Compare commits

..

5 Commits

Author SHA1 Message Date
Cursor Agent
893384a19d fix: load survey script directly via src instead of blob URL
Fixes FORMBRICKS-TQ

The blob URL approach was being blocked by CSP which doesn't include 'blob:'
in script-src directive. Simplified to load script directly via src attribute:
- Works with existing CSP ('self' is allowed)
- No need for fetch, blob URLs, or eval
- Properly executes and initializes window.formbricksSurveys
- Adds cache-busting in development for fresh script loads
2026-03-18 13:05:54 +00:00
Cursor Agent
1eba663294 chore: remove test files 2026-03-18 12:33:26 +00:00
Cursor Agent
c37e5c0750 chore: add blob URL test results screenshot - all tests passing 2026-03-18 12:32:43 +00:00
Cursor Agent
75e47b4979 fix: use blob URL to execute survey script and initialize window.formbricksSurveys
Fixes FORMBRICKS-TQ

The script content was being assigned to textContent which may not execute
properly in all contexts. Changed to use a Blob URL approach which:
- Creates a blob from the fetched script content
- Loads it via script src attribute (works with CSP without unsafe-eval)
- Properly waits for script execution before proceeding
- Ensures window.formbricksSurveys is initialized correctly
- Cleans up the blob URL after loading to prevent memory leaks
2026-03-18 12:31:17 +00:00
Cursor Agent
7504c47fc1 fix: execute survey script in global scope to initialize window.formbricksSurveys
Fixes FORMBRICKS-TQ

The script content was being assigned to textContent instead of being
executed, preventing the window.formbricksSurveys object from being
initialized. Changed to use indirect eval pattern to execute the script
content in the global scope, ensuring proper initialization.
2026-03-18 12:22:53 +00:00
350 changed files with 3862 additions and 10674 deletions

View File

@@ -94,12 +94,6 @@ 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
@@ -191,11 +185,6 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_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
@@ -242,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
LINGODOTDEV_API_KEY=your_api_key_here

2
.gitignore vendored
View File

@@ -45,7 +45,7 @@ yarn-error.log*
.direnv
# Playwright
**/test-results/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.

View File

@@ -41,14 +41,6 @@ The Open Source Qualtrics Alternative
<a href="https://trendshift.io/repositories/2570" target="_blank"><img src="https://trendshift.io/api/badge/repositories/2570" alt="Trendshift Badge for formbricks/formbricks" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
## multilanguage
- [English](#readme-en)
- [Deutsch](#readme-de)
- [Español](#readme-es)
<a id="readme-en"></a>
## ✨ About Formbricks
<img width="1527" alt="formbricks-sneak" src="https://github-production-user-asset-6210df.s3.amazonaws.com/675065/249441967-ccb89ea3-82b4-4bf2-8d2c-528721ec313b.png">
@@ -255,26 +247,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.
<a id="readme-de"></a>
## Deutsch
Formbricks ist eine freie, quelloffene und datenschutzorientierte Plattform für Surveys und Experience Management. Mit In-App-, Website-, Link- und E-Mail-Umfragen sammelt ihr Feedback entlang der gesamten User Journey.
- Website & Cloud: [formbricks.com](https://formbricks.com/) und [Cloud starten](https://app.formbricks.com/auth/signup)
- Self-Hosting: [Deployment-Dokumentation](https://formbricks.com/docs/self-hosting/deployment)
- Beitrag & Community: [Beitragen](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) und [Issues](https://github.com/formbricks/formbricks/issues)
- Sicherheit & Lizenz: [`SECURITY.md`](./SECURITY.md) und [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<a id="readme-es"></a>
## Español
Formbricks es una plataforma libre, de código abierto y centrada en la privacidad para encuestas y experience management. Permite recoger feedback durante todo el recorrido del usuario con encuestas dentro de la app, en sitios web, por enlace y por correo electrónico.
- Sitio web y Cloud: [formbricks.com](https://formbricks.com/) y [empezar en Cloud](https://app.formbricks.com/auth/signup)
- Self-Hosting: [documentación de despliegue](https://formbricks.com/docs/self-hosting/deployment)
- Contribución y comunidad: [guía para contribuir](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) e [Issues](https://github.com/formbricks/formbricks/issues)
- Seguridad y licencia: [`SECURITY.md`](./SECURITY.md) y [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<p align="right"><a href="#top">🔼 Back to top</a></p>

View File

@@ -1,6 +1,5 @@
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";
@@ -21,12 +20,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;

View File

@@ -1,7 +1,6 @@
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";
@@ -24,22 +23,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
throw new Error(t("common.environment_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);

View File

@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthorizationError } 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 AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
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 ResourceNotFoundError(t("common.organization"), params.organizationId);
throw new Error(t("common.organization_not_found"));
}
return (

View File

@@ -1,6 +1,5 @@
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";
@@ -29,7 +28,7 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
throw new Error(t("common.organization_not_found"));
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([

View File

@@ -1,7 +1,6 @@
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";
@@ -46,7 +45,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new ResourceNotFoundError(t("common.team"), null);
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();

View File

@@ -1,5 +1,4 @@
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";
@@ -18,13 +17,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
throw new Error(t("common.environment_not_found"));
}
return (

View File

@@ -2,11 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -50,7 +46,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);

View File

@@ -1,4 +1,3 @@
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";
@@ -43,7 +42,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_permission_not_found"));
}
return (

View File

@@ -11,6 +11,7 @@ import {
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -115,6 +116,13 @@ 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`,
@@ -122,7 +130,7 @@ export const MainNavigation = ({
isActive: pathname?.includes("/workspace"),
},
],
[t, environment.id, pathname]
[t, environment.id, pathname, isFormbricksCloud]
);
const dropdownNavigation = [

View File

@@ -1,5 +1,4 @@
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";
@@ -21,15 +20,15 @@ const AccountSettingsLayout = async (props: {
]);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
return <>{children}</>;

View File

@@ -1,6 +1,5 @@
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";
@@ -147,18 +146,18 @@ const Page = async (props: {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
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 AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
if (!memberships) {
throw new ResourceNotFoundError(t("common.membership"), null);
throw new Error(t("common.membership_not_found"));
}
if (user?.notificationSettings) {

View File

@@ -10,16 +10,15 @@ import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { EMAIL_VERIFICATION_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 { sendVerificationNewEmail } from "@/modules/email";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return {
@@ -86,15 +85,11 @@ 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 requestPasswordReset(ctx.user, "profile");
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;

View File

@@ -1,4 +1,3 @@
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";
@@ -29,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";

View File

@@ -1,5 +1,4 @@
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";
@@ -26,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);

View File

@@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -50,8 +49,7 @@ export const EnterpriseLicenseStatus = ({
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -99,7 +97,14 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
{t("common.updated_at")}{" "}
{new Date(lastChecked).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
</div>
@@ -127,7 +132,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",

View File

@@ -3,41 +3,13 @@
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 }),
@@ -46,55 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.action(
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"],
})
)
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;
})
);
const ZDeleteOrganizationAction = z.object({

View File

@@ -1,84 +0,0 @@
"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>
);
};

View File

@@ -11,7 +11,6 @@ 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";
@@ -61,11 +60,6 @@ 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}

View File

@@ -1,5 +1,4 @@
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";
@@ -18,15 +17,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
]);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
return <>{children}</>;

View File

@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
);
// Save settings to localStorage when they change
@@ -300,6 +300,7 @@ export const ResponseTable = ({
<DataTableSettingsModal
open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table}
columnOrder={columnOrder}
handleDragEnd={handleDragEnd}

View File

@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -35,7 +34,6 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -169,7 +167,6 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -221,7 +218,6 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt",
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
},
};

View File

@@ -1,4 +1,3 @@
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";
@@ -8,6 +7,7 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -23,24 +23,25 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
throw new Error(t("common.survey_not_found"));
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -49,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organization.id);
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
@@ -85,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={user.locale}
locale={locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}

View File

@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,14 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const parsedDate = new Date(value);
return (
formattedDate ??
t("common.invalid_date_with_value", {
value,
})
);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
@@ -60,7 +59,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
@@ -85,7 +84,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 md:px-6">
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>

View File

@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
@@ -10,11 +9,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate();
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), surveyId);
throw new Error("Survey not found");
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error("Workspace not found");
}
const styling = getStyling(project, survey);

View File

@@ -11,7 +11,8 @@ 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 { getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import {
getElementSummary,
getResponsesForSummary,
@@ -43,7 +44,7 @@ vi.mock("@/lib/survey/service", () => ({
}));
vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
@@ -228,6 +229,12 @@ 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", () => {
@@ -239,7 +246,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
ttc: { q1: 10 },
finished: false,
}, // Dropped at q2
{
@@ -262,55 +269,22 @@ describe("getSurveySummaryDropOff", () => {
);
expect(dropOff.length).toBe(2);
// Q1: welcome card disabled so impressions = displayCount
// Q1
expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount);
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = 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: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
// Q2
expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
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].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
expect(dropOff[1].ttc).toBe(10);
});
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)
test("handles logic jumps", () => {
const surveyWithLogic: TSurvey = {
...mockBaseSurvey,
blocks: [
@@ -341,6 +315,36 @@ 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",
@@ -373,21 +377,28 @@ 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", q4: "d" },
data: { q1: "a", q2: "b" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
ttc: { q1: 10, q2: 10 },
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,
@@ -396,11 +407,11 @@ describe("getSurveySummaryDropOff", () => {
1
);
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)
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
});
});

View File

@@ -11,6 +11,7 @@ import {
TResponseData,
TResponseFilterCriteria,
TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
@@ -36,7 +37,8 @@ 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 { getElementsFromBlocks } from "@/lib/survey/utils";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
@@ -91,13 +93,63 @@ export const getSurveySummaryMeta = (
};
};
// 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;
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 };
};
export const getSurveySummaryDropOff = (
@@ -118,8 +170,16 @@ 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 per element
// Calculate total time-to-completion
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
@@ -127,21 +187,51 @@ export const getSurveySummaryDropOff = (
}
});
// Count impressions based on actual interaction data (ttc + response data)
// instead of replaying survey logic which is unreliable with branching
let lastSeenIdx = -1;
let localSurvey = structuredClone(survey);
let localResponseData: TResponseData = { ...response.data };
let localVariables: TResponseVariables = {
...surveyVariablesData,
};
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (wasElementSeen(response, element.id)) {
impressionsArr[i]++;
lastSeenIdx = i;
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;
}
}
// Attribute drop-off to the last element the respondent interacted with
if (!response.finished && lastSeenIdx >= 0) {
dropOffArr[lastSeenIdx]++;
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++;
}
}
});
@@ -150,8 +240,6 @@ 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;
@@ -163,7 +251,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount;
} else {
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
}
for (let i = 1; i < elements.length; i++) {

View File

@@ -1,5 +1,4 @@
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";
@@ -33,13 +32,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
throw new Error(t("common.survey_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
@@ -47,11 +46,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
if (!organizationId) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organizationId);
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);

View File

@@ -2,16 +2,21 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
@@ -92,3 +97,68 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);

View File

@@ -1,7 +1,6 @@
"use client";
import clsx from "clsx";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -55,25 +54,6 @@ 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;
@@ -238,12 +218,7 @@ 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">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
Select,
SelectContent,
@@ -15,6 +14,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps {
environment: TEnvironment;

View File

@@ -1,6 +1,4 @@
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 {
@@ -12,10 +10,9 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
const resolvedParams = await params;
const survey = await getSurvey(resolvedParams.surveyId);
const t = await getTranslate();
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
throw new Error("Survey not found");
}
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;

View File

@@ -0,0 +1,208 @@
"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>
);
};

View File

@@ -0,0 +1,42 @@
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;

View File

@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>

View File

@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>

View File

@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET,
@@ -12,7 +11,7 @@ import {
} from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration, locale] = await Promise.all([
const [surveys, notionIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getUserLocale(session.user.id),
]);
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</PageContentWrapper>
);

View File

@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration, locale] = await Promise.all([
const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>

View File

@@ -6,10 +6,8 @@ import {
CHATWOOT_WEBSITE_TOKEN,
IS_CHATWOOT_CONFIGURED,
POSTHOG_KEY,
SESSION_MAX_AGE,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
@@ -25,7 +23,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
}
return (
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
<>
<NoMobileOverlay />
{POSTHOG_KEY && user && (
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
@@ -41,7 +39,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
)}
<ToasterClient />
{children}
</NextAuthProvider>
</>
);
};

View File

@@ -1,139 +0,0 @@
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",
}),
})
);
});
});

View File

@@ -6,26 +6,10 @@ 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 { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { TAuditStatus, 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;
@@ -33,6 +17,44 @@ 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;
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
const authMethod = getAuthMethod(account);
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -80,6 +102,15 @@ 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;
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
}
}
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",
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: "success",
userType: "user",
status,
userType: "user" as const,
newObject: {
...user,
authMethod: getAuthMethod(account),
authMethod,
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
...(error ? { errorMessage: error.message } : {}),
},
});
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};

View File

@@ -76,8 +76,7 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
};
const mockSurveys: TSurvey[] = [

View File

@@ -49,8 +49,7 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {

View File

@@ -1,324 +0,0 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockGetServerSession: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: mockAuthenticateRequest,
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
describe("withV3ApiWrapper", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
expect(authentication).toMatchObject({ user: { id: "user_1" } });
expect(requestId).toBe("req-1");
expect(instance).toBe("/api/v3/surveys");
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
headers: { "x-request-id": "req-1" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("req-1");
expect(handler).toHaveBeenCalledOnce();
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"user_1"
);
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
});
test("falls back to api key auth in both mode", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
environmentPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-api-key": "fbk_test" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"key_1"
);
expect(mockGetServerSession).not.toHaveBeenCalled();
});
test("returns 401 problem response when authentication is required but missing", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
});
test("returns 400 problem response for invalid query input", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
schemas: {
query: z.object({
limit: z.coerce.number().int().positive(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
headers: { "x-request-id": "req-invalid" },
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
expect(body.requestId).toBe("req-invalid");
});
test("parses body, repeated query params, and async route params", async () => {
const handler = vi.fn(async ({ parsedInput }) => {
expect(parsedInput).toEqual({
body: { name: "Survey API" },
query: { tag: ["a", "b"] },
params: { workspaceId: "ws_123" },
});
return Response.json(
{ ok: true },
{
headers: {
"X-Request-Id": "handler-request-id",
},
}
);
});
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
query: z.object({
tag: z.array(z.string()),
}),
params: z.object({
workspaceId: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
method: "POST",
body: JSON.stringify({ name: "Survey API" }),
headers: {
"Content-Type": "application/json",
},
}),
{
params: Promise.resolve({
workspaceId: "ws_123",
}),
} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
expect(handler).toHaveBeenCalledOnce();
});
test("returns 400 problem response for malformed JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{",
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "body",
reason: "Malformed JSON input, please check your request body",
},
]);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
params: z.object({
workspaceId: z.string().min(3),
}),
},
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
params: Promise.resolve({
workspaceId: "x",
}),
} as never);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => Response.json({ ok: true }),
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("60");
const body = await response.json();
expect(body.code).toBe("too_many_requests");
});
test("returns 500 problem response when the handler throws unexpectedly", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => {
throw new Error("boom");
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-request-id": "req-boom" },
}),
{} as never
);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.code).toBe("internal_server_error");
expect(body.requestId).toBe("req-boom");
});
});

View File

@@ -1,349 +0,0 @@
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
export type TV3Schemas = {
body?: TV3Schema;
query?: TV3Schema;
params?: TV3Schema;
};
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
}
: Record<string, never>;
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
parsedInput: TParsedInput;
requestId: string;
instance: string;
};
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
auth?: TV3AuthMode;
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
if (authMode === "session") {
return "Session required";
}
if (authMode === "apiKey") {
return "API key required";
}
return "Not authenticated";
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {};
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key);
query[key] = values.length > 1 ? values : (values[0] ?? "");
}
return query;
}
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
if (!authentication) {
return null;
}
if ("user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
if ("apiKeyId" in authentication) {
return authentication.apiKeyId;
}
return null;
}
function isPromiseLike<T>(value: unknown): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
if (!props || typeof props !== "object" || !("params" in props)) {
return {};
}
const params = (props as { params?: unknown }).params;
if (!params) {
return {};
}
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
return typeof resolvedParams === "object" && resolvedParams !== null
? (resolvedParams as Record<string, unknown>)
: {};
}
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
if (authMode === "none") {
return null;
}
if (authMode === "both" && req.headers.has("x-api-key")) {
const apiKeyAuth = await authenticateRequest(req);
if (apiKeyAuth) {
return apiKeyAuth;
}
}
if (authMode === "session" || authMode === "both") {
const session = await getServerSession(authOptions);
if (session?.user?.id) {
return session;
}
if (authMode === "session") {
return null;
}
}
if (authMode === "apiKey" || authMode === "both") {
return await authenticateRequest(req);
}
return null;
}
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
req: NextRequest,
props: TProps,
schemas: S | undefined,
requestId: string,
instance: string
): Promise<
| { ok: true; parsedInput: TV3ParsedInput<S> }
| {
ok: false;
response: Response;
}
> {
const parsedInput = {} as TV3ParsedInput<S>;
if (schemas?.body) {
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
}),
};
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: formatZodIssues(bodyResult.error, "body"),
}),
};
}
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
}
if (schemas?.query) {
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
if (!queryResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid query parameters", {
instance,
invalid_params: formatZodIssues(queryResult.error, "query"),
}),
};
}
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
}
if (schemas?.params) {
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
if (!paramsResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid route parameters", {
instance,
invalid_params: formatZodIssues(paramsResult.error, "params"),
}),
};
}
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
}
return { ok: true, parsedInput };
}
function ensureRequestIdHeader(response: Response, requestId: string): Response {
if (response.headers.get("X-Request-Id")) {
return response;
}
const headers = new Headers(response.headers);
headers.set("X-Request-Id", requestId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function authenticateV3RequestOrRespond(
req: NextRequest,
authMode: TV3AuthMode,
requestId: string,
instance: string
): Promise<
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
> {
const authentication = await authenticateV3Request(req, authMode);
if (!authentication && authMode !== "none") {
return {
authentication: null,
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
};
}
return {
authentication,
response: null,
};
}
async function applyV3RateLimitOrRespond(params: {
authentication: TV3Authentication;
enabled: boolean;
config: TRateLimitConfig;
requestId: string;
log: ReturnType<typeof logger.withContext>;
}): Promise<Response | null> {
const { authentication, enabled, config, requestId, log } = params;
if (!enabled) {
return null;
}
const identifier = getRateLimitIdentifier(authentication);
if (!identifier) {
return null;
}
try {
await applyRateLimit(config, identifier);
} catch (error) {
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
return problemTooManyRequests(
requestId,
error instanceof Error ? error.message : "Rate limit exceeded",
error instanceof TooManyRequestsError ? error.retryAfter : undefined
);
}
return null;
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const instance = req.nextUrl.pathname;
const log = logger.withContext({
requestId,
method: req.method,
path: instance,
});
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
if (authResult.response) {
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
return authResult.response;
}
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
if (!parsedInputResult.ok) {
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
return parsedInputResult.response;
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}
const response = await handler({
req,
props,
authentication: authResult.authentication,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
return ensureRequestIdHeader(response, requestId);
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
};
};

View File

@@ -1,274 +0,0 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
test("returns 401 when authentication is null", async () => {
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 401 when authentication is API key (no user)", async () => {
const result = await requireSessionWorkspaceAccess(
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
"proj_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_nonexistent",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
],
});
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"readWrite",
requestId
);
expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_abc",
organizationId: "org_1",
});
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
],
});
});
});
const keyBase = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_k",
organizationAccess: { accessControl: { read: true, write: false } },
};
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
return {
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_k",
projectName: "K",
permission,
};
}
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({
id: "env_k",
projectId: "proj_k",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
});
test("401 when authentication is null", async () => {
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
expect((r as Response).status).toBe(401);
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s",
projectId: "proj_s",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_s",
"read",
requestId
);
expect(r).toEqual({
environmentId: "env_s",
projectId: "proj_s",
organizationId: "org_s",
});
});
test("returns context for API key with read on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({
environmentId: "ws_a",
projectId: "proj_k",
organizationId: "org_k",
});
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
});
test("returns context for API key with write on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({
environmentId: "ws_b",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when API key permission is lower than the required permission", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key has no matching environment", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("other_env")],
};
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key permission is not list-eligible (runtime value)", async () => {
const auth = {
...keyBase,
environmentPermissions: [
{
...envPerm("ws_c"),
permission: "invalid" as unknown as ApiKeyPermission,
},
],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("returns context for API key with manage on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
expect(r).toEqual({
environmentId: "ws_m",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("401 when auth is neither session nor valid API key payload", async () => {
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
expect((r as Response).status).toBe(401);
});
});

View File

@@ -1,122 +0,0 @@
/**
* V3 API auth — session (browser) or API key with environment-scoped access.
*/
import { ApiKeyPermission } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
const grantedRank = {
[ApiKeyPermission.read]: 1,
[ApiKeyPermission.write]: 2,
[ApiKeyPermission.manage]: 3,
}[permission];
const requiredRank = {
read: 1,
readWrite: 2,
manage: 3,
}[minPermission];
return grantedRank >= requiredRank;
}
/**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/
export async function requireSessionWorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const userId = authentication.user.id;
const log = logger.withContext({ requestId, workspaceId });
try {
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
log.warn({ statusCode: 403, errorCode: err.name }, message);
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
export async function requireV3WorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
try {
const context = await resolveV3WorkspaceContext(workspaceId);
const permission = keyAuth.environmentPermissions.find(
(environmentPermission) => environmentPermission.environmentId === context.environmentId
);
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return context;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw error;
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}

View File

@@ -1,95 +0,0 @@
import { describe, expect, test } from "vitest";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
successListResponse,
} from "./response";
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
expect(res.headers.get("X-Request-Id")).toBe("rid");
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
test("problemUnauthorized default detail", async () => {
const res = problemUnauthorized("r1");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.detail).toBe("Not authenticated");
expect(body.code).toBe("not_authenticated");
});
test("problemForbidden", async () => {
const res = problemForbidden("r2", undefined, "/api/x");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(body.instance).toBe("/api/x");
});
test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
expect(body.detail).toBe("oops");
});
test("problemNotFound includes details", async () => {
const res = problemNotFound("r4", "Survey", "s1", "/s");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("not_found");
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
});
test("problemTooManyRequests with Retry-After", async () => {
const res = problemTooManyRequests("r5", "slow down", 60);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const body = await res.json();
expect(body.code).toBe("too_many_requests");
});
test("problemTooManyRequests without Retry-After", async () => {
const res = problemTooManyRequests("r6", "nope");
expect(res.headers.get("Retry-After")).toBeNull();
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
[{ a: 1 }],
{ limit: 10, nextCursor: "cursor-1" },
{
requestId: "req-x",
}
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-x");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: [{ a: 1 }],
meta: { limit: 10, nextCursor: "cursor-1" },
});
});
test("custom Cache-Control", async () => {
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});

View File

@@ -1,149 +0,0 @@
/**
* V3 API response helpers — RFC 9457 Problem Details (application/problem+json)
* and list envelope for success responses.
*/
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
requestId: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
};
export type ProblemBody = {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
} & ProblemExtension;
function problemResponse(
status: number,
title: string,
detail: string,
requestId: string,
options?: {
type?: string;
instance?: string;
code?: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
headers?: Record<string, string>;
}
): Response {
const body: ProblemBody = {
title,
status,
detail,
requestId,
...(options?.type && { type: options.type }),
...(options?.instance && { instance: options.instance }),
...(options?.code && { code: options.code }),
...(options?.details && { details: options.details }),
...(options?.invalid_params && { invalid_params: options.invalid_params }),
};
const headers: Record<string, string> = {
"Content-Type": PROBLEM_JSON,
"Cache-Control": CACHE_NO_STORE,
"X-Request-Id": requestId,
...options?.headers,
};
return Response.json(body, { status, headers });
}
export function problemBadRequest(
requestId: string,
detail: string,
options?: { invalid_params?: InvalidParam[]; instance?: string }
): Response {
return problemResponse(400, "Bad Request", detail, requestId, {
code: "bad_request",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
instance?: string
): Response {
return problemResponse(401, "Unauthorized", detail, requestId, {
code: "not_authenticated",
instance,
});
}
export function problemForbidden(
requestId: string,
detail: string = "You are not authorized to access this resource",
instance?: string
): Response {
return problemResponse(403, "Forbidden", detail, requestId, {
code: "forbidden",
instance,
});
}
/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
* Prefer problemForbidden with a generic message for those cases.
*/
export function problemNotFound(
requestId: string,
resourceType: string,
resourceId: string | null,
instance?: string
): Response {
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
code: "not_found",
details: { resource_type: resourceType, resource_id: resourceId },
instance,
});
}
export function problemInternalError(
requestId: string,
detail: string = "An unexpected error occurred.",
instance?: string
): Response {
return problemResponse(500, "Internal Server Error", detail, requestId, {
code: "internal_server_error",
instance,
});
}
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
const headers: Record<string, string> = {};
if (retryAfter !== undefined) {
headers["Retry-After"] = String(retryAfter);
}
return problemResponse(429, "Too Many Requests", detail, requestId, {
code: "too_many_requests",
headers,
});
}
export function successListResponse<T, TMeta extends Record<string, unknown>>(
data: T[],
meta: TMeta,
options?: { requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json({ data, meta }, { status: 200, headers });
}

View File

@@ -1,4 +0,0 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TV3Authentication = TAuthenticationApiKey | Session | null;

View File

@@ -1,38 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_xyz",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_xyz",
organizationId: "org_123",
});
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
});
test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
});
});

View File

@@ -1,50 +0,0 @@
/**
* V3 API workspace → internal IDs translation layer (retro-compatibility / future-proofing).
*
* Workspace is the default container for surveys. We are deprecating Environment and making
* Workspace that container. In the API, workspaceId refers to that container.
*
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
* (and derive environmentId or equivalent from it). Change only this file.
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
/**
* Internal IDs derived from a V3 workspace identifier.
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
*/
export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string;
/** Project ID used for projectTeam auth. */
projectId: string;
/** Organization ID used for org-level auth. */
organizationId: string;
};
/**
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
*
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
// Today: workspaceId is the environment id (survey container). Look it up.
const environment = await getEnvironment(workspaceId);
if (!environment) {
throw new ResourceNotFoundError("environment", workspaceId);
}
// Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return {
environmentId: workspaceId,
projectId: environment.projectId,
organizationId,
};
}

View File

@@ -1,122 +0,0 @@
import { describe, expect, test } from "vitest";
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
const wid = "clxx1234567890123456789012";
function params(qs: string): URLSearchParams {
return new URLSearchParams(qs);
}
describe("collectMultiValueQueryParam", () => {
test("merges repeated keys and comma-separated values", () => {
const sp = params("status=draft&status=inProgress&type=link,app");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
});
test("dedupes", () => {
const sp = params("status=draft&status=draft");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
});
});
describe("parseV3SurveysListQuery", () => {
test("rejects unsupported query parameters like filterCriteria", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
});
test("rejects unknown query parameters", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&foo=bar`));
expect(r.ok).toBe(false);
if (!r.ok)
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
test("rejects the legacy after query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&after=legacy-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects the legacy flat name query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&name=Foo`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("parses minimal query", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
`workspaceId=${wid}&filter[name][contains]=Foo&filter[status][in]=inProgress&filter[status][in]=draft&filter[type][in]=link&sortBy=updatedAt`
)
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.filterCriteria).toEqual({
name: "Foo",
status: ["inProgress", "draft"],
type: ["link"],
});
expect(r.sortBy).toBe("updatedAt");
}
});
test("invalid status", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[status][in]=notastatus`));
expect(r.ok).toBe(false);
});
test("rejects the createdBy filter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[createdBy][in]=you`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects an invalid cursor", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&cursor=not-a-real-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params).toEqual([
{
name: "cursor",
reason: "The cursor is invalid.",
},
]);
}
});
});

View File

@@ -1,159 +0,0 @@
/**
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
* Keeps HTTP parsing separate from the route handler and shared survey list service.
*/
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
type TSurveyFilterCriteria,
ZSurveyFilters,
ZSurveyStatus,
ZSurveyType,
} from "@formbricks/types/surveys/types";
import {
type TSurveyListPageCursor,
type TSurveyListSort,
decodeSurveyListPageCursor,
normalizeSurveyListSort,
} from "@/modules/survey/list/lib/survey-page";
const V3_SURVEYS_DEFAULT_LIMIT = 20;
const V3_SURVEYS_MAX_LIMIT = 100;
const FILTER_NAME_CONTAINS_QUERY_PARAM = "filter[name][contains]" as const;
const FILTER_STATUS_IN_QUERY_PARAM = "filter[status][in]" as const;
const FILTER_TYPE_IN_QUERY_PARAM = "filter[type][in]" as const;
const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
"sortBy",
] as const;
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
type InvalidParam = { name: string; reason: string };
/** Collect repeated query keys and comma-separated values for operator-style filters. */
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
const acc: string[] = [];
for (const raw of searchParams.getAll(key)) {
for (const part of raw.split(",")) {
const t = part.trim();
if (t) acc.push(t);
}
}
return [...new Set(acc)];
}
const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
.optional()
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
[FILTER_STATUS_IN_QUERY_PARAM]: z.array(ZSurveyStatus).optional(),
[FILTER_TYPE_IN_QUERY_PARAM]: z.array(ZSurveyType).optional(),
sortBy: ZSurveyFilters.shape.sortBy.optional(),
});
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
export type TV3SurveysListQueryParseResult =
| {
ok: true;
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
| { ok: false; invalid_params: InvalidParam[] };
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
const unsupportedParams = [
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
];
return unsupportedParams.map((name) => ({
name,
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
}));
}
function buildFilterCriteria(q: TV3SurveysListQuery): TSurveyFilterCriteria | undefined {
const f: TSurveyFilterCriteria = {};
if (q[FILTER_NAME_CONTAINS_QUERY_PARAM]) f.name = q[FILTER_NAME_CONTAINS_QUERY_PARAM];
if (q[FILTER_STATUS_IN_QUERY_PARAM]?.length) f.status = q[FILTER_STATUS_IN_QUERY_PARAM];
if (q[FILTER_TYPE_IN_QUERY_PARAM]?.length) f.type = q[FILTER_TYPE_IN_QUERY_PARAM];
return Object.keys(f).length > 0 ? f : undefined;
}
export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3SurveysListQueryParseResult {
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
if (unsupportedQueryParams.length > 0) {
return {
ok: false,
invalid_params: unsupportedQueryParams,
};
}
const statusVals = collectMultiValueQueryParam(searchParams, FILTER_STATUS_IN_QUERY_PARAM);
const typeVals = collectMultiValueQueryParam(searchParams, FILTER_TYPE_IN_QUERY_PARAM);
const raw = {
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
sortBy: searchParams.get("sortBy")?.trim() || undefined,
};
const result = ZV3SurveysListQuery.safeParse(raw);
if (!result.success) {
return {
ok: false,
invalid_params: result.error.issues.map((issue) => ({
name: issue.path.join(".") || "query",
reason: issue.message,
})),
};
}
const q = result.data;
const sortBy = normalizeSurveyListSort(q.sortBy);
let cursor: TSurveyListPageCursor | null = null;
if (q.cursor) {
try {
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
} catch (error) {
return {
ok: false,
invalid_params: [
{
name: "cursor",
reason: error instanceof Error ? error.message : "The cursor is invalid.",
},
],
};
}
}
return {
ok: true,
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
}

View File

@@ -1,357 +0,0 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
return {
...actual,
getSurveyListPage: vi.fn(),
};
});
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
getSurveyCount: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when the createdBy filter is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, nextCursor, and totalCount in meta", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: "cursor-123",
});
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 10,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
],
nextCursor: null,
});
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveyListPage", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});

View File

@@ -1,81 +0,0 @@
/**
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successListResponse,
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
handler: async ({ req, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId });
try {
const searchParams = new URL(req.url).searchParams;
const parsed = parseV3SurveysListQuery(searchParams);
if (!parsed.ok) {
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
return problemBadRequest(requestId, "Invalid query parameters", {
invalid_params: parsed.invalid_params,
instance,
});
}
const authResult = await requireV3WorkspaceAccess(
authentication,
parsed.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});

View File

@@ -1,18 +0,0 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
return {
...rest,
workspaceId: environmentId,
};
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({
userEmail,
userName,
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => {
const $chatwoot = getChatwoot();
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
});
userSetRef.current = true;
}
}, [userId, userEmail, userName, getChatwoot]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready
if (getChatwoot()) {
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = getChatwoot();
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
scriptElement?.remove();
userSetRef.current = false;
customerStatusSetRef.current = false;
};
}, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};

View File

@@ -1,18 +0,0 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});

View File

@@ -1,21 +0,0 @@
import type { TUserLocale } from "@formbricks/types/user";
import { getTranslate } from "@/lingodotdev/server";
interface NoScriptWarningProps {
locale: TUserLocale;
}
export const NoScriptWarning = async ({ locale }: NoScriptWarningProps) => {
const t = await getTranslate(locale);
return (
<noscript>
<div className="fixed inset-0 z-[9999] flex h-dvh w-full items-center justify-center bg-slate-50">
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
<h1 className="mb-4 text-2xl font-bold text-slate-800">{t("common.javascript_required")}</h1>
<p className="text-slate-600">{t("common.javascript_required_description")}</p>
</div>
</div>
</noscript>
);
};

View File

@@ -1,6 +1,5 @@
import { Metadata } from "next";
import React from "react";
import { NoScriptWarning } from "@/app/components/NoScriptWarning";
import { SentryProvider } from "@/app/sentry/SentryProvider";
import {
DEFAULT_LOCALE,
@@ -27,7 +26,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
<NoScriptWarning locale={locale} />
<SentryProvider
sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE}

View File

@@ -421,38 +421,6 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
status: 401,
headers: { "Content-Type": "application/problem+json" },
});
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({
handler,
unauthenticatedResponse: () => custom401,
});
const res = await wrapped(req, undefined);
expect(res).toBe(custom401);
expect(handler).not.toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =

View File

@@ -38,11 +38,6 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
action?: TAuditAction;
targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig;
/**
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
}
enum ApiV1RouteTypeEnum {
@@ -270,7 +265,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
const { handler, action, targetType, customRateLimitConfig } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -292,11 +287,6 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
const res = unauthenticatedResponse(req);
await processResponse(res, req, auditLog);
return res;
}
return responses.notAuthenticatedResponse();
}

View File

@@ -90,17 +90,6 @@ describe("endpoint-validator", () => {
});
describe("isManagementApiRoute", () => {
test("should return Both for v3 surveys routes", () => {
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true,

View File

@@ -22,9 +22,6 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
export const isManagementApiRoute = (
url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks"))

View File

@@ -140,7 +140,6 @@ checksums:
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
common/connected: aa0ceca574641de34c74b9e590664230
common/contact: 9afa39bc47019ee6dec6c74b6273967c
common/contacts: d5b6c3f890b3904eaf5754081945c03d
common/continue: 3cfba90b4600131e82fc4260c568d044
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
@@ -148,7 +147,6 @@ checksums:
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/copy_to_environment: c482d26b8fd4962af6542bbf49e49a32
common/count_attributes: 48805e836a9b50f9635ad00fed953058
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
@@ -188,12 +186,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
@@ -230,13 +228,11 @@ checksums:
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313
common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e
common/invite: 181884cea804cbde665f160811ee7ad0
common/invite_them: d4b7aadbd3c924b04ad4fce419709f10
common/javascript_required: d7988e5934af4d0df54fda369c0e4fb6
common/javascript_required_description: 4b65f456db79af4898888a3dd034fe2f
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
@@ -257,9 +253,7 @@ 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
@@ -299,9 +293,10 @@ 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
@@ -360,7 +355,6 @@ checksums:
common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_language: d75cf5fbce8a4c7a9055e2210af74480
common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -373,6 +367,7 @@ 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
@@ -392,6 +387,7 @@ 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,6 +402,7 @@ 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
@@ -429,6 +426,7 @@ 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
@@ -443,13 +441,15 @@ checksums:
common/website_survey: 17513d25a07b6361768a15ec622b021b
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
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
@@ -625,6 +625,7 @@ 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
@@ -804,16 +805,9 @@ 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
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
@@ -1068,13 +1062,6 @@ 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
@@ -1355,6 +1342,7 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
@@ -1616,8 +1604,6 @@ 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
@@ -1646,13 +1632,13 @@ checksums:
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
@@ -2464,8 +2450,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: 0cb1260dd25e94f56c2da7ab21b0e0ae
templates/csat_question_2_choice_2: f12ed9d98c7965ab949efcc25f8ca85e
templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab
@@ -3187,3 +3173,14 @@ 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

View File

@@ -1,19 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
export const onRequestError: Instrumentation.onRequestError = (...args) => {
const [error] = args;
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
// These are handled gracefully in the UI and don't need server-side Sentry reporting
if (error instanceof Error && isExpectedError(error)) {
return;
}
Sentry.captureRequestError(...args);
};
export const onRequestError = Sentry.captureRequestError;
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {

View File

@@ -1,97 +0,0 @@
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");
});
});

View File

@@ -20,36 +20,3 @@ 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;
}
};

View File

@@ -26,10 +26,7 @@ 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);

View File

@@ -1,77 +0,0 @@
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");
});
});

View File

@@ -15,9 +15,7 @@ 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(),
@@ -62,7 +60,6 @@ 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()
@@ -144,9 +141,7 @@ 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,
@@ -186,7 +181,6 @@ 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,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,

View File

@@ -84,9 +84,7 @@ 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.toLowerCase() === languageCode.toLowerCase()
);
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
return language?.default ? "default" : language?.language.code || "default";
};

View File

@@ -1,7 +1,7 @@
"use server";
import "server-only";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthorizationError } 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 ResourceNotFoundError("Organization", null);
throw new Error("Organization not found");
}
const currentUserMembership = await getMembershipRole(userId, organization.id);

View File

@@ -37,8 +37,7 @@ describe("auth", () => {
},
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);

View File

@@ -72,8 +72,7 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
whitelabel: false,
};
@@ -125,8 +124,7 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
whitelabel: false,
},
];
@@ -178,8 +176,7 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: expectedBilling,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
whitelabel: false,
};
@@ -238,8 +235,7 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
projects: [
@@ -280,8 +276,7 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: expect.any(Date),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({

View File

@@ -34,8 +34,7 @@ export const select = {
stripe: true,
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
isAIEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -73,8 +72,7 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
updatedAt: organization.updatedAt,
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
isAIEnabled: organization.isAIEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});

View File

@@ -378,7 +378,7 @@ export const getResponseDownloadFile = async (
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
if (!organizationId) {
throw new ResourceNotFoundError("Organization", null);
throw new Error("Organization ID not found");
}
const organizationBilling = await getOrganizationBilling(organizationId);

View File

@@ -232,8 +232,7 @@ export const mockOrganizationOutput: TOrganization = {
name: "mock Organization",
createdAt: currentDate,
updatedAt: currentDate,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
billing: {
stripeCustomerId: null,
limits: {

View File

@@ -1,13 +1,62 @@
import { describe, expect, test } from "vitest";
import {
convertDateString,
convertDateTimeString,
convertDateTimeStringShort,
convertDatesInObject,
convertTimeString,
formatDate,
getTodaysDateFormatted,
getTodaysDateTimeFormatted,
timeSince,
timeSinceDate,
} from "./time";
describe("Time Utilities", () => {
describe("convertDateString", () => {
test("should format date string correctly", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
test("should return null for null input", () => {
expect(convertDateString(null as any)).toBe(null);
});
test("should handle invalid date strings", () => {
expect(convertDateString("not-a-date")).toBe("Invalid Date");
});
});
describe("convertDateTimeString", () => {
test("should format date and time string correctly", () => {
expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeString("")).toBe("");
});
});
describe("convertDateTimeStringShort", () => {
test("should format date and time string in short format", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe("");
});
});
describe("convertTimeString", () => {
test("should format time string correctly", () => {
expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM");
});
});
describe("timeSince", () => {
test("should format time since in English", () => {
const now = new Date();
@@ -26,18 +75,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
});
test("should format time since in Brazilian Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
});
test("should format time since in European Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
});
});
describe("timeSinceDate", () => {
@@ -46,12 +83,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
});
test("should format time since from Date object in the provided locale", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
});
});
describe("formatDate", () => {
@@ -59,17 +90,13 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024");
});
});
test("should format date with the provided locale", () => {
const date = new Date(2024, 2, 20);
expect(formatDate(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
describe("getTodaysDateFormatted", () => {
test("should format today's date with specified separator", () => {
const today = new Date();
const expected = today.toISOString().split("T")[0].split("-").join(".");
expect(getTodaysDateFormatted(".")).toBe(expected);
});
});

View File

@@ -1,33 +1,120 @@
import { type Locale, formatDistance } from "date-fns";
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
const DEFAULT_LOCALE: TUserLocale = "en-US";
const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"de-DE": de,
"en-US": enUS,
"es-ES": es,
"fr-FR": fr,
"hu-HU": hu,
"ja-JP": ja,
"nl-NL": nl,
"pt-BR": ptBR,
"pt-PT": pt,
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
export const convertDateString = (dateString: string | null) => {
if (dateString === null) return null;
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return intlFormat(
date,
{
year: "numeric",
month: "short",
day: "numeric",
},
{
locale: "en",
}
);
};
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale);
export const convertDateTimeString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
/** Maps locale strings to date-fns locales and falls back to English for unsupported inputs. */
const getLocaleForTimeSince = (locale: string): Locale =>
isUserLocale(locale) ? TIME_SINCE_LOCALES[locale] : enUS;
export const convertDateTimeStringShort = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
{
hour: "numeric",
minute: "2-digit",
second: "2-digit",
},
{
locale: "en",
}
);
};
const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) {
case "de-DE":
return de;
case "en-US":
return enUS;
case "es-ES":
return es;
case "fr-FR":
return fr;
case "hu-HU":
return hu;
case "ja-JP":
return ja;
case "nl-NL":
return nl;
case "pt-BR":
return ptBR;
case "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
}
};
export const timeSince = (dateString: string, locale: TUserLocale) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
@@ -35,21 +122,27 @@ export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) =
});
};
export const timeSinceDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
export const timeSinceDate = (date: Date) => {
return formatDistance(date, new Date(), {
addSuffix: true,
locale: getLocaleForTimeSince(locale),
});
};
export const formatDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
return formatDateForDisplay(date, locale, {
export const formatDate = (date: Date) => {
return intlFormat(date, {
year: "numeric",
month: "long",
day: "numeric",
});
};
export const getTodaysDateFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
return formattedDate;
};
export const getTodaysDateTimeFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);

View File

@@ -70,8 +70,7 @@ describe("User Service", () => {
},
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
},
{
id: "org2",
@@ -88,8 +87,7 @@ describe("User Service", () => {
},
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
isAIEnabled: false,
},
];

View File

@@ -6,9 +6,7 @@ import {
AuthenticationError,
AuthorizationError,
EXPECTED_ERROR_NAMES,
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidInputError,
InvalidPasswordResetTokenError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
@@ -73,7 +71,6 @@ describe("isExpectedError (shared helper)", () => {
"AuthenticationError",
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -90,7 +87,6 @@ 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);
@@ -178,14 +174,6 @@ 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", () => {

View File

@@ -1,67 +0,0 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
describe("date display utils", () => {
test("parses ISO stored dates", () => {
const parsedDate = parseStoredDateValue("2025-05-06");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses legacy stored dates using the element format", () => {
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses day-first stored dates when no format is provided", () => {
const parsedDate = parseStoredDateValue("06-05-2025");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("formats stored dates using the selected locale", () => {
const date = new Date(2025, 4, 6);
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("returns null for invalid stored dates", () => {
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
});
test("builds a date format map for survey date elements", () => {
const elements = [
{
id: "dateQuestion",
type: "date",
format: "d-M-y",
},
{
id: "textQuestion",
type: "openText",
},
] as TSurveyElement[];
expect(getSurveyDateFormatMap(elements)).toEqual({
dateQuestion: "d-M-y",
});
});
});

View File

@@ -1,85 +0,0 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
const ISO_STORED_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const buildDate = (year: number, month: number, day: number): Date | null => {
if ([year, month, day].some((value) => Number.isNaN(value))) {
return null;
}
const parsedDate = new Date(year, month - 1, day);
if (
parsedDate.getFullYear() !== year ||
parsedDate.getMonth() !== month - 1 ||
parsedDate.getDate() !== day
) {
return null;
}
return parsedDate;
};
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
const parts = value.split("-");
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
return null;
}
const [first, second, third] = parts.map(Number);
switch (format) {
case "M-d-y":
return buildDate(third, first, second);
case "d-M-y":
return buildDate(third, second, first);
case "y-M-d":
return buildDate(first, second, third);
}
};
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
const isoMatch = ISO_STORED_DATE_PATTERN.exec(value);
if (isoMatch) {
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
}
if (format) {
return parseLegacyStoredDateValue(value, format);
}
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
return parseLegacyStoredDateValue(value, "d-M-y");
}
return null;
};
export const formatStoredDateForDisplay = (
value: string,
format: TSurveyDateElement["format"] | undefined,
locale: string = "en-US"
): string | null => {
const parsedDate = parseStoredDateValue(value, format);
if (!parsedDate) {
return null;
}
return formatDateWithOrdinal(parsedDate, locale);
};
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
if (element.type === "date") {
dateFormats[element.id] = element.format;
}
return dateFormats;
}, {});
};

View File

@@ -1,12 +1,5 @@
import { describe, expect, test } from "vitest";
import {
diffInDays,
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
getFormattedDateTimeString,
isValidDateString,
} from "./datetime";
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => {
@@ -15,45 +8,13 @@ describe("datetime utils", () => {
expect(diffInDays(date1, date2)).toBe(5);
});
test("formatDateWithOrdinal formats a date using the provided locale", () => {
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
// Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateWithOrdinal(date)).toBe(
new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("formatDateForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateForDisplay(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("formatDateTimeForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
// Test the function
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
});
test("isValidDateString validates correct date strings", () => {

View File

@@ -1,17 +1,7 @@
const DEFAULT_LOCALE = "en-US";
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
// Helper function to calculate difference in days between two dates
@@ -20,44 +10,23 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const formatDateForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateTimeForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
return formatDateForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
const day = date.getDate();
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
const year = date.getFullYear();
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};
export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
if (!regex.test(value)) {
return false;
}
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value)
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1")
: value;
const date = new Date(normalizedValue);
return !Number.isNaN(date.getTime());
const date = new Date(value);
return date;
};
export const getFormattedDateTimeString = (date: Date): string => {

View File

@@ -32,17 +32,16 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/utils/date-display", () => ({
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => {
if (value === "2023-01-01") {
return `formatted-${locale}-${format ?? "iso"}`;
vi.mock("@/lib/utils/datetime", () => ({
isValidDateString: vi.fn((value) => {
try {
return !isNaN(new Date(value as string).getTime());
} catch {
return false;
}
if (value === "01-02-2023" && format === "M-d-y") {
return `legacy-${locale}-${format}`;
}
return null;
}),
formatDateWithOrdinal: vi.fn(() => {
return "January 1st, 2023";
}),
}));
@@ -478,20 +477,7 @@ describe("recall utility functions", () => {
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on formatted-en-US-iso");
});
test("formats legacy date values using the provided locale and stored format", () => {
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
const responseData: TResponseData = {
joinDate: "01-02-2023",
};
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
joinDate: "M-d-y",
});
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
expect(result).toBe("You joined on January 1st, 2023");
});
test("formats array values as comma-separated list", () => {

View File

@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
[id: string]: string;
@@ -224,9 +224,7 @@ export const parseRecallInfo = (
text: string,
responseData?: TResponseData,
variables?: TResponseVariables,
withSlash: boolean = false,
locale: string = "en-US",
dateFormats?: TSurveyDateFormatMap
withSlash: boolean = false
) => {
let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : [];
@@ -256,14 +254,12 @@ export const parseRecallInfo = (
value = responseData[recallItemId];
// Apply formatting for special value types
if (typeof value === "string") {
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
if (formattedDate) {
value = formattedDate;
if (value) {
if (isValidDateString(value as string)) {
value = formatDateWithOrdinal(new Date(value as string));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
}

View File

@@ -9,10 +9,6 @@ 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);
@@ -298,78 +294,4 @@ 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"
);
});
});
});

View File

@@ -1,7 +1,6 @@
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",
@@ -140,10 +139,8 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
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");
}
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
@@ -152,17 +149,12 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
if (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 {
@@ -176,11 +168,9 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
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");
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};

View File

@@ -167,7 +167,6 @@
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
"connected": "Verbunden",
"contact": "Kontakt",
"contacts": "Kontakte",
"continue": "Weitermachen",
"copied": "Kopiert",
@@ -175,7 +174,6 @@
"copy": "Kopieren",
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"copy_to_environment": "In {{environment}} kopieren",
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
@@ -215,12 +213,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.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inaktive Umfragen",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date_with_value": "Ungültiges Datum: {value}",
"invalid_date": "Ungültiges Datum",
"invalid_file_name": "Ungültiger Dateiname, bitte benennen Sie Ihre Datei um und versuchen Sie es erneut",
"invalid_file_type": "Ungültiger Dateityp",
"invite": "Einladen",
"invite_them": "Lade sie ein",
"javascript_required": "JavaScript erforderlich",
"javascript_required_description": "Formbricks benötigt JavaScript, um ordnungsgemäß zu funktionieren. Bitte aktiviere JavaScript in deinen Browsereinstellungen, um fortzufahren.",
"key": "Schlüssel",
"label": "Bezeichnung",
"language": "Sprache",
@@ -284,9 +280,7 @@
"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!",
@@ -326,9 +320,10 @@
"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",
@@ -387,7 +382,6 @@
"select": "Auswählen",
"select_all": "Alles auswählen",
"select_filter": "Filter auswählen",
"select_language": "Sprache auswählen",
"select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
@@ -400,6 +394,7 @@
"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",
@@ -419,6 +414,7 @@
"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,6 +429,7 @@
"team_name": "Teamname",
"team_role": "Team-Rolle",
"teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
"time_to_finish": "Zeit zum Fertigstellen",
@@ -456,6 +453,7 @@
"url": "URL",
"user": "Benutzer",
"user_id": "Benutzer-ID",
"user_not_found": "Benutzer nicht gefunden",
"variable": "Variable",
"variable_ids": "Variablen-IDs",
"variables": "Variablen",
@@ -470,13 +468,15 @@
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workspace": "Arbeitsbereich",
"workflows": "Workflows",
"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",
@@ -505,7 +505,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 {minutes} Minuten gültig.",
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden 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",
@@ -661,6 +661,7 @@
"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",
@@ -851,16 +852,9 @@
"created_by_third_party": "Erstellt von einer dritten Partei",
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"endpoint_bad_gateway_error": "Ungültiges Gateway (502): Proxy-/Gateway-Fehler, Dienst nicht erreichbar",
"endpoint_gateway_timeout_error": "Gateway-Zeitüberschreitung (504): Gateway-Zeitüberschreitung, Dienst nicht erreichbar",
"endpoint_internal_server_error": "Interner Serverfehler (500): Der Dienst ist auf einen unerwarteten Fehler gestoßen",
"endpoint_method_not_allowed_error": "Methode nicht erlaubt (405): Der Endpoint existiert, akzeptiert aber keine POST-Anfragen",
"endpoint_not_found_error": "Nicht gefunden (404): Der Endpoint existiert nicht",
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"endpoint_service_unavailable_error": "Dienst nicht verfügbar (503): Dienst ist vorübergehend nicht verfügbar",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"no_triggers": "Keine Trigger",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt",
@@ -1129,13 +1123,6 @@
"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.",
@@ -1426,6 +1413,7 @@
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
@@ -1689,8 +1677,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"reverse_order_occasionally": "Reihenfolge gelegentlich umkehren",
"reverse_order_occasionally_except_last": "Reihenfolge gelegentlich umkehren, außer letzter",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
@@ -1719,13 +1705,13 @@
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"shrink_preview": "Vorschau verkleinern",
"simple": "Einfach",
"six_points": "6 Punkte",
"smiley": "Smiley",
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
"spam_protection_threshold_heading": "Antwortschwelle",
"shrink_preview": "Vorschau verkleinern",
"star": "Stern",
"starts_with": "Fängt an mit",
"state": "Bundesland",
@@ -2619,8 +2605,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": "Sehr zufrieden",
"csat_question_2_choice_2": "Etwas zufrieden",
"csat_question_2_choice_1": "Etwas zufrieden",
"csat_question_2_choice_2": "Sehr zufrieden",
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
"csat_question_2_choice_4": "Etwas unzufrieden",
"csat_question_2_choice_5": "Sehr unzufrieden",
@@ -3342,5 +3328,18 @@
"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!"
}
}

View File

@@ -167,7 +167,6 @@
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
"connected": "Connected",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continue",
"copied": "Copied",
@@ -175,7 +174,6 @@
"copy": "Copy",
"copy_code": "Copy code",
"copy_link": "Copy Link",
"copy_to_environment": "Copy to {{environment}}",
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
@@ -215,12 +213,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.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inactive surveys",
"integration": "integration",
"integrations": "Integrations",
"invalid_date_with_value": "Invalid date: {value}",
"invalid_date": "Invalid date",
"invalid_file_name": "Invalid file name, please rename your file and try again",
"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",
@@ -284,9 +280,7 @@
"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!",
@@ -326,9 +320,10 @@
"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",
@@ -387,7 +382,6 @@
"select": "Select",
"select_all": "Select all",
"select_filter": "Select filter",
"select_language": "Select Language",
"select_survey": "Select Survey",
"select_teams": "Select teams",
"selected": "Selected",
@@ -400,6 +394,7 @@
"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",
@@ -419,6 +414,7 @@
"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,6 +429,7 @@
"team_name": "Team name",
"team_role": "Team role",
"teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
"time_to_finish": "Time to finish",
@@ -456,6 +453,7 @@
"url": "URL",
"user": "User",
"user_id": "User ID",
"user_not_found": "User not found",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
@@ -470,13 +468,15 @@
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"workspace": "Workspace",
"workflows": "Workflows",
"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",
@@ -505,7 +505,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 {minutes} minutes.",
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"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",
@@ -661,6 +661,7 @@
"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",
@@ -851,16 +852,9 @@
"created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_bad_gateway_error": "Bad Gateway (502): Proxy/gateway error, service not reachable",
"endpoint_gateway_timeout_error": "Gateway Timeout (504): Gateway timeout, service not reachable",
"endpoint_internal_server_error": "Internal Server Error (500): The service encountered an unexpected error",
"endpoint_method_not_allowed_error": "Method Not Allowed (405): The endpoint exists, but doesn't accept POST requests",
"endpoint_not_found_error": "Not Found (404): The endpoint doesn't exist",
"endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!",
"endpoint_service_unavailable_error": "Service Unavailable (503): Service is temporarily down",
"learn_to_verify": "Learn how to verify webhook signatures",
"no_triggers": "No Triggers",
"please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL",
"response_created": "Response Created",
@@ -1129,13 +1123,6 @@
"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.",
@@ -1426,6 +1413,7 @@
"custom_hostname": "Custom hostname",
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
@@ -1689,8 +1677,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options",
"reverse_order_occasionally": "Reverse order occasionally",
"reverse_order_occasionally_except_last": "Reverse order occasionally except last",
"roundness": "Roundness",
"roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1719,13 +1705,13 @@
"show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"shrink_preview": "Shrink Preview",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Smiley",
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
"spam_protection_threshold_heading": "Response threshold",
"shrink_preview": "Shrink Preview",
"star": "Star",
"starts_with": "Starts with",
"state": "State",
@@ -2619,8 +2605,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": "Very satisfied",
"csat_question_2_choice_2": "Somewhat satisfied",
"csat_question_2_choice_1": "Somewhat satisfied",
"csat_question_2_choice_2": "Very satisfied",
"csat_question_2_choice_3": "Neither satisfied nor dissatisfied",
"csat_question_2_choice_4": "Somewhat dissatisfied",
"csat_question_2_choice_5": "Very dissatisfied",
@@ -3342,5 +3328,18 @@
"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!"
}
}

View File

@@ -167,7 +167,6 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
@@ -175,7 +174,6 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar enlace",
"copy_to_environment": "Copiar a {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
@@ -215,12 +213,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.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Encuestas inactivas",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date_with_value": "Fecha no válida: {value}",
"invalid_date": "Fecha no válida",
"invalid_file_name": "Nombre de archivo no válido, por favor renombre su archivo e inténtelo de nuevo",
"invalid_file_type": "Tipo de archivo no válido",
"invite": "Invitar",
"invite_them": "Invítales",
"javascript_required": "Se requiere JavaScript",
"javascript_required_description": "Formbricks requiere JavaScript para funcionar correctamente. Por favor, activa JavaScript en la configuración de tu navegador para continuar.",
"key": "Clave",
"label": "Etiqueta",
"language": "Idioma",
@@ -284,9 +280,7 @@
"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!",
@@ -326,9 +320,10 @@
"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",
@@ -387,7 +382,6 @@
"select": "Seleccionar",
"select_all": "Seleccionar todo",
"select_filter": "Seleccionar filtro",
"select_language": "Seleccionar idioma",
"select_survey": "Seleccionar encuesta",
"select_teams": "Seleccionar equipos",
"selected": "Seleccionado",
@@ -400,6 +394,7 @@
"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",
@@ -419,6 +414,7 @@
"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,6 +429,7 @@
"team_name": "Nombre del equipo",
"team_role": "Rol del equipo",
"teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
"time_to_finish": "Tiempo para finalizar",
@@ -456,6 +453,7 @@
"url": "URL",
"user": "Usuario",
"user_id": "ID de usuario",
"user_not_found": "Usuario no encontrado",
"variable": "Variable",
"variable_ids": "IDs de variables",
"variables": "Variables",
@@ -470,13 +468,15 @@
"website_survey": "Encuesta de sitio web",
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workspace": "Espacio de trabajo",
"workflows": "Flujos 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ú",
@@ -505,7 +505,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 {minutes} minutos.",
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"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",
@@ -661,6 +661,7 @@
"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",
@@ -851,16 +852,9 @@
"created_by_third_party": "Creado por un tercero",
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
"endpoint_bad_gateway_error": "Puerta de enlace incorrecta (502): Error de proxy o puerta de enlace, servicio no accesible",
"endpoint_gateway_timeout_error": "Tiempo de espera de la puerta de enlace agotado (504): Tiempo de espera de la puerta de enlace agotado, servicio no accesible",
"endpoint_internal_server_error": "Error interno del servidor (500): El servicio encontró un error inesperado",
"endpoint_method_not_allowed_error": "Método no permitido (405): El endpoint existe, pero no acepta solicitudes POST",
"endpoint_not_found_error": "No encontrado (404): El endpoint no existe",
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"endpoint_service_unavailable_error": "Servicio no disponible (503): El servicio está temporalmente caído",
"learn_to_verify": "Aprende a verificar las firmas de webhook",
"no_triggers": "Sin activadores",
"please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada",
@@ -1129,13 +1123,6 @@
"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.",
@@ -1426,6 +1413,7 @@
"custom_hostname": "Nombre de host personalizado",
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
@@ -1689,8 +1677,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).",
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta",
"reverse_order_occasionally": "Invertir orden ocasionalmente",
"reverse_order_occasionally_except_last": "Invertir orden ocasionalmente excepto el último",
"roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
@@ -1719,13 +1705,13 @@
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
"shrink_preview": "Contraer vista previa",
"simple": "Simple",
"six_points": "6 puntos",
"smiley": "Emoticono",
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
"spam_protection_threshold_heading": "Umbral de respuesta",
"shrink_preview": "Contraer vista previa",
"star": "Estrella",
"starts_with": "Comienza con",
"state": "Estado",
@@ -2619,8 +2605,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": "Muy satisfecho",
"csat_question_2_choice_2": "Algo satisfecho",
"csat_question_2_choice_1": "Algo satisfecho",
"csat_question_2_choice_2": "Muy satisfecho",
"csat_question_2_choice_3": "Ni satisfecho ni insatisfecho",
"csat_question_2_choice_4": "Algo insatisfecho",
"csat_question_2_choice_5": "Muy insatisfecho",
@@ -3342,5 +3328,18 @@
"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!"
}
}

View File

@@ -167,7 +167,6 @@
"connect": "Connecter",
"connect_formbricks": "Connecter Formbricks",
"connected": "Connecté",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continuer",
"copied": "Copié",
@@ -175,7 +174,6 @@
"copy": "Copier",
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"copy_to_environment": "Copier vers {{environment}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
@@ -215,12 +213,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.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Sondages inactifs",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date_with_value": "Date invalide: {value}",
"invalid_date": "Date invalide",
"invalid_file_name": "Nom de fichier invalide, veuillez renommer votre fichier et réessayer",
"invalid_file_type": "Type de fichier invalide",
"invite": "Inviter",
"invite_them": "Invitez-les",
"javascript_required": "JavaScript requis",
"javascript_required_description": "Formbricks nécessite JavaScript pour fonctionner correctement. Veuillez activer JavaScript dans les paramètres de votre navigateur pour continuer.",
"key": "Clé",
"label": "Étiquette",
"language": "Langue",
@@ -284,9 +280,7 @@
"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!",
@@ -326,9 +320,10 @@
"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",
@@ -387,7 +382,6 @@
"select": "Sélectionner",
"select_all": "Sélectionner tout",
"select_filter": "Sélectionner un filtre",
"select_language": "Sélectionner la langue",
"select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
@@ -400,6 +394,7 @@
"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",
@@ -419,6 +414,7 @@
"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,6 +429,7 @@
"team_name": "Nom de l'équipe",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
"time_to_finish": "Temps de finir",
@@ -456,6 +453,7 @@
"url": "URL",
"user": "Utilisateur",
"user_id": "Identifiant d'utilisateur",
"user_not_found": "Utilisateur non trouvé",
"variable": "Variable",
"variable_ids": "Identifiants variables",
"variables": "Variables",
@@ -470,13 +468,15 @@
"website_survey": "Sondage de site web",
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workspace": "Espace de travail",
"workflows": "Workflows",
"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",
@@ -505,7 +505,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 {minutes} minutes.",
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"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é",
@@ -661,6 +661,7 @@
"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",
@@ -851,16 +852,9 @@
"created_by_third_party": "Créé par un tiers",
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
"endpoint_bad_gateway_error": "Mauvaise passerelle (502) : Erreur de proxy/passerelle, service inaccessible",
"endpoint_gateway_timeout_error": "Délai d'attente de la passerelle dépassé (504) : Le délai d'attente de la passerelle a expiré, service inaccessible",
"endpoint_internal_server_error": "Erreur interne du serveur (500) : Le service a rencontré une erreur inattendue",
"endpoint_method_not_allowed_error": "Méthode non autorisée (405) : Le point de terminaison existe, mais n'accepte pas les requêtes POST",
"endpoint_not_found_error": "Introuvable (404) : Le point de terminaison n'existe pas",
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
"endpoint_pinged_error": "Impossible de pinger le webhook !",
"endpoint_service_unavailable_error": "Service indisponible (503) : Le service est temporairement indisponible",
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
"no_triggers": "Aucun déclencheur",
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
"please_enter_a_url": "Veuillez entrer une URL.",
"response_created": "Réponse créée",
@@ -1129,13 +1123,6 @@
"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.",
@@ -1426,6 +1413,7 @@
"custom_hostname": "Nom d'hôte personnalisé",
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
"delete_anyways": "Supprimer quand même",
"delete_block": "Supprimer le bloc",
@@ -1689,8 +1677,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse",
"reverse_order_occasionally": "Inverser l'ordre occasionnellement",
"reverse_order_occasionally_except_last": "Inverser l'ordre occasionnellement sauf le dernier",
"roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins.",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
@@ -1719,13 +1705,13 @@
"show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
"shrink_preview": "Réduire l'aperçu",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Sourire",
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
"spam_protection_threshold_heading": "Seuil de réponse",
"shrink_preview": "Réduire l'aperçu",
"star": "Étoile",
"starts_with": "Commence par",
"state": "État",
@@ -2619,8 +2605,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": "Très satisfait",
"csat_question_2_choice_2": "Un peu satisfait",
"csat_question_2_choice_1": "Un peu satisfait",
"csat_question_2_choice_2": "Très 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",
@@ -3342,5 +3328,18 @@
"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!"
}
}

View File

@@ -167,7 +167,6 @@
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
"connected": "Kapcsolódva",
"contact": "Kapcsolat",
"contacts": "Partnerek",
"continue": "Folytatás",
"copied": "Másolva",
@@ -175,7 +174,6 @@
"copy": "Másolás",
"copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása",
"copy_to_environment": "Másolás ide: {{environment}}",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
@@ -215,12 +213,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.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inaktív kérdőívek",
"integration": "integráció",
"integrations": "Integrációk",
"invalid_date_with_value": "Érvénytelen dátum: {value}",
"invalid_date": "Érvénytelen dátum",
"invalid_file_name": "Érvénytelen fájlnév, nevezze át a fájlt, és próbálja újra",
"invalid_file_type": "Érvénytelen fájltípus",
"invite": "Meghívás",
"invite_them": "Meghívó nekik",
"javascript_required": "JavaScript szükséges",
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
@@ -284,9 +280,7 @@
"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!",
@@ -326,9 +320,10 @@
"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",
@@ -387,7 +382,6 @@
"select": "Kiválasztás",
"select_all": "Összes kiválasztása",
"select_filter": "Szűrő kiválasztása",
"select_language": "Nyelv kiválasztása",
"select_survey": "Kérdőív kiválasztása",
"select_teams": "Csapatok kiválasztása",
"selected": "Kiválasztva",
@@ -400,6 +394,7 @@
"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",
@@ -419,6 +414,7 @@
"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,6 +429,7 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"teams_not_found": "A csapatok nem találhatók",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -456,6 +453,7 @@
"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",
@@ -470,13 +468,15 @@
"website_survey": "Webhely kérdőív",
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workspace": "Munkaterület",
"workflows": "Munkafolyamatok",
"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",
@@ -505,7 +505,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 {minutes} percig érvényes.",
"forgot_password_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül é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ő",
@@ -661,6 +661,7 @@
"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",
@@ -851,16 +852,9 @@
"created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -1129,13 +1123,6 @@
"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.",
@@ -1426,6 +1413,7 @@
"custom_hostname": "Egyéni gépnév",
"customize_survey_logo": "A kérdőív logójának személyre szabása",
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
"date_format": "Dátumformátum",
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
"delete_anyways": "Törlés mindenképp",
"delete_block": "Blokk törlése",
@@ -1689,8 +1677,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1719,13 +1705,13 @@
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
"shrink_preview": "Előnézet összecsukása",
"simple": "Egyszerű",
"six_points": "6 pont",
"smiley": "Hangulatjel",
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
"spam_protection_threshold_heading": "Válasz küszöbszintje",
"shrink_preview": "Előnézet összecsukása",
"star": "Csillag",
"starts_with": "Ezzel kezdődik",
"state": "Állapot",
@@ -2619,8 +2605,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": "Nagyon elégedett",
"csat_question_2_choice_2": "Valamelyest elégedett",
"csat_question_2_choice_1": "Valamelyest elégedett",
"csat_question_2_choice_2": "Nagyon 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",
@@ -3342,5 +3328,18 @@
"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!"
}
}

View File

@@ -167,7 +167,6 @@
"connect": "接続",
"connect_formbricks": "Formbricksを接続",
"connected": "接続済み",
"contact": "連絡先",
"contacts": "連絡先",
"continue": "続行",
"copied": "コピーしました",
@@ -175,7 +174,6 @@
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"copy_to_environment": "{{environment}} にコピー",
"count_attributes": "{count, plural, other {{count} 個の属性}}",
"count_contacts": "{count, plural, other {{count} 件の連絡先}}",
"count_members": "{count, plural, other {{count} 名のメンバー}}",
@@ -215,12 +213,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": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
@@ -257,13 +255,11 @@
"inactive_surveys": "非アクティブなフォーム",
"integration": "連携",
"integrations": "連携",
"invalid_date_with_value": "無効な日付です: {value}",
"invalid_date": "無効な日付です",
"invalid_file_name": "ファイル名が無効です。ファイル名を変更して再試行してください",
"invalid_file_type": "無効なファイルタイプです",
"invite": "招待",
"invite_them": "招待する",
"javascript_required": "JavaScriptが必要です",
"javascript_required_description": "Formbricksを正常に動作させるには、JavaScriptが必要です。続行するには、ブラウザの設定でJavaScriptを有効にしてください。",
"key": "キー",
"label": "ラベル",
"language": "言語",
@@ -284,9 +280,7 @@
"marketing": "マーケティング",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership": "メンバーシップ",
"membership_not_found": "メンバーシップが見つかりません",
"meta": "メタ",
"metadata": "メタデータ",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
@@ -326,9 +320,10 @@
"or": "または",
"organization": "組織",
"organization_id": "組織ID",
"organization_not_found": "組織が見つかりません",
"organization_settings": "組織設定",
"organization_teams_not_found": "組織のチームが見つかりません",
"other": "その他",
"other_filters": "その他のフィルター",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
@@ -387,7 +382,6 @@
"select": "選択",
"select_all": "すべて選択",
"select_filter": "フィルターを選択",
"select_language": "言語を選択",
"select_survey": "フォームを選択",
"select_teams": "チームを選択",
"selected": "選択済み",
@@ -400,6 +394,7 @@
"show_response_count": "回答数を表示",
"shown": "表示済み",
"size": "サイズ",
"skip": "スキップ",
"skipped": "スキップ済み",
"skips": "スキップ数",
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
@@ -419,6 +414,7 @@
"survey_id": "フォームID",
"survey_languages": "フォームの言語",
"survey_live": "フォーム公開中",
"survey_not_found": "フォームが見つかりません",
"survey_paused": "フォームは一時停止中です。",
"survey_type": "フォームの種類",
"surveys": "フォーム",
@@ -433,6 +429,7 @@
"team_name": "チーム名",
"team_role": "チームの役割",
"teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
"time_to_finish": "所要時間",
@@ -456,6 +453,7 @@
"url": "URL",
"user": "ユーザー",
"user_id": "ユーザーID",
"user_not_found": "ユーザーが見つかりません",
"variable": "変数",
"variable_ids": "変数ID",
"variables": "変数",
@@ -470,13 +468,15 @@
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workspace": "ワークスペース",
"workflows": "ワークフロー",
"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": "あなた",
@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "パスワードを変更",
"forgot_password_email_did_not_request": "このリクエストに心当たりのない場合は、このメールを無視してください。",
"forgot_password_email_heading": "パスワードを変更",
"forgot_password_email_link_valid_for_24_hours": "このリンクは{minutes}分間有効です。",
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
@@ -661,6 +661,7 @@
"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": "属性を作成",
@@ -851,16 +852,9 @@
"created_by_third_party": "サードパーティによって作成",
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
"endpoint_bad_gateway_error": "不正なゲートウェイ (502): プロキシまたはゲートウェイのエラーにより、サービスに到達できません",
"endpoint_gateway_timeout_error": "ゲートウェイタイムアウト (504): ゲートウェイのタイムアウトにより、サービスに到達できません",
"endpoint_internal_server_error": "内部サーバーエラー (500): サービスで予期しないエラーが発生しました",
"endpoint_method_not_allowed_error": "許可されていないメソッド (405): エンドポイントは存在しますが、POST リクエストを受け付けません",
"endpoint_not_found_error": "見つかりません (404): エンドポイントが存在しません",
"endpoint_pinged": "成功Webhook に ping できました。",
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
"endpoint_service_unavailable_error": "サービス利用不可 (503): サービスは一時的に停止しています",
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
"no_triggers": "トリガーなし",
"please_check_console": "詳細はコンソールを確認してください",
"please_enter_a_url": "URL を入力してください",
"response_created": "回答作成",
@@ -1129,13 +1123,6 @@
"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": "これはあなたの唯一の組織であるため、離れることはできません。まず新しい組織を作成してください。",
@@ -1426,6 +1413,7 @@
"custom_hostname": "カスタムホスト名",
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
"delete_anyways": "削除する",
"delete_block": "ブロックを削除",
@@ -1689,8 +1677,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション",
"reverse_order_occasionally": "順序をランダムに逆転",
"reverse_order_occasionally_except_last": "最後以外の順序をランダムに逆転",
"roundness": "丸み",
"roundness_description": "角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1719,13 +1705,13 @@
"show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
"simple": "シンプル",
"six_points": "6点",
"smiley": "スマイリー",
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
"spam_protection_threshold_heading": "回答のしきい値",
"shrink_preview": "プレビューを縮小",
"star": "星",
"starts_with": "で始まる",
"state": "都道府県",
@@ -2619,8 +2605,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": "非常に不満",
@@ -3342,5 +3328,18 @@
"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": "フィードバックありがとうございます!"
}
}

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