mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 19:30:41 -05:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50156d474c | |||
| 87b859d02a | |||
| df7e768216 | |||
| a96ba8b1e7 | |||
| e830871361 | |||
| 998e5c0819 | |||
| 13a56b0237 | |||
| 0b5418a03a | |||
| 0d8a338965 | |||
| d3250736a9 | |||
| e6ee6a6b0d | |||
| c0b097f929 | |||
| 78d336f8c7 | |||
| 95a7a265b9 | |||
| 136e59da68 | |||
| eb0a87cf80 | |||
| 0dcb98ac29 | |||
| 540f7aaae7 | |||
| 2d4614a0bd | |||
| 633bf18204 | |||
| 9a6cbd05b6 | |||
| 94b0248075 | |||
| 082de1042d | |||
| 8c19587baa | |||
| 433750d3fe | |||
| 61befd5ffd | |||
| 1e7817fb69 |
+1
-1
@@ -231,4 +231,4 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
||||
|
||||
type TFeatureDefinition = {
|
||||
key: TPublicLicenseFeatureKey;
|
||||
labelKey: string;
|
||||
docsUrl: string;
|
||||
};
|
||||
|
||||
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||
return [
|
||||
{
|
||||
key: "contacts",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||
},
|
||||
{
|
||||
key: "projects",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_projects"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
||||
},
|
||||
{
|
||||
key: "whitelabel",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||
},
|
||||
{
|
||||
key: "removeBranding",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
||||
},
|
||||
{
|
||||
key: "twoFactorAuth",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
||||
},
|
||||
{
|
||||
key: "sso",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
||||
},
|
||||
{
|
||||
key: "spamProtection",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
||||
},
|
||||
{
|
||||
key: "auditLogs",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
||||
},
|
||||
{
|
||||
key: "accessControl",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
||||
},
|
||||
{
|
||||
key: "quotas",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
interface EnterpriseLicenseFeaturesTableProps {
|
||||
features: TEnterpriseLicenseFeatures;
|
||||
}
|
||||
|
||||
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.enterprise.license_features_table_title")}
|
||||
description={t("environments.settings.enterprise.license_features_table_description")}
|
||||
noPadding>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
||||
<TableHead>{t("common.documentation")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{getFeatureDefinitions(t).map((feature) => {
|
||||
const value = features[feature.key];
|
||||
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
|
||||
let displayValue: number | string = "—";
|
||||
|
||||
if (typeof value === "number") {
|
||||
displayValue = value;
|
||||
} else if (value === null) {
|
||||
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={feature.key} className="hover:bg-white">
|
||||
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
type={isEnabled ? "success" : "gray"}
|
||||
size="normal"
|
||||
text={
|
||||
isEnabled
|
||||
? t("environments.settings.enterprise.license_features_table_enabled")
|
||||
: t("environments.settings.enterprise.license_features_table_disabled")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600">{displayValue}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={feature.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
+15
-1
@@ -15,6 +15,7 @@ import { SettingsCard } from "../../../components/SettingsCard";
|
||||
|
||||
interface EnterpriseLicenseStatusProps {
|
||||
status: TLicenseStatus;
|
||||
lastChecked: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
environmentId: string;
|
||||
}
|
||||
@@ -44,6 +45,7 @@ const getBadgeConfig = (
|
||||
|
||||
export const EnterpriseLicenseStatus = ({
|
||||
status,
|
||||
lastChecked,
|
||||
gracePeriodEnd,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
@@ -92,7 +94,19 @@ export const EnterpriseLicenseStatus = ({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
<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")}{" "}
|
||||
{new Date(lastChecked).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+14
-9
@@ -10,6 +10,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
@@ -93,15 +94,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
/>
|
||||
</PageHeader>
|
||||
{hasLicense ? (
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
<>
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status}
|
||||
lastChecked={licenseState.lastChecked}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
|
||||
@@ -2,21 +2,16 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { 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, updateSurvey } from "@/lib/survey/service";
|
||||
import { getSurvey } 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({
|
||||
@@ -97,68 +92,3 @@ 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;
|
||||
})
|
||||
);
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { updateSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyStatusDropdownProps {
|
||||
environment: TEnvironment;
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
export type TV3Authentication = TAuthenticationApiKey | Session | null;
|
||||
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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.",
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { getIsActiveCustomerAction } from "./actions";
|
||||
|
||||
interface ChatwootWidgetProps {
|
||||
chatwootBaseUrl: string;
|
||||
@@ -12,6 +13,18 @@ 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,
|
||||
@@ -20,15 +33,14 @@ 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 = (
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot;
|
||||
const $chatwoot = getChatwoot();
|
||||
if (userId && $chatwoot && !userSetRef.current) {
|
||||
$chatwoot.setUser(userId, {
|
||||
email: userEmail,
|
||||
@@ -36,7 +48,19 @@ export const ChatwootWidget = ({
|
||||
});
|
||||
userSetRef.current = true;
|
||||
}
|
||||
}, [userId, userEmail, userName]);
|
||||
}, [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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatwootWebsiteToken) return;
|
||||
@@ -65,23 +89,19 @@ 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 (
|
||||
(
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot
|
||||
) {
|
||||
if (getChatwoot()) {
|
||||
setUserInfo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
|
||||
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
|
||||
|
||||
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
|
||||
const $chatwoot = getChatwoot();
|
||||
if ($chatwoot) {
|
||||
$chatwoot.reset();
|
||||
}
|
||||
@@ -90,8 +110,18 @@ export const ChatwootWidget = ({
|
||||
scriptElement?.remove();
|
||||
|
||||
userSetRef.current = false;
|
||||
customerStatusSetRef.current = false;
|
||||
};
|
||||
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
|
||||
}, [
|
||||
chatwootBaseUrl,
|
||||
chatwootWebsiteToken,
|
||||
userId,
|
||||
userEmail,
|
||||
userName,
|
||||
setUserInfo,
|
||||
setCustomerStatus,
|
||||
getChatwoot,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"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;
|
||||
});
|
||||
});
|
||||
@@ -421,6 +421,38 @@ 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 } =
|
||||
|
||||
@@ -38,6 +38,11 @@ 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 {
|
||||
@@ -265,7 +270,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 } = params;
|
||||
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
// === Audit Log Setup ===
|
||||
const saveAuditLog = action && targetType;
|
||||
@@ -287,6 +292,11 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,17 @@ 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,
|
||||
|
||||
@@ -22,6 +22,9 @@ 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"))
|
||||
|
||||
+29
-2
@@ -267,6 +267,7 @@ checksums:
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
|
||||
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
|
||||
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
|
||||
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
|
||||
@@ -312,6 +313,7 @@ checksums:
|
||||
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
|
||||
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
|
||||
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
|
||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
|
||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||
@@ -353,6 +355,7 @@ 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
|
||||
@@ -806,6 +809,7 @@ checksums:
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
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
|
||||
@@ -1012,6 +1016,25 @@ checksums:
|
||||
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
|
||||
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
|
||||
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
|
||||
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
|
||||
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
|
||||
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
|
||||
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
|
||||
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
|
||||
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
|
||||
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
|
||||
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
||||
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
|
||||
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
|
||||
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
|
||||
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
|
||||
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
|
||||
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
|
||||
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
|
||||
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
|
||||
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
|
||||
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
|
||||
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
|
||||
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
|
||||
@@ -1359,6 +1382,7 @@ checksums:
|
||||
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
|
||||
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
||||
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
|
||||
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
|
||||
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
|
||||
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
|
||||
@@ -1610,6 +1634,7 @@ 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
|
||||
@@ -1625,10 +1650,12 @@ checksums:
|
||||
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
|
||||
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
|
||||
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
|
||||
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
|
||||
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
|
||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
|
||||
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||
@@ -2897,7 +2924,7 @@ checksums:
|
||||
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
||||
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
||||
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
|
||||
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940
|
||||
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
|
||||
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
||||
@@ -3150,7 +3177,7 @@ checksums:
|
||||
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
|
||||
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
|
||||
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
|
||||
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
|
||||
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
|
||||
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
|
||||
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
|
||||
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
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 = Sentry.captureRequestError;
|
||||
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 register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Neu",
|
||||
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
|
||||
"next": "Weiter",
|
||||
"no_actions_found": "Keine Aktionen gefunden",
|
||||
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
||||
"no_code": "No Code",
|
||||
"no_files_uploaded": "Keine Dateien hochgeladen",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
|
||||
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
|
||||
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
|
||||
"powered_by_formbricks": "Bereitgestellt von Formbricks",
|
||||
"preview": "Vorschau",
|
||||
"preview_survey": "Umfragevorschau",
|
||||
"privacy": "Datenschutz",
|
||||
@@ -380,6 +382,7 @@
|
||||
"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",
|
||||
@@ -850,9 +853,16 @@
|
||||
"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",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Unternehmensfunktionen",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
|
||||
"license_feature_access_control": "Zugriffskontrolle (RBAC)",
|
||||
"license_feature_audit_logs": "Audit-Protokolle",
|
||||
"license_feature_contacts": "Kontakte & Segmente",
|
||||
"license_feature_projects": "Arbeitsbereiche",
|
||||
"license_feature_quotas": "Kontingente",
|
||||
"license_feature_remove_branding": "Branding entfernen",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Spam-Schutz",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
|
||||
"license_feature_whitelabel": "White-Label-E-Mails",
|
||||
"license_features_table_access": "Zugriff",
|
||||
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
|
||||
"license_features_table_disabled": "Deaktiviert",
|
||||
"license_features_table_enabled": "Aktiviert",
|
||||
"license_features_table_feature": "Funktion",
|
||||
"license_features_table_title": "Lizenzierte Funktionen",
|
||||
"license_features_table_unlimited": "Unbegrenzt",
|
||||
"license_features_table_value": "Wert",
|
||||
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
|
||||
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
|
||||
"license_status": "Lizenzstatus",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
|
||||
"everyone": "Jeder",
|
||||
"expand_preview": "Vorschau erweitern",
|
||||
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"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",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
|
||||
"subheading": "Zwischenüberschrift",
|
||||
"subtract": "Subtrahieren -",
|
||||
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
|
||||
"survey_completed_heading": "Umfrage abgeschlossen",
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
"survey_placement": "Platzierung der Umfrage",
|
||||
"survey_preview": "Umfragevorschau 👀",
|
||||
"survey_styling": "Umfrage Styling",
|
||||
"survey_trigger": "Auslöser der Umfrage",
|
||||
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nein, danke!",
|
||||
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
|
||||
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
|
||||
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
|
||||
"preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?",
|
||||
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
|
||||
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
|
||||
"preview_survey_welcome_card_headline": "Willkommen!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"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": "Gibt es noch etwas, das du hinzufügen möchtest?",
|
||||
"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?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "New",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
"next": "Next",
|
||||
"no_actions_found": "No actions found",
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_code": "No code",
|
||||
"no_files_uploaded": "No files were uploaded",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Please select at least one survey",
|
||||
"please_select_at_least_one_trigger": "Please select at least one trigger",
|
||||
"please_upgrade_your_plan": "Please upgrade your plan",
|
||||
"powered_by_formbricks": "Powered by Formbricks",
|
||||
"preview": "Preview",
|
||||
"preview_survey": "Preview Survey",
|
||||
"privacy": "Privacy Policy",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Select",
|
||||
"select_all": "Select all",
|
||||
"select_filter": "Select filter",
|
||||
"select_language": "Select Language",
|
||||
"select_survey": "Select Survey",
|
||||
"select_teams": "Select teams",
|
||||
"selected": "Selected",
|
||||
@@ -850,9 +853,16 @@
|
||||
"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",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Enterprise Features",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
|
||||
"license_feature_access_control": "Access control (RBAC)",
|
||||
"license_feature_audit_logs": "Audit logs",
|
||||
"license_feature_contacts": "Contacts & Segments",
|
||||
"license_feature_projects": "Workspaces",
|
||||
"license_feature_quotas": "Quotas",
|
||||
"license_feature_remove_branding": "Remove branding",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Spam protection",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Two-factor authentication",
|
||||
"license_feature_whitelabel": "White-label emails",
|
||||
"license_features_table_access": "Access",
|
||||
"license_features_table_description": "Enterprise features and limits currently available to this instance.",
|
||||
"license_features_table_disabled": "Disabled",
|
||||
"license_features_table_enabled": "Enabled",
|
||||
"license_features_table_feature": "Feature",
|
||||
"license_features_table_title": "Licensed Features",
|
||||
"license_features_table_unlimited": "Unlimited",
|
||||
"license_features_table_value": "Value",
|
||||
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
|
||||
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
|
||||
"license_status": "License Status",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
|
||||
"everyone": "Everyone",
|
||||
"expand_preview": "Expand Preview",
|
||||
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
|
||||
"fallback_missing": "Fallback missing",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"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",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Styling set to theme styles",
|
||||
"subheading": "Subheading",
|
||||
"subtract": "Subtract -",
|
||||
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
|
||||
"survey_completed_heading": "Survey Completed",
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
"survey_placement": "Survey Placement",
|
||||
"survey_preview": "Survey Preview 👀",
|
||||
"survey_styling": "Survey styling",
|
||||
"survey_trigger": "Survey Trigger",
|
||||
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "No, thank you!",
|
||||
"preview_survey_question_2_headline": "Want to stay in the loop?",
|
||||
"preview_survey_question_2_subheader": "This is an example description.",
|
||||
"preview_survey_question_open_text_headline": "Anything else you'd like to share?",
|
||||
"preview_survey_question_open_text_headline": "Anything else you would like to share?",
|
||||
"preview_survey_question_open_text_placeholder": "Type your answer here…",
|
||||
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
|
||||
"preview_survey_welcome_card_headline": "Welcome!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"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'd like to add?",
|
||||
"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?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Nuevo",
|
||||
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
|
||||
"next": "Siguiente",
|
||||
"no_actions_found": "No se encontraron acciones",
|
||||
"no_background_image_found": "No se encontró imagen de fondo.",
|
||||
"no_code": "Sin código",
|
||||
"no_files_uploaded": "No se subieron archivos",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
|
||||
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
|
||||
"powered_by_formbricks": "Desarrollado por Formbricks",
|
||||
"preview": "Vista previa",
|
||||
"preview_survey": "Vista previa de la encuesta",
|
||||
"privacy": "Política de privacidad",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Seleccionar",
|
||||
"select_all": "Seleccionar todo",
|
||||
"select_filter": "Seleccionar filtro",
|
||||
"select_language": "Seleccionar idioma",
|
||||
"select_survey": "Seleccionar encuesta",
|
||||
"select_teams": "Seleccionar equipos",
|
||||
"selected": "Seleccionado",
|
||||
@@ -850,9 +853,16 @@
|
||||
"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",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Características empresariales",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
|
||||
"license_feature_access_control": "Control de acceso (RBAC)",
|
||||
"license_feature_audit_logs": "Registros de auditoría",
|
||||
"license_feature_contacts": "Contactos y segmentos",
|
||||
"license_feature_projects": "Espacios de trabajo",
|
||||
"license_feature_quotas": "Cuotas",
|
||||
"license_feature_remove_branding": "Eliminar marca",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Protección contra spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autenticación de dos factores",
|
||||
"license_feature_whitelabel": "Correos sin marca",
|
||||
"license_features_table_access": "Acceso",
|
||||
"license_features_table_description": "Funciones y límites empresariales disponibles actualmente para esta instancia.",
|
||||
"license_features_table_disabled": "Desactivado",
|
||||
"license_features_table_enabled": "Activado",
|
||||
"license_features_table_feature": "Función",
|
||||
"license_features_table_title": "Funciones con licencia",
|
||||
"license_features_table_unlimited": "Ilimitado",
|
||||
"license_features_table_value": "Valor",
|
||||
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
|
||||
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
|
||||
"license_status": "Estado de la licencia",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Error al guardar los cambios",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
|
||||
"everyone": "Todos",
|
||||
"expand_preview": "Expandir vista previa",
|
||||
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
|
||||
"fallback_missing": "Falta respaldo",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"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",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Restar -",
|
||||
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
|
||||
"survey_completed_heading": "Encuesta completada",
|
||||
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
|
||||
"survey_display_settings": "Ajustes de visualización de la encuesta",
|
||||
"survey_placement": "Ubicación de la encuesta",
|
||||
"survey_preview": "Vista previa de la encuesta 👀",
|
||||
"survey_styling": "Estilo del formulario",
|
||||
"survey_trigger": "Activador de la encuesta",
|
||||
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Nouveau",
|
||||
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
|
||||
"next": "Suivant",
|
||||
"no_actions_found": "Aucune action trouvée",
|
||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||
"no_code": "Sans code",
|
||||
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
|
||||
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
|
||||
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
|
||||
"powered_by_formbricks": "Propulsé par Formbricks",
|
||||
"preview": "Aperçu",
|
||||
"preview_survey": "Aperçu de l'enquête",
|
||||
"privacy": "Politique de confidentialité",
|
||||
@@ -380,6 +382,7 @@
|
||||
"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é",
|
||||
@@ -850,9 +853,16 @@
|
||||
"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",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Fonctionnalités d'entreprise",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
|
||||
"license_feature_access_control": "Contrôle d'accès (RBAC)",
|
||||
"license_feature_audit_logs": "Journaux d'audit",
|
||||
"license_feature_contacts": "Contacts et segments",
|
||||
"license_feature_projects": "Espaces de travail",
|
||||
"license_feature_quotas": "Quotas",
|
||||
"license_feature_remove_branding": "Retirer l'image de marque",
|
||||
"license_feature_saml": "SSO SAML",
|
||||
"license_feature_spam_protection": "Protection anti-spam",
|
||||
"license_feature_sso": "SSO OIDC",
|
||||
"license_feature_two_factor_auth": "Authentification à deux facteurs",
|
||||
"license_feature_whitelabel": "E-mails en marque blanche",
|
||||
"license_features_table_access": "Accès",
|
||||
"license_features_table_description": "Fonctionnalités Enterprise et limites actuellement disponibles pour cette instance.",
|
||||
"license_features_table_disabled": "Désactivé",
|
||||
"license_features_table_enabled": "Activé",
|
||||
"license_features_table_feature": "Fonctionnalité",
|
||||
"license_features_table_title": "Fonctionnalités sous licence",
|
||||
"license_features_table_unlimited": "Illimité",
|
||||
"license_features_table_value": "Valeur",
|
||||
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
|
||||
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
|
||||
"license_status": "Statut de la licence",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses ; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
|
||||
"everyone": "Tout le monde",
|
||||
"expand_preview": "Agrandir l'aperçu",
|
||||
"external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher l’hameçonnage.",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"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",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
|
||||
"subheading": "Sous-titre",
|
||||
"subtract": "Soustraire -",
|
||||
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
|
||||
"survey_completed_heading": "Enquête terminée",
|
||||
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
|
||||
"survey_display_settings": "Paramètres d'affichage de l'enquête",
|
||||
"survey_placement": "Placement de l'enquête",
|
||||
"survey_preview": "Aperçu du sondage 👀",
|
||||
"survey_styling": "Style de formulaire",
|
||||
"survey_trigger": "Déclencheur d'enquête",
|
||||
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Non, merci !",
|
||||
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
|
||||
"preview_survey_question_2_subheader": "Ceci est un exemple de description.",
|
||||
"preview_survey_question_open_text_headline": "Autre chose que vous aimeriez partager ?",
|
||||
"preview_survey_question_open_text_headline": "Souhaitez-vous partager autre chose ?",
|
||||
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
|
||||
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
|
||||
"preview_survey_welcome_card_headline": "Bienvenue !",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"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": "Y a-t-il autre chose que vous aimeriez ajouter ?",
|
||||
"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 ?",
|
||||
|
||||
+117
-82
@@ -175,7 +175,7 @@
|
||||
"copy_code": "Kód másolása",
|
||||
"copy_link": "Hivatkozás másolása",
|
||||
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
|
||||
"count_contacts": "{count, plural, one {{count} kontakt}} other {{count} kontakt}}",
|
||||
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
|
||||
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
|
||||
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
|
||||
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Új",
|
||||
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
|
||||
"next": "Következő",
|
||||
"no_actions_found": "Nem találhatók műveletek",
|
||||
"no_background_image_found": "Nem található háttérkép.",
|
||||
"no_code": "Kód nélkül",
|
||||
"no_files_uploaded": "Nem lettek fájlok feltöltve",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet",
|
||||
"please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót",
|
||||
"please_upgrade_your_plan": "Váltson magasabb csomagra",
|
||||
"powered_by_formbricks": "A gépházban: Formbricks",
|
||||
"preview": "Előnézet",
|
||||
"preview_survey": "Kérdőív előnézete",
|
||||
"privacy": "Adatvédelmi irányelvek",
|
||||
@@ -360,7 +362,7 @@
|
||||
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
|
||||
"replace": "Csere",
|
||||
"report_survey": "Kérdőív jelentése",
|
||||
"request_trial_license": "Próbalicenc kérése",
|
||||
"request_trial_license": "Próbaidőszaki licenc kérése",
|
||||
"reset_to_default": "Visszaállítás az alapértelmezettre",
|
||||
"response": "Válasz",
|
||||
"response_id": "Válaszazonosító",
|
||||
@@ -380,6 +382,7 @@
|
||||
"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",
|
||||
@@ -399,7 +402,7 @@
|
||||
"something_went_wrong": "Valami probléma történt",
|
||||
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
|
||||
"sort_by": "Rendezési sorrend",
|
||||
"start_free_trial": "Ingyenes próbaverzió indítása",
|
||||
"start_free_trial": "Ingyenes próbaidőszak indítása",
|
||||
"status": "Állapot",
|
||||
"step_by_step_manual": "Lépésenkénti kézikönyv",
|
||||
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
|
||||
@@ -434,9 +437,9 @@
|
||||
"title": "Cím",
|
||||
"top_left": "Balra fent",
|
||||
"top_right": "Jobbra fent",
|
||||
"trial_days_remaining": "{count} nap van hátra a próbaidőszakból",
|
||||
"trial_expired": "A próbaidőszak lejárt",
|
||||
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakból",
|
||||
"trial_days_remaining": "{count} nap van hátra a próbaidőszakából",
|
||||
"trial_expired": "A próbaidőszaka lejárt",
|
||||
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
|
||||
"try_again": "Próbálja újra",
|
||||
"type": "Típus",
|
||||
"unknown_survey": "Ismeretlen kérdőív",
|
||||
@@ -444,7 +447,7 @@
|
||||
"update": "Frissítés",
|
||||
"updated": "Frissítve",
|
||||
"updated_at": "Frissítve",
|
||||
"upgrade_plan": "Csomag frissítése",
|
||||
"upgrade_plan": "Magasabb csomagra váltás",
|
||||
"upload": "Feltöltés",
|
||||
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.",
|
||||
"upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
|
||||
@@ -537,7 +540,7 @@
|
||||
"survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése",
|
||||
"text_variable": "Szöveg változó",
|
||||
"verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:",
|
||||
"verification_email_heading": "Már majdnem megvagyunk!",
|
||||
"verification_email_heading": "Már majdnem kész vagyunk!",
|
||||
"verification_email_hey": "Helló 👋",
|
||||
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
|
||||
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
|
||||
@@ -605,15 +608,15 @@
|
||||
"test_match": "Illeszkedés tesztelése",
|
||||
"test_your_url": "URL tesztelése",
|
||||
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.",
|
||||
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet akkor fog aktiválódni, miután a felhasználó a megadott ideig az oldalon tartózkodik.",
|
||||
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet azután lesz aktiválva, hogy a felhasználó az oldalon marad a megadott időtartamig.",
|
||||
"this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.",
|
||||
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
|
||||
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
|
||||
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
|
||||
"time_in_seconds": "Idő másodpercben",
|
||||
"time_in_seconds_placeholder": "pl. 10",
|
||||
"time_in_seconds_placeholder": "például 10",
|
||||
"time_in_seconds_with_unit": "{seconds} mp",
|
||||
"time_on_page": "Oldalon töltött idő",
|
||||
"time_on_page": "Idő az oldalon",
|
||||
"track_new_user_action": "Új felhasználói művelet követése",
|
||||
"track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.",
|
||||
"url": "URL",
|
||||
@@ -850,9 +853,16 @@
|
||||
"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",
|
||||
@@ -973,79 +983,79 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Fizetési mód hozzáadása",
|
||||
"add_payment_method_to_upgrade_tooltip": "Kérjük, adjon hozzá egy fizetési módot fent a fizetős csomagra való frissítéshez",
|
||||
"billing_interval_toggle": "Számlázási időszak",
|
||||
"add_payment_method_to_upgrade_tooltip": "Adjon hozzá fizetési módot fent, hogy fizetős csomagra váltson",
|
||||
"billing_interval_toggle": "Számlázási időköz",
|
||||
"current_plan_badge": "Jelenlegi",
|
||||
"current_plan_cta": "Jelenlegi csomag",
|
||||
"custom_plan_description": "A szervezete egyedi számlázási beállítással rendelkezik. Továbbra is válthat az alábbi standard csomagok egyikére.",
|
||||
"custom_plan_title": "Egyedi csomag",
|
||||
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
|
||||
"custom_plan_description": "A szervezete egyéni számlázási beállítással rendelkezik. Ugyanakkor áttérhet az alábbi szabványos csomagok egyikére.",
|
||||
"custom_plan_title": "Egyéni csomag",
|
||||
"failed_to_start_trial": "Nem sikerült a próbaidőszak indítása. Próbálja meg újra.",
|
||||
"keep_current_plan": "Jelenlegi csomag megtartása",
|
||||
"manage_billing_details": "Kártyaadatok és számlák kezelése",
|
||||
"manage_billing_details": "Kártyarészletek és számlák kezelése",
|
||||
"monthly": "Havi",
|
||||
"most_popular": "Legnépszerűbb",
|
||||
"pending_change_removed": "Az ütemezett csomagváltás eltávolítva.",
|
||||
"pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
|
||||
"pending_plan_badge": "Ütemezett",
|
||||
"pending_plan_change_description": "A csomagja {{date}}-án átvált erre: {{plan}}.",
|
||||
"pending_plan_change_title": "Ütemezett csomagváltás",
|
||||
"pending_plan_change_description": "A csomagja {{plan}} csomagra fog váltani ekkor: {{date}}.",
|
||||
"pending_plan_change_title": "Ütemezett csomagváltoztatás",
|
||||
"pending_plan_cta": "Ütemezett",
|
||||
"per_month": "havonta",
|
||||
"per_year": "évente",
|
||||
"plan_change_applied": "A csomag sikeresen frissítve.",
|
||||
"plan_change_scheduled": "A csomagváltás sikeresen ütemezve.",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Minden, ami a Hobby csomagban",
|
||||
"plan_feature_everything_in_pro": "Minden, ami a Pro csomagban",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Magánszemélyek és kisebb csapatok számára, akik most kezdik a Formbricks Cloud használatát.",
|
||||
"plan_hobby_feature_responses": "250 válasz / hó",
|
||||
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
|
||||
"plan_custom": "Egyéni",
|
||||
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
|
||||
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
|
||||
"plan_hobby": "Hobbi",
|
||||
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
|
||||
"plan_hobby_feature_responses": "250 válasz/hónap",
|
||||
"plan_hobby_feature_workspaces": "1 munkaterület",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Növekvő csapatok számára, amelyeknek magasabb korlátokra, automatizálásokra és dinamikus túlhasználatra van szükségük.",
|
||||
"plan_pro_feature_responses": "2 000 válasz / hó (dinamikus túlhasználat)",
|
||||
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
|
||||
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
|
||||
"plan_pro_feature_workspaces": "3 munkaterület",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Nagyobb csapatok számára, amelyeknek nagyobb kapacitásra, erősebb irányításra és magasabb válaszmennyiségre van szükségük.",
|
||||
"plan_scale_feature_responses": "5000 válasz / hónap (dinamikus túllépés)",
|
||||
"plan_scale": "Méretezés",
|
||||
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek több kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
|
||||
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
|
||||
"plan_scale_feature_workspaces": "5 munkaterület",
|
||||
"plan_selection_description": "Hasonlítsa össze a Hobby, Pro és Scale csomagokat, majd váltson csomagot közvetlenül a Formbricks alkalmazásból.",
|
||||
"plan_selection_title": "Válassza ki az Ön csomagját",
|
||||
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
|
||||
"plan_selection_title": "Csomag kiválasztása",
|
||||
"plan_unknown": "Ismeretlen",
|
||||
"remove_branding": "Márkajel eltávolítása",
|
||||
"retry_setup": "Újrapróbálkozás a beállítással",
|
||||
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
|
||||
"select_plan_header_title": "Zökkenőmentesen integrált felmérések, 100%-ban az Ön márkája.",
|
||||
"status_trialing": "Próbaverzió",
|
||||
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
|
||||
"stripe_setup_incomplete": "Számlázás beállítása nem teljes",
|
||||
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.",
|
||||
"retry_setup": "Beállítás újrapróbálása",
|
||||
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
|
||||
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
|
||||
"status_trialing": "Próbaidőszak",
|
||||
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
|
||||
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
|
||||
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
|
||||
"subscription": "Előfizetés",
|
||||
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot",
|
||||
"subscription_description": "Az előfizetési csomag kezelése és a használat felügyelete",
|
||||
"switch_at_period_end": "Váltás az időszak végén",
|
||||
"switch_plan_now": "Csomag váltása most",
|
||||
"this_includes": "Ez tartalmazza",
|
||||
"trial_alert_description": "Adjon hozzá fizetési módot, hogy megtarthassa a hozzáférést az összes funkcióhoz.",
|
||||
"trial_already_used": "Ehhez az e-mail címhez már igénybe vettek ingyenes próbaidőszakot. Kérjük, válasszon helyette fizetős csomagot.",
|
||||
"this_includes": "Ezeket tartalmazza",
|
||||
"trial_alert_description": "Fizetési mód hozzáadása az összes funkcióhoz való hozzáférés megtartásához.",
|
||||
"trial_already_used": "Ehhez az e-mail-címhez már használatban van egy ingyenes próbaidőszak. Váltson inkább fizetős csomagra.",
|
||||
"trial_feature_api_access": "API-hozzáférés",
|
||||
"trial_feature_attribute_segmentation": "Attribútumalapú szegmentálás",
|
||||
"trial_feature_contact_segment_management": "Kapcsolat- és szegmenskezelés",
|
||||
"trial_feature_email_followups": "E-mail követések",
|
||||
"trial_feature_hide_branding": "Formbricks márkajelzés elrejtése",
|
||||
"trial_feature_attribute_segmentation": "Attribútumalapú szakaszolás",
|
||||
"trial_feature_contact_segment_management": "Partner- és szakaszkezelés",
|
||||
"trial_feature_email_followups": "E-mailes utókövetések",
|
||||
"trial_feature_hide_branding": "Formbricks márkajel elrejtése",
|
||||
"trial_feature_mobile_sdks": "iOS és Android SDK-k",
|
||||
"trial_feature_respondent_identification": "Válaszadó-azonosítás",
|
||||
"trial_feature_unlimited_seats": "Korlátlan számú felhasználói hely",
|
||||
"trial_feature_webhooks": "Egyéni webhookok",
|
||||
"trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül",
|
||||
"trial_payment_method_added_description": "Minden rendben! A Pro csomag automatikusan folytatódik a próbaidőszak lejárta után.",
|
||||
"trial_title": "Szerezze meg a Formbricks Pro-t ingyen!",
|
||||
"trial_feature_unlimited_seats": "Korlátlan számú hely",
|
||||
"trial_feature_webhooks": "Egyéni webhorgok",
|
||||
"trial_no_credit_card": "14 napos próbaidőszak, nincs szükség hitelkártyára",
|
||||
"trial_payment_method_added_description": "Mindent beállított! A Pro csomagja a próbaidőszak vége után automatikusan folytatódik.",
|
||||
"trial_title": "Szerezze meg a Formbricks Pro csomagot ingyen!",
|
||||
"unlimited_responses": "Korlátlan válaszok",
|
||||
"unlimited_workspaces": "Korlátlan munkaterület",
|
||||
"upgrade": "Frissítés",
|
||||
"upgrade_now": "Frissítés most",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "felhasználva",
|
||||
"yearly": "Éves",
|
||||
"yearly_checkout_unavailable": "Az éves fizetés még nem érhető el. Kérjük, adjon hozzá fizetési módot egy havi előfizetéshez, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
|
||||
"usage_cycle": "Használati ciklus",
|
||||
"used": "használva",
|
||||
"yearly": "Évente",
|
||||
"yearly_checkout_unavailable": "Az éves fizetési lehetőség még nem érhető el. Először adjon hozzá fizetési módot egy havi csomaghoz, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
|
||||
"your_plan": "Az Ön csomagja"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1071,29 +1081,48 @@
|
||||
"enterprise_features": "Vállalati funkciók",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
|
||||
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks példányhoz van kötve. Amennyiben ez a telepítés újra lett építve vagy áthelyezésre került, kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző példány kötését.",
|
||||
"license_feature_access_control": "Hozzáférés-vezérlés (RBAC)",
|
||||
"license_feature_audit_logs": "Auditálási naplók",
|
||||
"license_feature_contacts": "Partnerek és szakaszok",
|
||||
"license_feature_projects": "Munkaterületek",
|
||||
"license_feature_quotas": "Kvóták",
|
||||
"license_feature_remove_branding": "Márkajel eltávolítása",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Szemét elleni védekezés",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
|
||||
"license_feature_whitelabel": "Fehér címkés e-mailek",
|
||||
"license_features_table_access": "Hozzáférés",
|
||||
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
|
||||
"license_features_table_disabled": "Letiltva",
|
||||
"license_features_table_enabled": "Engedélyezve",
|
||||
"license_features_table_feature": "Funkció",
|
||||
"license_features_table_title": "Licencelt funkciók",
|
||||
"license_features_table_unlimited": "Korlátlan",
|
||||
"license_features_table_value": "Érték",
|
||||
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks-példányhoz van kötve. Ha ezt a telepítést újraépítették vagy áthelyezték, akkor kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi példányhoz való kötést.",
|
||||
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
|
||||
"license_status": "Licencállapot",
|
||||
"license_status_active": "Aktív",
|
||||
"license_status_description": "A vállalati licenc állapota.",
|
||||
"license_status_expired": "Lejárt",
|
||||
"license_status_instance_mismatch": "Másik Példányhoz Kötve",
|
||||
"license_status_instance_mismatch": "Másik példányhoz kötve",
|
||||
"license_status_invalid": "Érvénytelen licenc",
|
||||
"license_status_unreachable": "Nem érhető el",
|
||||
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
|
||||
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos ingyenes próbalicencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
|
||||
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos próbaidőszaki licencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
|
||||
"no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)",
|
||||
"on_request": "Kérésre",
|
||||
"organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)",
|
||||
"questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:",
|
||||
"recheck_license": "Licenc újraellenőrzése",
|
||||
"recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.",
|
||||
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks példányhoz van kötve. Kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző kötést.",
|
||||
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks-példányhoz van kötve. Kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi kötést.",
|
||||
"recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.",
|
||||
"recheck_license_success": "A licencellenőrzés sikeres",
|
||||
"recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.",
|
||||
"rechecking": "Újraellenőrzés…",
|
||||
"request_30_day_trial_license": "30 napos ingyenes licenc kérése",
|
||||
"request_30_day_trial_license": "30 napos próbaidőszaki licenc kérése",
|
||||
"saml_sso": "SAML SSO",
|
||||
"service_level_agreement": "Szolgáltatási megállapodás",
|
||||
"soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés",
|
||||
@@ -1430,21 +1459,22 @@
|
||||
"error_saving_changes": "Hiba a változtatások mentésekor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).",
|
||||
"everyone": "Mindenki",
|
||||
"external_urls_paywall_tooltip": "Kérjük, váltson fizetős csomagra, hogy testre szabhassa a külső URL-eket. Ez segít megelőzni az adathalászatot.",
|
||||
"expand_preview": "Előnézet kinyitása",
|
||||
"external_urls_paywall_tooltip": "Váltson a magasabb fizetős csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
|
||||
"fallback_missing": "Tartalék hiányzik",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában",
|
||||
"field_name_eg_score_price": "Mező neve, például pontszám, ár",
|
||||
"first_name": "Keresztnév",
|
||||
"five_points_recommended": "5 pont (ajánlott)",
|
||||
"follow_ups": "Követések",
|
||||
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt a követést?",
|
||||
"follow_ups_delete_modal_title": "Törli a követést?",
|
||||
"follow_ups": "Utókövetések",
|
||||
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt az utókövetést?",
|
||||
"follow_ups_delete_modal_title": "Törli az utókövetést?",
|
||||
"follow_ups_empty_description": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak.",
|
||||
"follow_ups_empty_heading": "Automatikus követések küldése",
|
||||
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van a követésekben. A törlése eltávolítja az összes követésből. Biztosan törölni szeretné?",
|
||||
"follow_ups_empty_heading": "Automatikus utókövetések küldése",
|
||||
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van az utókövetésekben. A törlése eltávolítja az összes utókövetésből. Biztosan törölni szeretné?",
|
||||
"follow_ups_ending_card_delete_modal_title": "Törli a befejező kártyát?",
|
||||
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy követésben. Először távolítsa el a követésből.",
|
||||
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy utókövetésben. Először távolítsa el az utókövetésből.",
|
||||
"follow_ups_include_hidden_fields": "Rejtett mezők értékeinek felvétele",
|
||||
"follow_ups_include_variables": "Változó értékeinek felvétele",
|
||||
"follow_ups_item_ending_tag": "Befejezések",
|
||||
@@ -1468,21 +1498,21 @@
|
||||
"follow_ups_modal_action_to_description": "Az az e-mail-cím, ahova az e-mail elküldésre kerül",
|
||||
"follow_ups_modal_action_to_label": "Címzett",
|
||||
"follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt",
|
||||
"follow_ups_modal_create_heading": "Új követés létrehozása",
|
||||
"follow_ups_modal_created_successfull_toast": "A követés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_modal_edit_heading": "A követés szerkesztése",
|
||||
"follow_ups_modal_edit_no_id": "Nincs kérdőívkövetési azonosító megadva, nem lehet frissíteni a kérdőívkövetést",
|
||||
"follow_ups_modal_name_label": "Követés neve",
|
||||
"follow_ups_modal_name_placeholder": "A követés elnevezése",
|
||||
"follow_ups_modal_create_heading": "Új utókövetés létrehozása",
|
||||
"follow_ups_modal_created_successfull_toast": "Az utókövetés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_modal_edit_heading": "Az utókövetés szerkesztése",
|
||||
"follow_ups_modal_edit_no_id": "Nincs kérdőív-utókövetési azonosító megadva, nem lehet frissíteni a kérdőív utókövetését",
|
||||
"follow_ups_modal_name_label": "Utókövetés neve",
|
||||
"follow_ups_modal_name_placeholder": "Az utókövetés elnevezése",
|
||||
"follow_ups_modal_subheading": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak",
|
||||
"follow_ups_modal_trigger_description": "Mikor kell ezt a követést aktiválni?",
|
||||
"follow_ups_modal_trigger_description": "Mikor kell ezt az utókövetést aktiválni?",
|
||||
"follow_ups_modal_trigger_label": "Aktiváló",
|
||||
"follow_ups_modal_trigger_type_ending": "A válaszadó egy adott befejezést lát",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát",
|
||||
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
|
||||
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_new": "Új követés",
|
||||
"follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_new": "Új utókövetés",
|
||||
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
|
||||
"four_points": "4 pont",
|
||||
"heading": "Címsor",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"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",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
|
||||
"subheading": "Alcím",
|
||||
"subtract": "Kivonás -",
|
||||
"survey_closed_message_heading_required": "Címsor hozzáadása az egyéni kérdőív záró üzenetéhez.",
|
||||
"survey_completed_heading": "A kérdőív kitöltve",
|
||||
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
|
||||
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
|
||||
"survey_placement": "Kérdőív elhelyezése",
|
||||
"survey_preview": "Kérdőív előnézete 👀",
|
||||
"survey_styling": "Kérdőív stílusának beállítása",
|
||||
"survey_trigger": "Kérdőív aktiválója",
|
||||
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
|
||||
@@ -2764,8 +2799,8 @@
|
||||
"evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…",
|
||||
"evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?",
|
||||
"evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…",
|
||||
"fake_door_follow_up_description": "Követés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
|
||||
"fake_door_follow_up_name": "„Fake door” követés",
|
||||
"fake_door_follow_up_description": "Utókövetés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
|
||||
"fake_door_follow_up_name": "„Fake door” utókövetés",
|
||||
"fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?",
|
||||
"fake_door_follow_up_question_1_lower_label": "Nem fontos",
|
||||
"fake_door_follow_up_question_1_upper_label": "Nagyon fontos",
|
||||
@@ -2774,7 +2809,7 @@
|
||||
"fake_door_follow_up_question_2_choice_3": "3. szempont",
|
||||
"fake_door_follow_up_question_2_choice_4": "4. szempont",
|
||||
"fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?",
|
||||
"feature_chaser_description": "Követés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
|
||||
"feature_chaser_description": "Utókövetés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
|
||||
"feature_chaser_name": "Funkcióvadász",
|
||||
"feature_chaser_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?",
|
||||
"feature_chaser_question_1_lower_label": "Nem fontos",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "新規",
|
||||
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
|
||||
"next": "次へ",
|
||||
"no_actions_found": "アクションが見つかりません",
|
||||
"no_background_image_found": "背景画像が見つかりません。",
|
||||
"no_code": "ノーコード",
|
||||
"no_files_uploaded": "ファイルがアップロードされていません",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
|
||||
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
|
||||
"please_upgrade_your_plan": "プランをアップグレードしてください",
|
||||
"powered_by_formbricks": "Powered by Formbricks",
|
||||
"preview": "プレビュー",
|
||||
"preview_survey": "フォームをプレビュー",
|
||||
"privacy": "プライバシーポリシー",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "選択",
|
||||
"select_all": "すべて選択",
|
||||
"select_filter": "フィルターを選択",
|
||||
"select_language": "言語を選択",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_teams": "チームを選択",
|
||||
"selected": "選択済み",
|
||||
@@ -850,9 +853,16 @@
|
||||
"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": "回答作成",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "エンタープライズ機能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
|
||||
"license_feature_access_control": "アクセス制御(RBAC)",
|
||||
"license_feature_audit_logs": "監査ログ",
|
||||
"license_feature_contacts": "連絡先とセグメント",
|
||||
"license_feature_projects": "ワークスペース",
|
||||
"license_feature_quotas": "クォータ",
|
||||
"license_feature_remove_branding": "ブランディングの削除",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "スパム保護",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "二要素認証",
|
||||
"license_feature_whitelabel": "ホワイトラベルメール",
|
||||
"license_features_table_access": "アクセス",
|
||||
"license_features_table_description": "このインスタンスで現在利用可能なエンタープライズ機能と制限。",
|
||||
"license_features_table_disabled": "無効",
|
||||
"license_features_table_enabled": "有効",
|
||||
"license_features_table_feature": "機能",
|
||||
"license_features_table_title": "ライセンス機能",
|
||||
"license_features_table_unlimited": "無制限",
|
||||
"license_features_table_value": "値",
|
||||
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
|
||||
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
|
||||
"license_status": "ライセンスステータス",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "変更の保存中にエラーが発生しました",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
|
||||
"everyone": "全員",
|
||||
"expand_preview": "プレビューを展開",
|
||||
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。",
|
||||
"fallback_missing": "フォールバックがありません",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "フォームの最大表示回数",
|
||||
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
|
||||
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
|
||||
"shrink_preview": "プレビューを縮小",
|
||||
"simple": "シンプル",
|
||||
"six_points": "6点",
|
||||
"smiley": "スマイリー",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
|
||||
"subheading": "サブ見出し",
|
||||
"subtract": "減算 -",
|
||||
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
|
||||
"survey_completed_heading": "フォームが完了しました",
|
||||
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
|
||||
"survey_display_settings": "フォーム表示設定",
|
||||
"survey_placement": "フォームの配置",
|
||||
"survey_preview": "アンケートプレビュー 👀",
|
||||
"survey_styling": "フォームのスタイル",
|
||||
"survey_trigger": "フォームのトリガー",
|
||||
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Nieuw",
|
||||
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
|
||||
"next": "Volgende",
|
||||
"no_actions_found": "Geen acties gevonden",
|
||||
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
|
||||
"no_code": "Geen code",
|
||||
"no_files_uploaded": "Er zijn geen bestanden geüpload",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
|
||||
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
|
||||
"please_upgrade_your_plan": "Upgrade je abonnement",
|
||||
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
|
||||
"preview": "Voorbeeld",
|
||||
"preview_survey": "Voorbeeld van enquête",
|
||||
"privacy": "Privacybeleid",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Selecteer",
|
||||
"select_all": "Selecteer alles",
|
||||
"select_filter": "Filter selecteren",
|
||||
"select_language": "Selecteer taal",
|
||||
"select_survey": "Selecteer Enquête",
|
||||
"select_teams": "Selecteer teams",
|
||||
"selected": "Gekozen",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "Gemaakt door een derde partij",
|
||||
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
|
||||
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Ongeldige gateway (502): Proxy-/gatewayfout, service niet bereikbaar",
|
||||
"endpoint_gateway_timeout_error": "Gateway-time-out (504): Gateway-time-out, service niet bereikbaar",
|
||||
"endpoint_internal_server_error": "Interne serverfout (500): De service is een onverwachte fout tegengekomen",
|
||||
"endpoint_method_not_allowed_error": "Methode niet toegestaan (405): Het endpoint bestaat, maar accepteert geen POST-verzoeken",
|
||||
"endpoint_not_found_error": "Niet gevonden (404): Het endpoint bestaat niet",
|
||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||
"endpoint_service_unavailable_error": "Service niet beschikbaar (503): De service is tijdelijk niet beschikbaar",
|
||||
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||
"no_triggers": "Geen triggers",
|
||||
"please_check_console": "Controleer de console voor meer details",
|
||||
"please_enter_a_url": "Voer een URL in",
|
||||
"response_created": "Reactie gemaakt",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Enterprise-functies",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
|
||||
"license_feature_access_control": "Toegangscontrole (RBAC)",
|
||||
"license_feature_audit_logs": "Auditlogboeken",
|
||||
"license_feature_contacts": "Contacten & Segmenten",
|
||||
"license_feature_projects": "Werkruimtes",
|
||||
"license_feature_quotas": "Quota's",
|
||||
"license_feature_remove_branding": "Branding verwijderen",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Spambescherming",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Tweefactorauthenticatie",
|
||||
"license_feature_whitelabel": "Whitelabel-e-mails",
|
||||
"license_features_table_access": "Toegang",
|
||||
"license_features_table_description": "Enterprise-functies en limieten die momenteel beschikbaar zijn voor deze instantie.",
|
||||
"license_features_table_disabled": "Uitgeschakeld",
|
||||
"license_features_table_enabled": "Ingeschakeld",
|
||||
"license_features_table_feature": "Functie",
|
||||
"license_features_table_title": "Gelicentieerde Functies",
|
||||
"license_features_table_unlimited": "Onbeperkt",
|
||||
"license_features_table_value": "Waarde",
|
||||
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
|
||||
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
|
||||
"license_status": "Licentiestatus",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Fout bij het opslaan van wijzigingen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
|
||||
"everyone": "Iedereen",
|
||||
"expand_preview": "Voorbeeld uitvouwen",
|
||||
"external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.",
|
||||
"fallback_missing": "Terugval ontbreekt",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
|
||||
"response_options": "Reactieopties",
|
||||
"reverse_order_occasionally": "Volgorde af en toe omkeren",
|
||||
"reverse_order_occasionally_except_last": "Volgorde af en toe omkeren behalve laatste",
|
||||
"roundness": "Rondheid",
|
||||
"roundness_description": "Bepaalt hoe afgerond de hoeken zijn.",
|
||||
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "Toon onderzoek maximaal",
|
||||
"show_survey_to_users": "Enquête tonen aan % van de gebruikers",
|
||||
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
|
||||
"shrink_preview": "Voorbeeld invouwen",
|
||||
"simple": "Eenvoudig",
|
||||
"six_points": "6 punten",
|
||||
"smiley": "Smiley",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
|
||||
"subheading": "Ondertitel",
|
||||
"subtract": "Aftrekken -",
|
||||
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
|
||||
"survey_completed_heading": "Enquête voltooid",
|
||||
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
|
||||
"survey_display_settings": "Enquêteweergave-instellingen",
|
||||
"survey_placement": "Enquête plaatsing",
|
||||
"survey_preview": "Enquêtevoorbeeld 👀",
|
||||
"survey_styling": "Vorm styling",
|
||||
"survey_trigger": "Enquêtetrigger",
|
||||
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nee, dank je!",
|
||||
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
|
||||
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
|
||||
"preview_survey_question_open_text_headline": "Wil je nog iets delen?",
|
||||
"preview_survey_question_open_text_headline": "Wilt u nog iets anders delen?",
|
||||
"preview_survey_question_open_text_placeholder": "Typ hier je antwoord...",
|
||||
"preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.",
|
||||
"preview_survey_welcome_card_headline": "Welkom!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
|
||||
"coming_soon_title": "We zijn er bijna!",
|
||||
"follow_up_label": "Is er nog iets dat je wilt toevoegen?",
|
||||
"follow_up_label": "Is er nog iets dat u wilt toevoegen?",
|
||||
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
|
||||
"generate_button": "Genereer workflow",
|
||||
"heading": "Welke workflow wil je maken?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
|
||||
"next": "Próximo",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||
"please_upgrade_your_plan": "Por favor, atualize seu plano",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"preview": "Prévia",
|
||||
"preview_survey": "Prévia da Pesquisa",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_language": "Selecionar Idioma",
|
||||
"select_survey": "Selecionar Pesquisa",
|
||||
"select_teams": "Selecionar times",
|
||||
"selected": "Selecionado",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
|
||||
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
|
||||
"endpoint_gateway_timeout_error": "Tempo limite do gateway esgotado (504): Tempo limite do gateway esgotado, serviço inacessível",
|
||||
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
|
||||
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita solicitações POST",
|
||||
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
|
||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
|
||||
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||
"no_triggers": "Nenhum Gatilho",
|
||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira uma URL",
|
||||
"response_created": "Resposta Criada",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Recursos Empresariais",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
|
||||
"license_feature_access_control": "Controle de acesso (RBAC)",
|
||||
"license_feature_audit_logs": "Logs de auditoria",
|
||||
"license_feature_contacts": "Contatos e Segmentos",
|
||||
"license_feature_projects": "Workspaces",
|
||||
"license_feature_quotas": "Cotas",
|
||||
"license_feature_remove_branding": "Remover identidade visual",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Proteção contra spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autenticação de dois fatores",
|
||||
"license_feature_whitelabel": "E-mails white-label",
|
||||
"license_features_table_access": "Acesso",
|
||||
"license_features_table_description": "Recursos empresariais e limites disponíveis atualmente para esta instância.",
|
||||
"license_features_table_disabled": "Desabilitado",
|
||||
"license_features_table_enabled": "Habilitado",
|
||||
"license_features_table_feature": "Recurso",
|
||||
"license_features_table_title": "Recursos Licenciados",
|
||||
"license_features_table_unlimited": "Ilimitado",
|
||||
"license_features_table_value": "Valor",
|
||||
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
|
||||
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
|
||||
"license_status": "Status da licença",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Erro ao salvar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
|
||||
"everyone": "Todo mundo",
|
||||
"expand_preview": "Expandir prévia",
|
||||
"external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
|
||||
"response_options": "Opções de Resposta",
|
||||
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
|
||||
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
|
||||
"roundness": "Circularidade",
|
||||
"roundness_description": "Controla o arredondamento dos cantos.",
|
||||
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "Mostrar no máximo",
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
|
||||
"shrink_preview": "Recolher prévia",
|
||||
"simple": "Simples",
|
||||
"six_points": "6 pontos",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
|
||||
"survey_completed_heading": "Pesquisa Concluída",
|
||||
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
|
||||
"survey_display_settings": "Configurações de Exibição da Pesquisa",
|
||||
"survey_placement": "Posicionamento da Pesquisa",
|
||||
"survey_preview": "Prévia da pesquisa 👀",
|
||||
"survey_styling": "Estilização de Formulários",
|
||||
"survey_trigger": "Gatilho de Pesquisa",
|
||||
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
|
||||
"preview_survey_question_2_headline": "Quer ficar por dentro?",
|
||||
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
|
||||
"preview_survey_question_open_text_headline": "Tem mais alguma coisa que você gostaria de compartilhar?",
|
||||
"preview_survey_question_open_text_headline": "Há algo mais que você gostaria de compartilhar?",
|
||||
"preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...",
|
||||
"preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.",
|
||||
"preview_survey_welcome_card_headline": "Bem-vindo!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
|
||||
"coming_soon_title": "Estamos quase lá!",
|
||||
"follow_up_label": "Há algo mais que você gostaria de adicionar?",
|
||||
"follow_up_label": "Há algo mais que você gostaria de acrescentar?",
|
||||
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
|
||||
"generate_button": "Gerar fluxo de trabalho",
|
||||
"heading": "Qual fluxo de trabalho você quer criar?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
|
||||
"next": "Seguinte",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"preview": "Pré-visualização",
|
||||
"preview_survey": "Pré-visualização do inquérito",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_language": "Selecionar Idioma",
|
||||
"select_survey": "Selecionar Inquérito",
|
||||
"select_teams": "Selecionar equipas",
|
||||
"selected": "Selecionado",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
|
||||
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
|
||||
"endpoint_gateway_timeout_error": "Tempo limite do gateway excedido (504): Tempo limite do gateway excedido, serviço inacessível",
|
||||
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
|
||||
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita pedidos POST",
|
||||
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
|
||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
|
||||
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||
"no_triggers": "Sem Acionadores",
|
||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira um URL",
|
||||
"response_created": "Resposta Criada",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Funcionalidades da Empresa",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
|
||||
"license_feature_access_control": "Controlo de acesso (RBAC)",
|
||||
"license_feature_audit_logs": "Registos de auditoria",
|
||||
"license_feature_contacts": "Contactos e Segmentos",
|
||||
"license_feature_projects": "Áreas de trabalho",
|
||||
"license_feature_quotas": "Quotas",
|
||||
"license_feature_remove_branding": "Remover marca",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Proteção contra spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autenticação de dois fatores",
|
||||
"license_feature_whitelabel": "E-mails personalizados",
|
||||
"license_features_table_access": "Acesso",
|
||||
"license_features_table_description": "Funcionalidades e limites empresariais atualmente disponíveis para esta instância.",
|
||||
"license_features_table_disabled": "Desativado",
|
||||
"license_features_table_enabled": "Ativado",
|
||||
"license_features_table_feature": "Funcionalidade",
|
||||
"license_features_table_title": "Funcionalidades Licenciadas",
|
||||
"license_features_table_unlimited": "Ilimitado",
|
||||
"license_features_table_value": "Valor",
|
||||
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
|
||||
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
|
||||
"license_status": "Estado da licença",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
|
||||
"everyone": "Todos",
|
||||
"expand_preview": "Expandir pré-visualização",
|
||||
"external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
|
||||
"response_options": "Opções de Resposta",
|
||||
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
|
||||
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
|
||||
"roundness": "Arredondamento",
|
||||
"roundness_description": "Controla o arredondamento dos cantos.",
|
||||
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "Mostrar inquérito máximo de",
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
|
||||
"shrink_preview": "Reduzir pré-visualização",
|
||||
"simple": "Simples",
|
||||
"six_points": "6 pontos",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Estilo definido para estilos do tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
|
||||
"survey_completed_heading": "Inquérito Concluído",
|
||||
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
|
||||
"survey_display_settings": "Configurações de Exibição do Inquérito",
|
||||
"survey_placement": "Colocação do Inquérito",
|
||||
"survey_preview": "Pré-visualização do questionário 👀",
|
||||
"survey_styling": "Estilo do formulário",
|
||||
"survey_trigger": "Desencadeador de Inquérito",
|
||||
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
|
||||
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
|
||||
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
|
||||
"preview_survey_question_open_text_headline": "Mais alguma coisa que gostaria de partilhar?",
|
||||
"preview_survey_question_open_text_headline": "Há mais alguma coisa que gostaria de partilhar?",
|
||||
"preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...",
|
||||
"preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.",
|
||||
"preview_survey_welcome_card_headline": "Bem-vindo!",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Nou",
|
||||
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
|
||||
"next": "Următorul",
|
||||
"no_actions_found": "Nu au fost găsite acțiuni",
|
||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||
"no_code": "Fără Cod",
|
||||
"no_files_uploaded": "Nu au fost încărcate fișiere",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
|
||||
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
|
||||
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
|
||||
"powered_by_formbricks": "Oferit de Formbricks",
|
||||
"preview": "Previzualizare",
|
||||
"preview_survey": "Previzualizare Chestionar",
|
||||
"privacy": "Politica de Confidențialitate",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Selectați",
|
||||
"select_all": "Selectați toate",
|
||||
"select_filter": "Selectați filtrul",
|
||||
"select_language": "Selectează limba",
|
||||
"select_survey": "Selectați chestionar",
|
||||
"select_teams": "Selectați echipele",
|
||||
"selected": "Selectat",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "Creat de o Parte Terță",
|
||||
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
|
||||
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Gateway invalid (502): Eroare de proxy/gateway, serviciul nu este accesibil",
|
||||
"endpoint_gateway_timeout_error": "Timp de așteptare gateway depășit (504): Timpul de așteptare al gateway-ului a fost depășit, serviciul nu este accesibil",
|
||||
"endpoint_internal_server_error": "Eroare internă de server (500): Serviciul a întâmpinat o eroare neașteptată",
|
||||
"endpoint_method_not_allowed_error": "Metodă nepermisă (405): Endpointul există, dar nu acceptă cereri POST",
|
||||
"endpoint_not_found_error": "Negăsit (404): Endpointul nu există",
|
||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||
"endpoint_service_unavailable_error": "Serviciu indisponibil (503): Serviciul este temporar indisponibil",
|
||||
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||
"no_triggers": "Fără declanșatori",
|
||||
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
||||
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
||||
"response_created": "Răspuns creat",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Funcții Enterprise",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
|
||||
"license_feature_access_control": "Control acces (RBAC)",
|
||||
"license_feature_audit_logs": "Jurnale de audit",
|
||||
"license_feature_contacts": "Contacte și segmente",
|
||||
"license_feature_projects": "Spații de lucru",
|
||||
"license_feature_quotas": "Cote",
|
||||
"license_feature_remove_branding": "Elimină branding-ul",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Protecție spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autentificare cu doi factori",
|
||||
"license_feature_whitelabel": "E-mailuri white-label",
|
||||
"license_features_table_access": "Acces",
|
||||
"license_features_table_description": "Funcționalități și limite enterprise disponibile în prezent pentru această instanță.",
|
||||
"license_features_table_disabled": "Dezactivat",
|
||||
"license_features_table_enabled": "Activat",
|
||||
"license_features_table_feature": "Funcționalitate",
|
||||
"license_features_table_title": "Funcționalități licențiate",
|
||||
"license_features_table_unlimited": "Nelimitat",
|
||||
"license_features_table_value": "Valoare",
|
||||
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
|
||||
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
|
||||
"license_status": "Stare licență",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Eroare la salvarea modificărilor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
|
||||
"everyone": "Toată lumea",
|
||||
"expand_preview": "Extinde previzualizarea",
|
||||
"external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.",
|
||||
"fallback_missing": "Rezerva lipsă",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
|
||||
"response_options": "Opțiuni răspuns",
|
||||
"reverse_order_occasionally": "Inversare ordine ocazional",
|
||||
"reverse_order_occasionally_except_last": "Inversare ordine ocazional cu excepția ultimului",
|
||||
"roundness": "Rotunjire",
|
||||
"roundness_description": "Controlează cât de rotunjite sunt colțurile.",
|
||||
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "Afișează sondajul de maxim",
|
||||
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
|
||||
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
|
||||
"shrink_preview": "Restrânge previzualizarea",
|
||||
"simple": "Simplu",
|
||||
"six_points": "6 puncte",
|
||||
"smiley": "Smiley",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
|
||||
"subheading": "Subtitlu",
|
||||
"subtract": "Scade -",
|
||||
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
|
||||
"survey_completed_heading": "Sondaj Completat",
|
||||
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
|
||||
"survey_display_settings": "Setări de afișare a sondajului",
|
||||
"survey_placement": "Amplasarea sondajului",
|
||||
"survey_preview": "Previzualizare chestionar 👀",
|
||||
"survey_styling": "Stilizare formular",
|
||||
"survey_trigger": "Declanșator sondaj",
|
||||
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
|
||||
"preview_survey_question_2_headline": "Vrei să fii în temă?",
|
||||
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
|
||||
"preview_survey_question_open_text_headline": "Mai vrei să împărtășești ceva?",
|
||||
"preview_survey_question_open_text_headline": "Mai aveți ceva de adăugat?",
|
||||
"preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...",
|
||||
"preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
|
||||
"preview_survey_welcome_card_headline": "Bun venit!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
|
||||
"coming_soon_title": "Suntem aproape gata!",
|
||||
"follow_up_label": "Mai este ceva ce ai vrea să adaugi?",
|
||||
"follow_up_label": "Mai este ceva ce ați dori să adăugați?",
|
||||
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
|
||||
"generate_button": "Generează workflow",
|
||||
"heading": "Ce workflow vrei să creezi?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Новый",
|
||||
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
|
||||
"next": "Далее",
|
||||
"no_actions_found": "Действия не найдены",
|
||||
"no_background_image_found": "Фоновое изображение не найдено.",
|
||||
"no_code": "Нет кода",
|
||||
"no_files_uploaded": "Файлы не были загружены",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
|
||||
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
|
||||
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
|
||||
"powered_by_formbricks": "Работает на Formbricks",
|
||||
"preview": "Предпросмотр",
|
||||
"preview_survey": "Предпросмотр опроса",
|
||||
"privacy": "Политика конфиденциальности",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Выбрать",
|
||||
"select_all": "Выбрать все",
|
||||
"select_filter": "Выбрать фильтр",
|
||||
"select_language": "Выберите язык",
|
||||
"select_survey": "Выбрать опрос",
|
||||
"select_teams": "Выбрать команды",
|
||||
"selected": "Выбрано",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "Создано сторонней организацией",
|
||||
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
||||
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
||||
"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": "Ура! Нам удалось отправить ping на webhook!",
|
||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||
"endpoint_service_unavailable_error": "Сервис недоступен (503): Сервис временно недоступен",
|
||||
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||
"no_triggers": "Нет триггеров",
|
||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||
"response_created": "Ответ создан",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Функции для предприятий",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
|
||||
"license_feature_access_control": "Управление доступом (RBAC)",
|
||||
"license_feature_audit_logs": "Журналы аудита",
|
||||
"license_feature_contacts": "Контакты и сегменты",
|
||||
"license_feature_projects": "Рабочие пространства",
|
||||
"license_feature_quotas": "Квоты",
|
||||
"license_feature_remove_branding": "Удаление брендирования",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Защита от спама",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Двухфакторная аутентификация",
|
||||
"license_feature_whitelabel": "Электронные письма без брендирования",
|
||||
"license_features_table_access": "Доступ",
|
||||
"license_features_table_description": "Корпоративные функции и ограничения, доступные для этого экземпляра.",
|
||||
"license_features_table_disabled": "Отключено",
|
||||
"license_features_table_enabled": "Включено",
|
||||
"license_features_table_feature": "Функция",
|
||||
"license_features_table_title": "Лицензированные функции",
|
||||
"license_features_table_unlimited": "Без ограничений",
|
||||
"license_features_table_value": "Значение",
|
||||
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
|
||||
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
|
||||
"license_status": "Статус лицензии",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Ошибка при сохранении изменений",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).",
|
||||
"everyone": "Все",
|
||||
"expand_preview": "Развернуть предпросмотр",
|
||||
"external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.",
|
||||
"fallback_missing": "Запасное значение отсутствует",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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}. Пожалуйста, сначала удалите её из логики.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "Показать опрос максимум",
|
||||
"show_survey_to_users": "Показать опрос % пользователей",
|
||||
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
|
||||
"shrink_preview": "Свернуть предпросмотр",
|
||||
"simple": "Простой",
|
||||
"six_points": "6 баллов",
|
||||
"smiley": "Смайлик",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
|
||||
"subheading": "Подзаголовок",
|
||||
"subtract": "Вычесть -",
|
||||
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
|
||||
"survey_completed_heading": "Опрос завершён",
|
||||
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
|
||||
"survey_display_settings": "Настройки отображения опроса",
|
||||
"survey_placement": "Размещение опроса",
|
||||
"survey_preview": "Предпросмотр опроса 👀",
|
||||
"survey_styling": "Оформление формы",
|
||||
"survey_trigger": "Триггер опроса",
|
||||
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Нет, спасибо!",
|
||||
"preview_survey_question_2_headline": "Хотите быть в курсе событий?",
|
||||
"preview_survey_question_2_subheader": "Это пример описания.",
|
||||
"preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?",
|
||||
"preview_survey_question_open_text_headline": "Хотите ли вы чем-то ещё поделиться?",
|
||||
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
|
||||
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
|
||||
"preview_survey_welcome_card_headline": "Добро пожаловать!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
|
||||
"coming_soon_title": "Мы почти готовы!",
|
||||
"follow_up_label": "Хочешь что-то ещё добавить?",
|
||||
"follow_up_label": "Хотите ли вы что-нибудь добавить?",
|
||||
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
|
||||
"generate_button": "Сгенерировать воркфлоу",
|
||||
"heading": "Какой воркфлоу ты хочешь создать?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "Ny",
|
||||
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
|
||||
"next": "Nästa",
|
||||
"no_actions_found": "Inga åtgärder hittades",
|
||||
"no_background_image_found": "Ingen bakgrundsbild hittades.",
|
||||
"no_code": "Ingen kod",
|
||||
"no_files_uploaded": "Inga filer laddades upp",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
|
||||
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
|
||||
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
|
||||
"powered_by_formbricks": "Drivs av Formbricks",
|
||||
"preview": "Förhandsgranska",
|
||||
"preview_survey": "Förhandsgranska enkät",
|
||||
"privacy": "Integritetspolicy",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "Välj",
|
||||
"select_all": "Välj alla",
|
||||
"select_filter": "Välj filter",
|
||||
"select_language": "Välj språk",
|
||||
"select_survey": "Välj enkät",
|
||||
"select_teams": "Välj team",
|
||||
"selected": "Vald",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "Skapad av tredje part",
|
||||
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
|
||||
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Felaktig gateway (502): Proxy-/gatewayfel, tjänsten kan inte nås",
|
||||
"endpoint_gateway_timeout_error": "Gateway-timeout (504): Gateway-timeout, tjänsten kan inte nås",
|
||||
"endpoint_internal_server_error": "Internt serverfel (500): Tjänsten stötte på ett oväntat fel",
|
||||
"endpoint_method_not_allowed_error": "Metoden tillåts inte (405): Endpointen finns, men accepterar inte POST-förfrågningar",
|
||||
"endpoint_not_found_error": "Hittades inte (404): Endpointen finns inte",
|
||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||
"endpoint_service_unavailable_error": "Tjänsten är inte tillgänglig (503): Tjänsten är tillfälligt nere",
|
||||
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||
"no_triggers": "Inga utlösare",
|
||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||
"please_enter_a_url": "Vänligen ange en URL",
|
||||
"response_created": "Svar skapat",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "Enterprise-funktioner",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
|
||||
"license_feature_access_control": "Åtkomstkontroll (RBAC)",
|
||||
"license_feature_audit_logs": "Granskningsloggar",
|
||||
"license_feature_contacts": "Kontakter & Segment",
|
||||
"license_feature_projects": "Arbetsytor",
|
||||
"license_feature_quotas": "Kvoter",
|
||||
"license_feature_remove_branding": "Ta bort varumärkning",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Skräppostskydd",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Tvåfaktorsautentisering",
|
||||
"license_feature_whitelabel": "White-label-mejl",
|
||||
"license_features_table_access": "Åtkomst",
|
||||
"license_features_table_description": "Företagsfunktioner och begränsningar som för närvarande är tillgängliga för den här instansen.",
|
||||
"license_features_table_disabled": "Inaktiverad",
|
||||
"license_features_table_enabled": "Aktiverad",
|
||||
"license_features_table_feature": "Funktion",
|
||||
"license_features_table_title": "Licensierade funktioner",
|
||||
"license_features_table_unlimited": "Obegränsad",
|
||||
"license_features_table_value": "Värde",
|
||||
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
|
||||
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
|
||||
"license_status": "Licensstatus",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "Fel vid sparande av ändringar",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).",
|
||||
"everyone": "Alla",
|
||||
"expand_preview": "Expandera förhandsgranskning",
|
||||
"external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
|
||||
"fallback_missing": "Reservvärde saknas",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.",
|
||||
"response_options": "Svarsalternativ",
|
||||
"reverse_order_occasionally": "Vänd ordning ibland",
|
||||
"reverse_order_occasionally_except_last": "Vänd ordning ibland utom sista",
|
||||
"roundness": "Rundhet",
|
||||
"roundness_description": "Styr hur rundade hörnen är.",
|
||||
"row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "Visa enkät maximalt",
|
||||
"show_survey_to_users": "Visa enkät för % av användare",
|
||||
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
|
||||
"shrink_preview": "Minimera förhandsgranskning",
|
||||
"simple": "Enkel",
|
||||
"six_points": "6 poäng",
|
||||
"smiley": "Smiley",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "Styling inställd på temastil",
|
||||
"subheading": "Underrubrik",
|
||||
"subtract": "Subtrahera -",
|
||||
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
|
||||
"survey_completed_heading": "Enkät slutförd",
|
||||
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
|
||||
"survey_display_settings": "Visningsinställningar för enkät",
|
||||
"survey_placement": "Enkätplacering",
|
||||
"survey_preview": "Enkätförhandsgranskning 👀",
|
||||
"survey_styling": "Formulärstil",
|
||||
"survey_trigger": "Enkätutlösare",
|
||||
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nej, tack!",
|
||||
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
|
||||
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
|
||||
"preview_survey_question_open_text_headline": "Något mer du vill dela med dig av?",
|
||||
"preview_survey_question_open_text_headline": "Finns det något annat du vill dela med dig av?",
|
||||
"preview_survey_question_open_text_placeholder": "Skriv ditt svar här...",
|
||||
"preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.",
|
||||
"preview_survey_welcome_card_headline": "Välkommen!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
|
||||
"coming_soon_title": "Vi är nästan där!",
|
||||
"follow_up_label": "Är det något mer du vill lägga till?",
|
||||
"follow_up_label": "Finns det något annat du vill lägga till?",
|
||||
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
|
||||
"generate_button": "Skapa arbetsflöde",
|
||||
"heading": "Vilket arbetsflöde vill du skapa?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "新建",
|
||||
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
|
||||
"next": "下一步",
|
||||
"no_actions_found": "未找到操作",
|
||||
"no_background_image_found": "未找到 背景 图片。",
|
||||
"no_code": "无代码",
|
||||
"no_files_uploaded": "没有 文件 被 上传",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "请选择至少 一个调查",
|
||||
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
|
||||
"please_upgrade_your_plan": "请升级您的计划",
|
||||
"powered_by_formbricks": "由 Formbricks 提供支持",
|
||||
"preview": "预览",
|
||||
"preview_survey": "预览 Survey",
|
||||
"privacy": "隐私政策",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "选择",
|
||||
"select_all": "选择 全部",
|
||||
"select_filter": "选择过滤器",
|
||||
"select_language": "选择语言",
|
||||
"select_survey": "选择 调查",
|
||||
"select_teams": "选择 团队",
|
||||
"selected": "已选择",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "由 第三方 创建",
|
||||
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
||||
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
||||
"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": "太好了! 我们能 ping 该 webhook!",
|
||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||
"endpoint_service_unavailable_error": "服务不可用 (503):服务暂时不可用",
|
||||
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||
"no_triggers": "无触发器",
|
||||
"please_check_console": "请查看控制台以获取更多详情",
|
||||
"please_enter_a_url": "请输入一个 URL",
|
||||
"response_created": "创建 响应",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "企业 功能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
|
||||
"license_feature_access_control": "访问控制(RBAC)",
|
||||
"license_feature_audit_logs": "审计日志",
|
||||
"license_feature_contacts": "联系人与细分",
|
||||
"license_feature_projects": "工作空间",
|
||||
"license_feature_quotas": "配额",
|
||||
"license_feature_remove_branding": "移除品牌标识",
|
||||
"license_feature_saml": "SAML 单点登录",
|
||||
"license_feature_spam_protection": "垃圾信息防护",
|
||||
"license_feature_sso": "OIDC 单点登录",
|
||||
"license_feature_two_factor_auth": "双因素认证",
|
||||
"license_feature_whitelabel": "白标电子邮件",
|
||||
"license_features_table_access": "访问权限",
|
||||
"license_features_table_description": "此实例当前可用的企业功能和限制。",
|
||||
"license_features_table_disabled": "已禁用",
|
||||
"license_features_table_enabled": "已启用",
|
||||
"license_features_table_feature": "功能",
|
||||
"license_features_table_title": "许可功能",
|
||||
"license_features_table_unlimited": "无限制",
|
||||
"license_features_table_value": "值",
|
||||
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
|
||||
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
|
||||
"license_status": "许可证状态",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "保存 更改 时 出错",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
|
||||
"everyone": "所有 人",
|
||||
"expand_preview": "展开预览",
|
||||
"external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。",
|
||||
"fallback_missing": "备用 缺失",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "显示 调查 最大 一次",
|
||||
"show_survey_to_users": "显示 问卷 给 % 的 用户",
|
||||
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
|
||||
"shrink_preview": "收起预览",
|
||||
"simple": "简单",
|
||||
"six_points": "6 分",
|
||||
"smiley": "笑脸",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "样式 设置 为 主题 风格",
|
||||
"subheading": "子标题",
|
||||
"subtract": "减 -",
|
||||
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
|
||||
"survey_completed_heading": "调查 完成",
|
||||
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
|
||||
"survey_display_settings": "调查显示设置",
|
||||
"survey_placement": "调查 放置",
|
||||
"survey_preview": "问卷预览 👀",
|
||||
"survey_styling": "表单 样式",
|
||||
"survey_trigger": "调查 触发",
|
||||
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "不,谢谢!",
|
||||
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
|
||||
"preview_survey_question_2_subheader": "这是一个示例描述。",
|
||||
"preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?",
|
||||
"preview_survey_question_open_text_headline": "还有其他想分享的内容吗?",
|
||||
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
|
||||
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
|
||||
"preview_survey_welcome_card_headline": "欢迎!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
|
||||
"coming_soon_title": "我们快完成啦!",
|
||||
"follow_up_label": "你还有其他想补充的吗?",
|
||||
"follow_up_label": "还有其他想补充的内容吗?",
|
||||
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
|
||||
"generate_button": "生成工作流",
|
||||
"heading": "你想创建什么样的工作流?",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"new": "新增",
|
||||
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
|
||||
"next": "下一步",
|
||||
"no_actions_found": "找不到動作",
|
||||
"no_background_image_found": "找不到背景圖片。",
|
||||
"no_code": "無程式碼",
|
||||
"no_files_uploaded": "沒有上傳任何檔案",
|
||||
@@ -339,6 +340,7 @@
|
||||
"please_select_at_least_one_survey": "請選擇至少一個問卷",
|
||||
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
|
||||
"please_upgrade_your_plan": "請升級您的方案",
|
||||
"powered_by_formbricks": "由 Formbricks 提供技術支援",
|
||||
"preview": "預覽",
|
||||
"preview_survey": "預覽問卷",
|
||||
"privacy": "隱私權政策",
|
||||
@@ -380,6 +382,7 @@
|
||||
"select": "選擇",
|
||||
"select_all": "全選",
|
||||
"select_filter": "選擇篩選器",
|
||||
"select_language": "選擇語言",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_teams": "選擇 團隊",
|
||||
"selected": "已選取",
|
||||
@@ -850,9 +853,16 @@
|
||||
"created_by_third_party": "由第三方建立",
|
||||
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
||||
"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": "耶!我們能夠 ping Webhook!",
|
||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||
"endpoint_service_unavailable_error": "服務無法使用 (503):服務暫時無法使用",
|
||||
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||
"no_triggers": "無觸發條件",
|
||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||
"please_enter_a_url": "請輸入網址",
|
||||
"response_created": "已建立回應",
|
||||
@@ -1071,6 +1081,25 @@
|
||||
"enterprise_features": "企業版功能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
|
||||
"license_feature_access_control": "存取控制 (RBAC)",
|
||||
"license_feature_audit_logs": "稽核日誌",
|
||||
"license_feature_contacts": "聯絡人與區隔",
|
||||
"license_feature_projects": "工作區",
|
||||
"license_feature_quotas": "配額",
|
||||
"license_feature_remove_branding": "移除品牌標識",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "垃圾訊息防護",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "雙重驗證",
|
||||
"license_feature_whitelabel": "白標電子郵件",
|
||||
"license_features_table_access": "存取權限",
|
||||
"license_features_table_description": "此執行個體目前可使用的企業功能與限制。",
|
||||
"license_features_table_disabled": "已停用",
|
||||
"license_features_table_enabled": "已啟用",
|
||||
"license_features_table_feature": "功能",
|
||||
"license_features_table_title": "授權功能",
|
||||
"license_features_table_unlimited": "無限制",
|
||||
"license_features_table_value": "值",
|
||||
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
|
||||
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
|
||||
"license_status": "授權狀態",
|
||||
@@ -1430,6 +1459,7 @@
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
|
||||
"everyone": "所有人",
|
||||
"expand_preview": "展開預覽",
|
||||
"external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。",
|
||||
"fallback_missing": "遺失的回退",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
@@ -1655,6 +1685,8 @@
|
||||
"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": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
@@ -1683,6 +1715,7 @@
|
||||
"show_survey_maximum_of": "最多顯示問卷",
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
|
||||
"shrink_preview": "收合預覽",
|
||||
"simple": "簡單",
|
||||
"six_points": "6 分",
|
||||
"smiley": "表情符號",
|
||||
@@ -1698,10 +1731,12 @@
|
||||
"styling_set_to_theme_styles": "樣式設定為主題樣式",
|
||||
"subheading": "副標題",
|
||||
"subtract": "減 -",
|
||||
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
|
||||
"survey_completed_heading": "問卷已完成",
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
"survey_placement": "問卷位置",
|
||||
"survey_preview": "問卷預覽 👀",
|
||||
"survey_styling": "表單樣式設定",
|
||||
"survey_trigger": "問卷觸發器",
|
||||
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
|
||||
@@ -3052,7 +3087,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
|
||||
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
|
||||
"preview_survey_question_2_subheader": "這是一個範例說明。",
|
||||
"preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎?",
|
||||
"preview_survey_question_open_text_headline": "還有其他想分享的嗎?",
|
||||
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
|
||||
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
|
||||
"preview_survey_welcome_card_headline": "歡迎!",
|
||||
@@ -3307,7 +3342,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
|
||||
"coming_soon_title": "快完成囉!",
|
||||
"follow_up_label": "還有什麼想補充的嗎?",
|
||||
"follow_up_label": "還有其他想補充的嗎?",
|
||||
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
|
||||
"generate_button": "產生工作流程",
|
||||
"heading": "你想建立什麼樣的工作流程?",
|
||||
|
||||
+8
-2
@@ -1,5 +1,6 @@
|
||||
import { Languages } from "lucide-react";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
@@ -17,7 +18,12 @@ interface LanguageDropdownProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
|
||||
export const LanguageDropdown = ({
|
||||
survey,
|
||||
setLanguage,
|
||||
locale,
|
||||
}: LanguageDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
|
||||
|
||||
if (enabledLanguages.length <= 1) {
|
||||
@@ -27,7 +33,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" title="Select Language" aria-label="Select Language">
|
||||
<Button variant="secondary" title={t("common.select_language")} aria-label={t("common.select_language")}>
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("helpers", () => {
|
||||
test("should allow request when rate limit check passes", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toEqual({ allowed: true });
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
@@ -127,7 +127,7 @@ describe("helpers", () => {
|
||||
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toEqual({ allowed: true });
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
|
||||
});
|
||||
@@ -138,7 +138,7 @@ describe("helpers", () => {
|
||||
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(mockConfig, identifier)).resolves.toEqual({ allowed: true });
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("helpers", () => {
|
||||
(hashString as any).mockReturnValue("hashed-ip-123");
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined();
|
||||
await expect(applyIPRateLimit(mockConfig)).resolves.toEqual({ allowed: true });
|
||||
|
||||
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
|
||||
expect(hashString).toHaveBeenCalledWith("192.168.1.1");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { type TRateLimitConfig } from "./types/rate-limit";
|
||||
import { type TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
|
||||
|
||||
/**
|
||||
* Get client identifier for rate limiting with IP hashing
|
||||
@@ -31,12 +31,20 @@ export const getClientIdentifier = async (): Promise<string> => {
|
||||
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
|
||||
* @throws {Error} When rate limit is exceeded or rate limiting system fails
|
||||
*/
|
||||
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => {
|
||||
export const applyRateLimit = async (
|
||||
config: TRateLimitConfig,
|
||||
identifier: string
|
||||
): Promise<TRateLimitResponse> => {
|
||||
const result = await checkRateLimit(config, identifier);
|
||||
|
||||
if (!result.ok || !result.data.allowed) {
|
||||
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later.");
|
||||
throw new TooManyRequestsError(
|
||||
"Maximum number of requests reached. Please try again later.",
|
||||
result.ok ? result.data.retryAfter : undefined
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -46,7 +54,7 @@ export const applyRateLimit = async (config: TRateLimitConfig, identifier: strin
|
||||
* @param config - Rate limit configuration to apply
|
||||
* @throws {Error} When rate limit is exceeded or IP hashing fails
|
||||
*/
|
||||
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => {
|
||||
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<TRateLimitResponse> => {
|
||||
const identifier = await getClientIdentifier();
|
||||
await applyRateLimit(config, identifier);
|
||||
return await applyRateLimit(config, identifier);
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all API configurations", () => {
|
||||
const apiConfigs = Object.keys(rateLimitConfigs.api);
|
||||
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
|
||||
expect(apiConfigs).toEqual(["v1", "v2", "v3", "client"]);
|
||||
});
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
@@ -127,7 +127,7 @@ describe("rateLimitConfigs", () => {
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
const config = rateLimitConfigs.api.v1;
|
||||
await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined();
|
||||
await expect(applyRateLimit(config, "api-key-123")).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
test("should enforce limits correctly for each config type", async () => {
|
||||
@@ -136,6 +136,7 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
|
||||
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
|
||||
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
|
||||
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
|
||||
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
|
||||
|
||||
@@ -11,6 +11,7 @@ export const rateLimitConfigs = {
|
||||
api: {
|
||||
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API)
|
||||
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
|
||||
v3: { interval: 60, allowedPerInterval: 100, namespace: "api:v3" }, // 100 per minute
|
||||
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
|
||||
},
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export const checkRateLimit = async (
|
||||
|
||||
const response: TRateLimitResponse = {
|
||||
allowed: isAllowed === 1,
|
||||
retryAfter: isAllowed === 1 ? undefined : ttlSeconds,
|
||||
};
|
||||
|
||||
// Log rate limit violations for security monitoring
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TRateLimitConfig = z.infer<typeof ZRateLimitConfig>;
|
||||
|
||||
const ZRateLimitResponse = z.object({
|
||||
allowed: z.boolean().describe("Whether the request is allowed"),
|
||||
retryAfter: z.int().positive().optional().describe("Seconds until the current rate-limit window resets"),
|
||||
});
|
||||
|
||||
export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>;
|
||||
|
||||
@@ -6,11 +6,11 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "uploadBulkContacts",
|
||||
summary: "Upload Bulk Contacts",
|
||||
description:
|
||||
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
|
||||
"Uploads contacts in bulk. This endpoint expects the bulk request shape: `contacts` must be an array, and each contact item must contain an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST /management/contacts`, this endpoint does not accept a top-level `attributes` object. Each contact must include an `email` attribute in its `attributes` array, and that email must be valid.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
description:
|
||||
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
|
||||
"The contacts to upload. Use the full nested bulk body shown in the example or cURL snippet: `{ environmentId, contacts: [{ attributes: [{ attributeKey: { key, name }, value }] }] }`. Each contact must include an `email` attribute inside its `attributes` array.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactBulkUploadRequest,
|
||||
|
||||
@@ -6,13 +6,13 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContact",
|
||||
summary: "Create a contact",
|
||||
description:
|
||||
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
|
||||
"Creates a single contact in the database. This endpoint expects a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`, which expects `contacts[].attributes[]` instead. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
|
||||
tags: ["Management API - Contacts"],
|
||||
|
||||
requestBody: {
|
||||
required: true,
|
||||
description:
|
||||
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.",
|
||||
"The single contact to create. Must include a top-level `attributes` object with an email attribute, and all attribute keys must already exist in the environment.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactCreateRequest,
|
||||
|
||||
@@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({
|
||||
label: t("common.contacts"),
|
||||
href: `/environments/${environmentId}/contacts`,
|
||||
},
|
||||
{
|
||||
id: "segments",
|
||||
label: t("common.segments"),
|
||||
href: `/environments/${environmentId}/segments`,
|
||||
},
|
||||
{
|
||||
id: "attributes",
|
||||
label: t("common.attributes"),
|
||||
href: `/environments/${environmentId}/attributes`,
|
||||
},
|
||||
{
|
||||
id: "segments",
|
||||
label: t("common.segments"),
|
||||
href: `/environments/${environmentId}/segments`,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -133,6 +133,10 @@ export function SegmentSettings({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segment.filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// parse the filters to check if they are valid
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
|
||||
const validFilters = [
|
||||
{
|
||||
id: createId(),
|
||||
connector: null,
|
||||
resource: {
|
||||
id: createId(),
|
||||
root: {
|
||||
type: "attribute" as const,
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
value: "user@example.com",
|
||||
qualifier: {
|
||||
operator: "equals" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("segment schema validation", () => {
|
||||
test("keeps base segment filters compatible with empty arrays", () => {
|
||||
const result = ZSegmentFilters.safeParse([]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("requires at least one filter when creating a segment", () => {
|
||||
const result = ZSegmentCreateInput.safeParse({
|
||||
environmentId: "environmentId",
|
||||
title: "Power users",
|
||||
description: "Users with a matching email",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
surveyId: "surveyId",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
|
||||
});
|
||||
|
||||
test("accepts segment creation with a valid filter", () => {
|
||||
const result = ZSegmentCreateInput.safeParse({
|
||||
environmentId: "environmentId",
|
||||
title: "Power users",
|
||||
description: "Users with a matching email",
|
||||
isPrivate: false,
|
||||
filters: validFilters,
|
||||
surveyId: "surveyId",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("requires at least one filter when updating a segment", () => {
|
||||
const result = ZSegmentUpdateInput.safeParse({
|
||||
filters: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
|
||||
});
|
||||
|
||||
test("accepts segment updates with a valid filter", () => {
|
||||
const result = ZSegmentUpdateInput.safeParse({
|
||||
filters: validFilters,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<ElementHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Section className="border-input-border-color rounded-custom mt-4 block h-20 w-full border border-solid bg-slate-50" />
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
@@ -124,7 +124,7 @@ export async function PreviewEmailTemplate({
|
||||
{t("emails.accept")}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
@@ -172,7 +172,7 @@ export async function PreviewEmailTemplate({
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
@@ -193,7 +193,7 @@ export async function PreviewEmailTemplate({
|
||||
</EmailButton>
|
||||
</Container>
|
||||
)}
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
@@ -246,7 +246,7 @@ export async function PreviewEmailTemplate({
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
@@ -263,7 +263,7 @@ export async function PreviewEmailTemplate({
|
||||
</Section>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
@@ -279,7 +279,7 @@ export async function PreviewEmailTemplate({
|
||||
</Section>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
@@ -296,7 +296,7 @@ export async function PreviewEmailTemplate({
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
@@ -322,7 +322,7 @@ export async function PreviewEmailTemplate({
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Cal:
|
||||
@@ -338,7 +338,7 @@ export async function PreviewEmailTemplate({
|
||||
{t("emails.schedule_your_meeting")}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
@@ -351,7 +351,7 @@ export async function PreviewEmailTemplate({
|
||||
{t("emails.select_a_date")}
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
@@ -392,7 +392,7 @@ export async function PreviewEmailTemplate({
|
||||
})}
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
@@ -407,7 +407,7 @@ export async function PreviewEmailTemplate({
|
||||
{label}
|
||||
</Section>
|
||||
))}
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
|
||||
@@ -421,7 +421,7 @@ export async function PreviewEmailTemplate({
|
||||
<Text className="text-slate-400">{t("emails.click_or_drag_to_upload_files")}</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
<EmailFooter t={t} />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
@@ -477,11 +477,11 @@ function EmailTemplateWrapper({
|
||||
);
|
||||
}
|
||||
|
||||
function EmailFooter(): React.JSX.Element {
|
||||
function EmailFooter({ t }: { t: TFunction }): React.JSX.Element {
|
||||
return (
|
||||
<Container className="m-auto mt-8 text-center">
|
||||
<Link className="text-signature-color text-xs" href="https://formbricks.com/" target="_blank">
|
||||
Powered by Formbricks
|
||||
{t("common.powered_by_formbricks")}
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -85,9 +85,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||
toast.error(
|
||||
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${
|
||||
errMessage.length < 250
|
||||
? `${t("common.error")}: ${errMessage}`
|
||||
: t("environments.integrations.webhooks.please_check_console")
|
||||
errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")
|
||||
}`,
|
||||
{ className: errMessage.length < 250 ? "break-all" : "" }
|
||||
);
|
||||
|
||||
@@ -9,21 +9,33 @@ import { timeSince } from "@/lib/time";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
|
||||
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
|
||||
let surveyNames: string[];
|
||||
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
const allSurveyNames = allSurveys.map((survey) => survey.name);
|
||||
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
|
||||
surveyNames = allSurveys.map((survey) => survey.name);
|
||||
} else {
|
||||
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
surveyNames = webhook.surveyIds
|
||||
.map((surveyId) => {
|
||||
const survey = allSurveys.find((s) => s.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (surveyNames.length === 0) {
|
||||
return <p className="text-slate-400">-</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="truncate text-slate-400" title={surveyNames.join(", ")}>
|
||||
{surveyNames.join(", ")}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
|
||||
if (webhook.triggers.length === 0) {
|
||||
return <p className="text-slate-400">No Triggers</p>;
|
||||
return <p className="text-slate-400">{t("environments.integrations.webhooks.no_triggers")}</p>;
|
||||
} else {
|
||||
let cleanedTriggers = webhook.triggers.map((trigger) => {
|
||||
if (trigger === "responseCreated") {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
setHittingEndpoint(false);
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||
toast.error(
|
||||
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? `${t("common.error")}: ${errMessage}` : t("environments.integrations.webhooks.please_check_console")}`,
|
||||
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")}`,
|
||||
{ className: errMessage.length < 250 ? "break-all" : "" }
|
||||
);
|
||||
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), errMessage);
|
||||
@@ -300,7 +300,9 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
)}
|
||||
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://formbricks.com/docs/api/management/webhooks" target="_blank">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks"
|
||||
target="_blank">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { testEndpoint } from "./webhook";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
|
||||
generateWebhookSecret: vi.fn(() => "generated-secret"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateWebhookUrl: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
getTranslate: vi.fn(async () => (key: string) => key),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/integrations/webhooks/lib/utils", () => ({
|
||||
isDiscordWebhook: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("uuid", () => ({
|
||||
v7: vi.fn(() => "webhook-message-id"),
|
||||
}));
|
||||
|
||||
describe("testEndpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
||||
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
||||
vi.mocked(isDiscordWebhook).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[500, "environments.integrations.webhooks.endpoint_internal_server_error"],
|
||||
[404, "environments.integrations.webhooks.endpoint_not_found_error"],
|
||||
[405, "environments.integrations.webhooks.endpoint_method_not_allowed_error"],
|
||||
[502, "environments.integrations.webhooks.endpoint_bad_gateway_error"],
|
||||
[503, "environments.integrations.webhooks.endpoint_service_unavailable_error"],
|
||||
[504, "environments.integrations.webhooks.endpoint_gateway_timeout_error"],
|
||||
])("throws a translated InvalidInputError for blocked status %s", async (statusCode, messageKey) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: statusCode,
|
||||
}))
|
||||
);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook", "secret")).rejects.toThrow(
|
||||
new InvalidInputError(messageKey)
|
||||
);
|
||||
|
||||
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(generateStandardWebhookSignature).toHaveBeenCalled();
|
||||
expect(getTranslate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows non-blocked non-2xx statuses", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 418,
|
||||
}))
|
||||
);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
|
||||
expect(getTranslate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects Discord webhooks before sending the request", async () => {
|
||||
vi.mocked(isDiscordWebhook).mockReturnValue(true);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(testEndpoint("https://discord.com/api/webhooks/123")).rejects.toThrow(
|
||||
"Discord webhooks are currently not supported."
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws a timeout error when the request is aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((_url, init) => {
|
||||
const signal = init?.signal as AbortSignal;
|
||||
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
const abortError = new Error("The operation was aborted");
|
||||
abortError.name = "AbortError";
|
||||
reject(abortError);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const requestPromise = testEndpoint("https://example.com/webhook");
|
||||
const assertion = expect(requestPromise).rejects.toThrow("Request timed out after 5 seconds");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
test("wraps unexpected fetch errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Promise.reject(new Error("socket hang up")))
|
||||
);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
|
||||
"Error while fetching the URL: socket hang up"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,41 @@ import {
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { TWebhookInput } from "../types/webhooks";
|
||||
|
||||
const getWebhookTestErrorMessage = async (statusCode: number): Promise<string | null> => {
|
||||
switch (statusCode) {
|
||||
case 500: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_internal_server_error");
|
||||
}
|
||||
case 404: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_not_found_error");
|
||||
}
|
||||
case 405: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_method_not_allowed_error");
|
||||
}
|
||||
case 502: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_bad_gateway_error");
|
||||
}
|
||||
case 503: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_service_unavailable_error");
|
||||
}
|
||||
case 504: {
|
||||
const t = await getTranslate();
|
||||
return t("environments.integrations.webhooks.endpoint_gateway_timeout_error");
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateWebhook = async (
|
||||
webhookId: string,
|
||||
webhookInput: Partial<TWebhookInput>
|
||||
@@ -132,14 +164,14 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
|
||||
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
|
||||
await validateWebhookUrl(url);
|
||||
|
||||
if (isDiscordWebhook(url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
if (isDiscordWebhook(url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
const body = JSON.stringify({ event: "testEndpoint" });
|
||||
@@ -165,27 +197,27 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
const statusCode = response.status;
|
||||
const errorMessage = await getWebhookTestErrorMessage(statusCode);
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return true;
|
||||
} else {
|
||||
const errorMessage = await response.text().then(
|
||||
(text) => text.substring(0, 1000) // Limit error message size
|
||||
);
|
||||
throw new UnknownError(`Request failed with status code ${statusCode}: ${errorMessage}`);
|
||||
if (errorMessage) {
|
||||
throw new InvalidInputError(errorMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new UnknownError("Request timed out after 5 seconds");
|
||||
}
|
||||
if (error instanceof UnknownError) {
|
||||
|
||||
if (error instanceof InvalidInputError || error instanceof UnknownError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UnknownError(
|
||||
`Error while fetching the URL: ${error instanceof Error ? error.message : "Unknown error occurred"}`
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ export const InviteMemberModal = ({
|
||||
<DialogDescription>{t("environments.settings.teams.invite_member_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex flex-col gap-6" unconstrained>
|
||||
<DialogBody className="flex min-h-0 flex-col gap-6 overflow-y-auto">
|
||||
{!showTeamAdminRestrictions && (
|
||||
<TabToggle
|
||||
id="type"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type JSX, useState } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -24,6 +25,7 @@ export const ActionClassesTable = ({
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionClassesTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
|
||||
|
||||
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
||||
@@ -56,7 +58,7 @@ export const ActionClassesTable = ({
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-sm text-slate-500">No actions found</span>
|
||||
<span className="text-sm text-slate-500">{t("common.no_actions_found")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ export const ActionSettingsTab = ({
|
||||
setOpen,
|
||||
isReadOnly,
|
||||
}: ActionSettingsTabProps) => {
|
||||
const actionDocsHref = "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions";
|
||||
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
|
||||
const router = useRouter();
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
@@ -146,7 +147,7 @@ export const ActionSettingsTab = ({
|
||||
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isReadOnly ? (
|
||||
{isReadOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
@@ -155,22 +156,22 @@ export const ActionSettingsTab = ({
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
|
||||
<Link href={actionDocsHref} target="_blank">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isReadOnly ? (
|
||||
{isReadOnly ? null : (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" loading={isUpdatingAction}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<ProjectConfigNavigation activeId="tags" />
|
||||
<ProjectConfigNavigation activeId="tags" loading />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.workspace.tags.manage_tags")}
|
||||
|
||||
@@ -26,22 +26,27 @@ import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { getProject } from "./lib/project";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks if survey follow-ups can be added for the given organization.
|
||||
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
|
||||
* Only throws when new follow-ups are being added.
|
||||
*/
|
||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||
const checkSurveyFollowUpsPermission = async (
|
||||
organizationId: string,
|
||||
newFollowUpIds: string[],
|
||||
oldFollowUpIds: Set<string>
|
||||
): Promise<void> => {
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
if (isSurveyFollowUpsEnabled) return;
|
||||
|
||||
for (const id of newFollowUpIds) {
|
||||
if (!oldFollowUpIds.has(id)) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,14 +76,19 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (survey.followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = survey.id;
|
||||
const oldObject = await getSurvey(survey.id);
|
||||
|
||||
if (survey.followUps.length) {
|
||||
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
|
||||
await checkSurveyFollowUpsPermission(
|
||||
organizationId,
|
||||
survey.followUps.map((f) => f.id),
|
||||
oldFollowUpIds
|
||||
);
|
||||
}
|
||||
|
||||
await checkExternalUrlsPermission(organizationId, survey, oldObject);
|
||||
|
||||
// Use the draft version that skips validation
|
||||
@@ -116,14 +126,19 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (parsedInput.followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
const oldObject = await getSurvey(parsedInput.id);
|
||||
|
||||
if (parsedInput.followUps?.length) {
|
||||
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
|
||||
await checkSurveyFollowUpsPermission(
|
||||
organizationId,
|
||||
parsedInput.followUps.map((f) => f.id),
|
||||
oldFollowUpIds
|
||||
);
|
||||
}
|
||||
|
||||
// Check external URLs permission (with grandfathering)
|
||||
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
|
||||
const result = await updateSurvey(parsedInput);
|
||||
|
||||
@@ -129,8 +129,10 @@ export const EditWelcomeCard = ({
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
|
||||
if (url?.[0]) {
|
||||
if (url?.length) {
|
||||
updateSurvey({ fileUrl: url[0] });
|
||||
} else {
|
||||
updateSurvey({ fileUrl: undefined });
|
||||
}
|
||||
}}
|
||||
fileUrl={localSurvey?.welcomeCard?.fileUrl}
|
||||
|
||||
@@ -188,6 +188,16 @@ export const MatrixElementForm = ({
|
||||
label: t("environments.surveys.edit.randomize_all_except_last"),
|
||||
show: true,
|
||||
},
|
||||
reverseOrderOccasionally: {
|
||||
id: "reverseOrderOccasionally",
|
||||
label: t("environments.surveys.edit.reverse_order_occasionally"),
|
||||
show: true,
|
||||
},
|
||||
reverseOrderExceptLast: {
|
||||
id: "reverseOrderExceptLast",
|
||||
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const MultipleChoiceElementForm = ({
|
||||
const elementRef = useRef<HTMLInputElement>(null);
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
const shuffleOptionsTypes = {
|
||||
const shuffleOptionsTypes: Record<TShuffleOption, { id: TShuffleOption; label: string; show: boolean }> = {
|
||||
none: {
|
||||
id: "none",
|
||||
label: t("environments.surveys.edit.keep_current_order"),
|
||||
@@ -79,6 +79,16 @@ export const MultipleChoiceElementForm = ({
|
||||
label: t("environments.surveys.edit.randomize_all_except_last"),
|
||||
show: true,
|
||||
},
|
||||
reverseOrderOccasionally: {
|
||||
id: "reverseOrderOccasionally",
|
||||
label: t("environments.surveys.edit.reverse_order_occasionally"),
|
||||
show: element.choices.every((c) => c.id !== "other" && c.id !== "none"),
|
||||
},
|
||||
reverseOrderExceptLast: {
|
||||
id: "reverseOrderExceptLast",
|
||||
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
|
||||
const multipleChoiceOptionDisplayTypeOptions = [
|
||||
@@ -167,7 +177,10 @@ export const MultipleChoiceElementForm = ({
|
||||
updateElement(elementIdx, {
|
||||
choices: newChoices,
|
||||
...(element.shuffleOption === shuffleOptionsTypes.all.id && {
|
||||
shuffleOption: shuffleOptionsTypes.exceptLast.id as TShuffleOption,
|
||||
shuffleOption: shuffleOptionsTypes.exceptLast.id,
|
||||
}),
|
||||
...(element.shuffleOption === shuffleOptionsTypes.reverseOrderOccasionally.id && {
|
||||
shuffleOption: shuffleOptionsTypes.reverseOrderExceptLast.id,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -193,8 +206,18 @@ export const MultipleChoiceElementForm = ({
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
|
||||
const hasRemainingSpecialChoices = newChoices.some((c) => c.id === "other" || c.id === "none");
|
||||
|
||||
updateElement(elementIdx, {
|
||||
choices: newChoices,
|
||||
...(!hasRemainingSpecialChoices &&
|
||||
element.shuffleOption === "reverseOrderExceptLast" && {
|
||||
shuffleOption: "reverseOrderOccasionally",
|
||||
}),
|
||||
...(!hasRemainingSpecialChoices &&
|
||||
element.shuffleOption === "exceptLast" && {
|
||||
shuffleOption: "all",
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -115,6 +115,21 @@ export const RankingElementForm = ({
|
||||
label: t("environments.surveys.edit.randomize_all"),
|
||||
show: element.choices.length > 0,
|
||||
},
|
||||
exceptLast: {
|
||||
id: "exceptLast",
|
||||
label: t("environments.surveys.edit.randomize_all_except_last"),
|
||||
show: true,
|
||||
},
|
||||
reverseOrderOccasionally: {
|
||||
id: "reverseOrderOccasionally",
|
||||
label: t("environments.surveys.edit.reverse_order_occasionally"),
|
||||
show: true,
|
||||
},
|
||||
reverseOrderExceptLast: {
|
||||
id: "reverseOrderExceptLast",
|
||||
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import type { z } from "zod";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import {
|
||||
createActionClassZodResolver,
|
||||
|
||||
@@ -316,10 +316,6 @@ describe("validation.isEndingCardValid", () => {
|
||||
const card = { ...baseRedirectUrlCard, label: " " };
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
// test("should return false for redirectUrl card if label is undefined", () => {
|
||||
// const card = { ...baseRedirectUrlCard, label: undefined };
|
||||
// expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
// });
|
||||
});
|
||||
|
||||
describe("validation.validateElement", () => {
|
||||
@@ -1029,6 +1025,66 @@ describe("validation.isSurveyValid", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return false and toast error if a link survey has an empty custom survey closed message heading", () => {
|
||||
const surveyWithEmptyClosedMessageHeading = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: "",
|
||||
subheading: "Closed for now",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, "en", mockT)).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"environments.surveys.edit.survey_closed_message_heading_required"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return false and toast error if a link survey has a whitespace-only custom survey closed message heading", () => {
|
||||
const surveyWithWhitespaceClosedMessageHeading = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: " ",
|
||||
subheading: "",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, "en", mockT)).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"environments.surveys.edit.survey_closed_message_heading_required"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true if a link survey has a custom survey closed message heading and no subheading", () => {
|
||||
const surveyWithHeadingOnlyClosedMessage = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: "Survey closed",
|
||||
subheading: "",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, "en", mockT)).toBe(true);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return true if a link survey has a custom survey closed message heading and subheading", () => {
|
||||
const surveyWithClosedMessageContent = {
|
||||
...baseSurvey,
|
||||
type: "link",
|
||||
surveyClosedMessage: {
|
||||
heading: "Survey closed",
|
||||
subheading: "Thanks for your interest",
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(validation.isSurveyValid(surveyWithClosedMessageContent, "en", mockT)).toBe(true);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("App Survey Segment Validation", () => {
|
||||
test("should return false and toast error for app survey with invalid segment filters", () => {
|
||||
const surveyWithInvalidSegment = {
|
||||
|
||||
@@ -151,11 +151,7 @@ export const validationRules = {
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field];
|
||||
if (
|
||||
fieldValue &&
|
||||
typeof fieldValue[defaultLanguageCode] !== "undefined" &&
|
||||
fieldValue[defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
if (fieldValue?.[defaultLanguageCode] !== undefined && fieldValue[defaultLanguageCode].trim() !== "") {
|
||||
isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages);
|
||||
}
|
||||
}
|
||||
@@ -203,6 +199,16 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
|
||||
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
|
||||
};
|
||||
|
||||
const hasValidSurveyClosedMessageHeading = (survey: TSurvey): boolean => {
|
||||
if (survey.type !== "link" || !survey.surveyClosedMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const heading = survey.surveyClosedMessage.heading?.trim() ?? "";
|
||||
|
||||
return heading.length > 0;
|
||||
};
|
||||
|
||||
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
|
||||
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages);
|
||||
};
|
||||
@@ -286,5 +292,10 @@ export const isSurveyValid = (
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidSurveyClosedMessageHeading(survey)) {
|
||||
toast.error(t("environments.surveys.edit.survey_closed_message_heading_required"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Project, SurveyType } from "@prisma/client";
|
||||
import { type JSX, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -44,6 +45,7 @@ export const LinkSurveyWrapper = ({
|
||||
isBrandingEnabled,
|
||||
dir = "auto",
|
||||
}: LinkSurveyWrapperProps) => {
|
||||
const { t } = useTranslation();
|
||||
//for embedded survey strip away all surrounding css
|
||||
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
|
||||
|
||||
@@ -88,7 +90,7 @@ export const LinkSurveyWrapper = ({
|
||||
{isPreview && (
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
Survey Preview 👀
|
||||
{t("environments.surveys.edit.survey_preview")}
|
||||
<ResetProgressButton onClick={handleResetSurvey} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const SurveysList = ({
|
||||
<div>
|
||||
<div className="flex-col space-y-3" ref={parent}>
|
||||
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
|
||||
<div className="col-span-2 place-self-start">Name</div>
|
||||
<div className="col-span-2 place-self-start">{t("common.name")}</div>
|
||||
<div className="col-span-1">{t("common.status")}</div>
|
||||
<div className="col-span-1">{t("common.responses")}</div>
|
||||
<div className="col-span-1">{t("common.type")}</div>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { decodeSurveyListPageCursor, encodeSurveyListPageCursor, getSurveyListPage } from "./survey-page";
|
||||
|
||||
vi.mock("@/modules/survey/lib/utils", () => ({
|
||||
buildWhereClause: vi.fn(() => ({ AND: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "env_123";
|
||||
|
||||
function makeSurveyRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "survey_1",
|
||||
name: "Survey 1",
|
||||
environmentId,
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
_count: { responses: 3 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("survey-page cursor helpers", () => {
|
||||
test("encodes and decodes an updatedAt cursor", () => {
|
||||
const encoded = encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_1",
|
||||
});
|
||||
|
||||
expect(decodeSurveyListPageCursor(encoded, "updatedAt")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a cursor that does not match the requested sort order", () => {
|
||||
const encoded = encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "name",
|
||||
value: "Survey 1",
|
||||
id: "survey_1",
|
||||
});
|
||||
|
||||
expect(() => decodeSurveyListPageCursor(encoded, "updatedAt")).toThrow(InvalidInputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurveyListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("uses a stable updatedAt order with a next cursor", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
makeSurveyRow({ id: "survey_2", updatedAt: new Date("2025-01-03T00:00:00.000Z") }),
|
||||
makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
});
|
||||
|
||||
expect(buildWhereClause).toHaveBeenCalledWith(undefined);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId, AND: [] },
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 2,
|
||||
});
|
||||
expect(page.surveys).toHaveLength(1);
|
||||
expect(page.surveys[0].responseCount).toBe(3);
|
||||
expect(page.surveys[0]).not.toHaveProperty("_count");
|
||||
expect(page.nextCursor).not.toBeNull();
|
||||
expect(decodeSurveyListPageCursor(page.nextCursor as string, "updatedAt")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2025-01-03T00:00:00.000Z",
|
||||
id: "survey_2",
|
||||
});
|
||||
});
|
||||
|
||||
test("applies a name cursor for forward pagination", async () => {
|
||||
const cursor = decodeSurveyListPageCursor(
|
||||
encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "name",
|
||||
value: "Bravo",
|
||||
id: "survey_b",
|
||||
}),
|
||||
"name"
|
||||
);
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
makeSurveyRow({ id: "survey_c", name: "Charlie" }),
|
||||
] as never);
|
||||
|
||||
await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
cursor,
|
||||
sortBy: "name",
|
||||
});
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
OR: [{ name: { gt: "Bravo" } }, { name: "Bravo", id: { gt: "survey_b" } }],
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
take: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("paginates relevance by exhausting in-progress surveys before others", async () => {
|
||||
vi.mocked(prisma.survey.findMany)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_in_progress",
|
||||
status: "inProgress",
|
||||
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
|
||||
}),
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_other_1",
|
||||
status: "completed",
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
}),
|
||||
makeSurveyRow({
|
||||
id: "survey_other_2",
|
||||
status: "paused",
|
||||
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
cursor: null,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(1, {
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 3,
|
||||
});
|
||||
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(2, {
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
status: { not: "inProgress" },
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 2,
|
||||
});
|
||||
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress", "survey_other_1"]);
|
||||
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "relevance",
|
||||
bucket: "other",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_other_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an in-progress next cursor when the page fills before switching to other surveys", async () => {
|
||||
vi.mocked(prisma.survey.findMany)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_in_progress",
|
||||
status: "inProgress",
|
||||
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
|
||||
}),
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
makeSurveyRow({
|
||||
id: "survey_other_1",
|
||||
status: "completed",
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
|
||||
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress"]);
|
||||
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
|
||||
version: 1,
|
||||
sortBy: "relevance",
|
||||
bucket: "inProgress",
|
||||
updatedAt: "2025-01-03T00:00:00.000Z",
|
||||
id: "survey_in_progress",
|
||||
});
|
||||
});
|
||||
|
||||
test("continues relevance pagination from the other bucket cursor", async () => {
|
||||
const cursor = decodeSurveyListPageCursor(
|
||||
encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "relevance",
|
||||
bucket: "other",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
id: "survey_other_1",
|
||||
}),
|
||||
"relevance"
|
||||
);
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
makeSurveyRow({
|
||||
id: "survey_other_2",
|
||||
status: "completed",
|
||||
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
cursor,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledOnce();
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
AND: [],
|
||||
status: { not: "inProgress" },
|
||||
OR: [
|
||||
{ updatedAt: { lt: new Date("2025-01-02T00:00:00.000Z") } },
|
||||
{
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
id: { lt: "survey_other_1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 3,
|
||||
});
|
||||
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_other_2"]);
|
||||
expect(page.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
test("wraps Prisma errors as DatabaseError", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("db failed", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
})
|
||||
).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting paginated surveys");
|
||||
});
|
||||
|
||||
test("rethrows InvalidInputError unchanged", async () => {
|
||||
const invalidInputError = new InvalidInputError("bad cursor");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(invalidInputError);
|
||||
|
||||
await expect(
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
})
|
||||
).rejects.toThrow(invalidInputError);
|
||||
});
|
||||
|
||||
test("rethrows unexpected errors unchanged", async () => {
|
||||
const unexpectedError = new Error("boom");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(unexpectedError);
|
||||
|
||||
await expect(
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
})
|
||||
).rejects.toThrow(unexpectedError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import { buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import type { TSurvey } from "../types/surveys";
|
||||
import { type TSurveyRow, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
|
||||
|
||||
const SURVEY_LIST_CURSOR_VERSION = 1 as const;
|
||||
const IN_PROGRESS_BUCKET = "inProgress" as const;
|
||||
const OTHER_BUCKET = "other" as const;
|
||||
|
||||
const ZDateCursor = z.object({
|
||||
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
|
||||
sortBy: z.enum(["updatedAt", "createdAt"]),
|
||||
value: z.iso.datetime(),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZNameCursor = z.object({
|
||||
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
|
||||
sortBy: z.literal("name"),
|
||||
value: z.string(),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZRelevanceCursor = z.object({
|
||||
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
|
||||
sortBy: z.literal("relevance"),
|
||||
bucket: z.enum([IN_PROGRESS_BUCKET, OTHER_BUCKET]),
|
||||
updatedAt: z.iso.datetime(),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZSurveyListPageCursor = z.union([ZDateCursor, ZNameCursor, ZRelevanceCursor]);
|
||||
|
||||
export type TSurveyListSort = NonNullable<TSurveyFilterCriteria["sortBy"]>;
|
||||
export type TSurveyListPageCursor = z.infer<typeof ZSurveyListPageCursor>;
|
||||
type TStandardSurveyListSort = Exclude<TSurveyListSort, "relevance">;
|
||||
type TStandardSurveyListCursor = Extract<TSurveyListPageCursor, { sortBy: TStandardSurveyListSort }>;
|
||||
type TRelevanceSurveyListCursor = Extract<TSurveyListPageCursor, { sortBy: "relevance" }>;
|
||||
type TRelevanceBucket = typeof IN_PROGRESS_BUCKET | typeof OTHER_BUCKET;
|
||||
|
||||
export type TSurveyListPage = {
|
||||
surveys: TSurvey[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
type TGetSurveyListPageOptions = {
|
||||
limit: number;
|
||||
cursor: TSurveyListPageCursor | null;
|
||||
sortBy: TSurveyListSort;
|
||||
filterCriteria?: TSurveyFilterCriteria;
|
||||
};
|
||||
|
||||
type TCursorDirection = "asc" | "desc";
|
||||
|
||||
export function normalizeSurveyListSort(sortBy?: TSurveyFilterCriteria["sortBy"]): TSurveyListSort {
|
||||
return sortBy ?? "updatedAt";
|
||||
}
|
||||
|
||||
export function encodeSurveyListPageCursor(cursor: TSurveyListPageCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
export function decodeSurveyListPageCursor(
|
||||
encodedCursor: string,
|
||||
sortBy: TSurveyListSort
|
||||
): TSurveyListPageCursor {
|
||||
try {
|
||||
const decodedJson = Buffer.from(encodedCursor, "base64url").toString("utf8");
|
||||
const parsedCursor = ZSurveyListPageCursor.parse(JSON.parse(decodedJson));
|
||||
|
||||
if (parsedCursor.sortBy !== sortBy) {
|
||||
throw new InvalidInputError("The cursor does not match the requested sort order.");
|
||||
}
|
||||
|
||||
return parsedCursor;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new InvalidInputError("The cursor is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function getSurveyOrderBy(sortBy: TStandardSurveyListSort): Prisma.SurveyOrderByWithRelationInput[] {
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
return [{ name: "asc" }, { id: "asc" }];
|
||||
case "createdAt":
|
||||
return [{ createdAt: "desc" }, { id: "desc" }];
|
||||
case "updatedAt":
|
||||
default:
|
||||
return [{ updatedAt: "desc" }, { id: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
function buildDateCursorWhere(
|
||||
field: "createdAt" | "updatedAt",
|
||||
cursorValue: string,
|
||||
cursorId: string,
|
||||
direction: TCursorDirection
|
||||
): Prisma.SurveyWhereInput {
|
||||
const comparisonOperator = direction === "desc" ? "lt" : "gt";
|
||||
const cursorDate = new Date(cursorValue);
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ [field]: { [comparisonOperator]: cursorDate } },
|
||||
{
|
||||
[field]: cursorDate,
|
||||
id: { [comparisonOperator]: cursorId },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildNameCursorWhere(cursorValue: string, cursorId: string): Prisma.SurveyWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ name: { gt: cursorValue } },
|
||||
{
|
||||
name: cursorValue,
|
||||
id: { gt: cursorId },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandardCursorWhere(
|
||||
sortBy: TStandardSurveyListSort,
|
||||
cursor: TStandardSurveyListCursor
|
||||
): Prisma.SurveyWhereInput {
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
return buildNameCursorWhere(cursor.value, cursor.id);
|
||||
case "createdAt":
|
||||
return buildDateCursorWhere("createdAt", cursor.value, cursor.id, "desc");
|
||||
case "updatedAt":
|
||||
default:
|
||||
return buildDateCursorWhere("updatedAt", cursor.value, cursor.id, "desc");
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseWhere(
|
||||
environmentId: string,
|
||||
filterCriteria?: TSurveyFilterCriteria,
|
||||
extraWhere?: Prisma.SurveyWhereInput
|
||||
): Prisma.SurveyWhereInput {
|
||||
return {
|
||||
environmentId,
|
||||
...buildWhereClause(filterCriteria),
|
||||
...extraWhere,
|
||||
};
|
||||
}
|
||||
|
||||
function getStandardNextCursor(survey: TSurveyRow, sortBy: TStandardSurveyListSort): TSurveyListPageCursor {
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy,
|
||||
value: survey.name,
|
||||
id: survey.id,
|
||||
};
|
||||
case "createdAt":
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy,
|
||||
value: survey.createdAt.toISOString(),
|
||||
id: survey.id,
|
||||
};
|
||||
case "updatedAt":
|
||||
default:
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy,
|
||||
value: survey.updatedAt.toISOString(),
|
||||
id: survey.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getRelevanceNextCursor(survey: TSurveyRow, bucket: TRelevanceBucket): TSurveyListPageCursor {
|
||||
return {
|
||||
version: SURVEY_LIST_CURSOR_VERSION,
|
||||
sortBy: "relevance",
|
||||
bucket,
|
||||
updatedAt: survey.updatedAt.toISOString(),
|
||||
id: survey.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function findSurveyRows(
|
||||
environmentId: string,
|
||||
limit: number,
|
||||
sortBy: TStandardSurveyListSort,
|
||||
filterCriteria?: TSurveyFilterCriteria,
|
||||
cursor?: TStandardSurveyListCursor | null,
|
||||
extraWhere?: Prisma.SurveyWhereInput
|
||||
): Promise<TSurveyRow[]> {
|
||||
const cursorWhere = cursor ? buildStandardCursorWhere(sortBy, cursor) : undefined;
|
||||
|
||||
return prisma.survey.findMany({
|
||||
where: buildBaseWhere(environmentId, filterCriteria, {
|
||||
...extraWhere,
|
||||
...cursorWhere,
|
||||
}),
|
||||
select: surveySelect,
|
||||
orderBy: getSurveyOrderBy(sortBy),
|
||||
take: limit + 1,
|
||||
});
|
||||
}
|
||||
|
||||
function getLastSurveyRow(rows: TSurveyRow[]): TSurveyRow | null {
|
||||
return rows.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function getPageRows<T>(rows: T[], limit: number): { pageRows: T[]; hasMore: boolean } {
|
||||
const hasMore = rows.length > limit;
|
||||
return {
|
||||
pageRows: hasMore ? rows.slice(0, limit) : rows,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSurveyListPage(rows: TSurveyRow[], cursor: TSurveyListPageCursor | null): TSurveyListPage {
|
||||
return {
|
||||
surveys: mapSurveyRowsToSurveys(rows),
|
||||
nextCursor: cursor ? encodeSurveyListPageCursor(cursor) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStandardSurveyListPage(
|
||||
environmentId: string,
|
||||
options: TGetSurveyListPageOptions & { sortBy: TStandardSurveyListSort }
|
||||
): Promise<TSurveyListPage> {
|
||||
const surveyRows = await findSurveyRows(
|
||||
environmentId,
|
||||
options.limit,
|
||||
options.sortBy,
|
||||
options.filterCriteria,
|
||||
options.cursor as TStandardSurveyListCursor | null
|
||||
);
|
||||
|
||||
const { pageRows, hasMore } = getPageRows(surveyRows, options.limit);
|
||||
const lastRow = getLastSurveyRow(pageRows);
|
||||
|
||||
return buildSurveyListPage(
|
||||
pageRows,
|
||||
hasMore && lastRow ? getStandardNextCursor(lastRow, options.sortBy) : null
|
||||
);
|
||||
}
|
||||
|
||||
async function findRelevanceRows(
|
||||
environmentId: string,
|
||||
limit: number,
|
||||
filterCriteria: TSurveyFilterCriteria | undefined,
|
||||
bucket: TRelevanceBucket,
|
||||
cursor: TRelevanceSurveyListCursor | null
|
||||
): Promise<TSurveyRow[]> {
|
||||
const statusWhere: Prisma.SurveyWhereInput =
|
||||
bucket === IN_PROGRESS_BUCKET ? { status: "inProgress" } : { status: { not: "inProgress" } };
|
||||
const cursorWhere = cursor
|
||||
? buildDateCursorWhere("updatedAt", cursor.updatedAt, cursor.id, "desc")
|
||||
: undefined;
|
||||
|
||||
return prisma.survey.findMany({
|
||||
where: buildBaseWhere(environmentId, filterCriteria, {
|
||||
...statusWhere,
|
||||
...cursorWhere,
|
||||
}),
|
||||
select: surveySelect,
|
||||
orderBy: getSurveyOrderBy("updatedAt"),
|
||||
take: limit + 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function hasMoreRelevanceRowsInOtherBucket(
|
||||
environmentId: string,
|
||||
filterCriteria?: TSurveyFilterCriteria
|
||||
): Promise<boolean> {
|
||||
const otherRows = await findRelevanceRows(environmentId, 1, filterCriteria, OTHER_BUCKET, null);
|
||||
return otherRows.length > 0;
|
||||
}
|
||||
|
||||
function getRelevanceCursor(cursor: TSurveyListPageCursor | null): TRelevanceSurveyListCursor | null {
|
||||
if (cursor && cursor.sortBy !== "relevance") {
|
||||
throw new InvalidInputError("The cursor does not match the requested sort order.");
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function getRelevanceBucketCursor(
|
||||
cursor: TRelevanceSurveyListCursor | null,
|
||||
bucket: TRelevanceBucket
|
||||
): TRelevanceSurveyListCursor | null {
|
||||
return cursor?.bucket === bucket ? cursor : null;
|
||||
}
|
||||
|
||||
function shouldReadInProgressBucket(cursor: TRelevanceSurveyListCursor | null): boolean {
|
||||
return !cursor || cursor.bucket === IN_PROGRESS_BUCKET;
|
||||
}
|
||||
|
||||
function buildRelevancePage(rows: TSurveyRow[], bucket: TRelevanceBucket | null): TSurveyListPage {
|
||||
const lastRow = getLastSurveyRow(rows);
|
||||
|
||||
return buildSurveyListPage(rows, bucket && lastRow ? getRelevanceNextCursor(lastRow, bucket) : null);
|
||||
}
|
||||
|
||||
async function getInProgressRelevanceStep(
|
||||
environmentId: string,
|
||||
limit: number,
|
||||
filterCriteria: TSurveyFilterCriteria | undefined,
|
||||
cursor: TRelevanceSurveyListCursor | null
|
||||
): Promise<{ pageRows: TSurveyRow[]; remaining: number; response: TSurveyListPage | null }> {
|
||||
const inProgressRows = await findRelevanceRows(
|
||||
environmentId,
|
||||
limit,
|
||||
filterCriteria,
|
||||
IN_PROGRESS_BUCKET,
|
||||
getRelevanceBucketCursor(cursor, IN_PROGRESS_BUCKET)
|
||||
);
|
||||
const { pageRows, hasMore } = getPageRows(inProgressRows, limit);
|
||||
|
||||
return {
|
||||
pageRows,
|
||||
remaining: limit - pageRows.length,
|
||||
response: hasMore ? buildRelevancePage(pageRows, IN_PROGRESS_BUCKET) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildInProgressOnlyRelevancePage(
|
||||
environmentId: string,
|
||||
rows: TSurveyRow[],
|
||||
filterCriteria: TSurveyFilterCriteria | undefined,
|
||||
cursor: TRelevanceSurveyListCursor | null
|
||||
): Promise<TSurveyListPage> {
|
||||
const hasOtherRows =
|
||||
rows.length > 0 &&
|
||||
shouldReadInProgressBucket(cursor) &&
|
||||
(await hasMoreRelevanceRowsInOtherBucket(environmentId, filterCriteria));
|
||||
|
||||
return buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
|
||||
}
|
||||
|
||||
async function getRelevanceSurveyListPage(
|
||||
environmentId: string,
|
||||
options: TGetSurveyListPageOptions & { sortBy: "relevance" }
|
||||
): Promise<TSurveyListPage> {
|
||||
const relevanceCursor = getRelevanceCursor(options.cursor);
|
||||
const pageRows: TSurveyRow[] = [];
|
||||
let remaining = options.limit;
|
||||
|
||||
if (shouldReadInProgressBucket(relevanceCursor)) {
|
||||
const inProgressStep = await getInProgressRelevanceStep(
|
||||
environmentId,
|
||||
remaining,
|
||||
options.filterCriteria,
|
||||
relevanceCursor
|
||||
);
|
||||
pageRows.push(...inProgressStep.pageRows);
|
||||
|
||||
if (inProgressStep.response) {
|
||||
return inProgressStep.response;
|
||||
}
|
||||
|
||||
remaining = inProgressStep.remaining;
|
||||
}
|
||||
|
||||
if (remaining <= 0) {
|
||||
return await buildInProgressOnlyRelevancePage(
|
||||
environmentId,
|
||||
pageRows,
|
||||
options.filterCriteria,
|
||||
relevanceCursor
|
||||
);
|
||||
}
|
||||
|
||||
const otherRows = await findRelevanceRows(
|
||||
environmentId,
|
||||
remaining,
|
||||
options.filterCriteria,
|
||||
OTHER_BUCKET,
|
||||
getRelevanceBucketCursor(relevanceCursor, OTHER_BUCKET)
|
||||
);
|
||||
const { pageRows: otherPageRows, hasMore: hasMoreOther } = getPageRows(otherRows, remaining);
|
||||
pageRows.push(...otherPageRows);
|
||||
|
||||
return buildRelevancePage(pageRows, hasMoreOther ? OTHER_BUCKET : null);
|
||||
}
|
||||
|
||||
export async function getSurveyListPage(
|
||||
environmentId: string,
|
||||
options: TGetSurveyListPageOptions
|
||||
): Promise<TSurveyListPage> {
|
||||
try {
|
||||
if (options.sortBy === "relevance") {
|
||||
return await getRelevanceSurveyListPage(environmentId, {
|
||||
...options,
|
||||
sortBy: "relevance",
|
||||
});
|
||||
}
|
||||
|
||||
return await getStandardSurveyListPage(environmentId, {
|
||||
...options,
|
||||
sortBy: options.sortBy,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting paginated surveys");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
|
||||
export const surveySelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
creator: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
status: true,
|
||||
singleUse: true,
|
||||
environmentId: true,
|
||||
_count: {
|
||||
select: { responses: true },
|
||||
},
|
||||
} satisfies Prisma.SurveySelect;
|
||||
|
||||
export type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>;
|
||||
|
||||
export function mapSurveyRowToSurvey(row: TSurveyRow): TSurvey {
|
||||
const { _count, ...survey } = row;
|
||||
|
||||
return {
|
||||
...survey,
|
||||
responseCount: _count.responses,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSurveyRowsToSurveys(rows: TSurveyRow[]): TSurvey[] {
|
||||
return rows.map(mapSurveyRowToSurvey);
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
getSurveyCount,
|
||||
getSurveys,
|
||||
getSurveysSortedByRelevance,
|
||||
surveySelect,
|
||||
} from "./survey";
|
||||
import { surveySelect } from "./survey-record";
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
@@ -197,7 +197,19 @@ describe("getSurvey", () => {
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
expect(survey).toEqual({ ...prismaSurvey, responseCount: 5 });
|
||||
expect(survey).toEqual({
|
||||
id: prismaSurvey.id,
|
||||
createdAt: prismaSurvey.createdAt,
|
||||
updatedAt: prismaSurvey.updatedAt,
|
||||
name: prismaSurvey.name,
|
||||
type: prismaSurvey.type,
|
||||
creator: prismaSurvey.creator,
|
||||
status: prismaSurvey.status,
|
||||
singleUse: prismaSurvey.singleUse,
|
||||
environmentId: prismaSurvey.environmentId,
|
||||
responseCount: 5,
|
||||
});
|
||||
expect(survey).not.toHaveProperty("_count");
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: surveyId },
|
||||
select: surveySelect,
|
||||
@@ -234,7 +246,15 @@ describe("getSurveys", () => {
|
||||
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2", _count: { responses: 2 } },
|
||||
];
|
||||
const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({
|
||||
...s,
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
creator: s.creator,
|
||||
status: s.status,
|
||||
singleUse: s.singleUse,
|
||||
environmentId: s.environmentId,
|
||||
responseCount: s._count.responses,
|
||||
}));
|
||||
|
||||
@@ -243,6 +263,7 @@ describe("getSurveys", () => {
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
expect(surveys).toEqual(expectedSurveys);
|
||||
expect(surveys[0]).not.toHaveProperty("_count");
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId, ...buildWhereClause() },
|
||||
select: surveySelect,
|
||||
@@ -317,8 +338,30 @@ describe("getSurveysSortedByRelevance", () => {
|
||||
_count: { responses: 5 },
|
||||
};
|
||||
|
||||
const expectedInProgressSurvey: TSurvey = { ...mockInProgressPrisma, responseCount: 3 };
|
||||
const expectedOtherSurvey: TSurvey = { ...mockOtherPrisma, responseCount: 5 };
|
||||
const expectedInProgressSurvey: TSurvey = {
|
||||
id: mockInProgressPrisma.id,
|
||||
createdAt: mockInProgressPrisma.createdAt,
|
||||
updatedAt: mockInProgressPrisma.updatedAt,
|
||||
name: mockInProgressPrisma.name,
|
||||
type: mockInProgressPrisma.type,
|
||||
creator: mockInProgressPrisma.creator,
|
||||
status: mockInProgressPrisma.status,
|
||||
singleUse: mockInProgressPrisma.singleUse,
|
||||
environmentId: mockInProgressPrisma.environmentId,
|
||||
responseCount: 3,
|
||||
};
|
||||
const expectedOtherSurvey: TSurvey = {
|
||||
id: mockOtherPrisma.id,
|
||||
createdAt: mockOtherPrisma.createdAt,
|
||||
updatedAt: mockOtherPrisma.updatedAt,
|
||||
name: mockOtherPrisma.name,
|
||||
type: mockOtherPrisma.type,
|
||||
creator: mockOtherPrisma.creator,
|
||||
status: mockOtherPrisma.status,
|
||||
singleUse: mockOtherPrisma.singleUse,
|
||||
environmentId: mockOtherPrisma.environmentId,
|
||||
responseCount: 5,
|
||||
};
|
||||
|
||||
test("should fetch inProgress surveys first, then others if limit not met", async () => {
|
||||
vi.mocked(prisma.survey.count).mockResolvedValue(1); // 1 inProgress survey
|
||||
@@ -329,6 +372,7 @@ describe("getSurveysSortedByRelevance", () => {
|
||||
const surveys = await getSurveysSortedByRelevance(environmentId, 2, 0);
|
||||
|
||||
expect(surveys).toEqual([expectedInProgressSurvey, expectedOtherSurvey]);
|
||||
expect(surveys[0]).not.toHaveProperty("_count");
|
||||
expect(prisma.survey.count).toHaveBeenCalledWith({
|
||||
where: { environmentId, status: "inProgress", ...buildWhereClause() },
|
||||
});
|
||||
|
||||
@@ -17,25 +17,7 @@ import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils
|
||||
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
|
||||
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
|
||||
import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
|
||||
export const surveySelect: Prisma.SurveySelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
creator: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
status: true,
|
||||
singleUse: true,
|
||||
environmentId: true,
|
||||
_count: {
|
||||
select: { responses: true },
|
||||
},
|
||||
};
|
||||
import { mapSurveyRowToSurvey, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (
|
||||
@@ -62,12 +44,7 @@ export const getSurveys = reactCache(
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return surveysPrisma.map((survey) => {
|
||||
return {
|
||||
...survey,
|
||||
responseCount: survey._count.responses,
|
||||
};
|
||||
});
|
||||
return mapSurveyRowsToSurveys(surveysPrisma);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting surveys");
|
||||
@@ -112,12 +89,7 @@ export const getSurveysSortedByRelevance = reactCache(
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
surveys = inProgressSurveys.map((survey) => {
|
||||
return {
|
||||
...survey,
|
||||
responseCount: survey._count.responses,
|
||||
};
|
||||
});
|
||||
surveys = mapSurveyRowsToSurveys(inProgressSurveys);
|
||||
|
||||
// Determine if additional surveys are needed
|
||||
if (offset !== undefined && limit && inProgressSurveys.length < limit) {
|
||||
@@ -135,15 +107,7 @@ export const getSurveysSortedByRelevance = reactCache(
|
||||
skip: newOffset,
|
||||
});
|
||||
|
||||
surveys = [
|
||||
...surveys,
|
||||
...additionalSurveys.map((survey) => {
|
||||
return {
|
||||
...survey,
|
||||
responseCount: survey._count.responses,
|
||||
};
|
||||
}),
|
||||
];
|
||||
surveys = [...surveys, ...mapSurveyRowsToSurveys(additionalSurveys)];
|
||||
}
|
||||
|
||||
return surveys;
|
||||
@@ -178,7 +142,7 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...surveyPrisma, responseCount: surveyPrisma?._count.responses };
|
||||
return mapSurveyRowToSurvey(surveyPrisma);
|
||||
});
|
||||
|
||||
export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
|
||||
@@ -605,22 +569,26 @@ export const copySurveyToOtherEnvironment = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => {
|
||||
validateInputs([environmentId, z.cuid2()]);
|
||||
try {
|
||||
const surveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
},
|
||||
});
|
||||
/** Count surveys in an environment, optionally with the same filter as getSurveys (so total matches list). */
|
||||
export const getSurveyCount = reactCache(
|
||||
async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> => {
|
||||
validateInputs([environmentId, z.cuid2()]);
|
||||
try {
|
||||
const surveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
environmentId,
|
||||
...buildWhereClause(filterCriteria),
|
||||
},
|
||||
});
|
||||
|
||||
return surveyCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey count");
|
||||
throw new DatabaseError(error.message);
|
||||
return surveyCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey count");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
@@ -163,6 +163,9 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
}
|
||||
} else {
|
||||
setIsMultiLanguageActivated(true);
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -50,7 +50,11 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
|
||||
formbricks.track("{watch("key")}")
|
||||
</span>{" "}
|
||||
{t("environments.actions.in_your_code_read_more_in_our")}{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline">
|
||||
{t("common.docs")}
|
||||
</a>
|
||||
{"."}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpFromLineIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
@@ -33,6 +34,7 @@ export const Uploader = ({
|
||||
disabled = false,
|
||||
isStorageConfigured = true,
|
||||
}: UploaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label // NOSONAR - This is a label for a file input, we need the onClick to trigger storage not configured toast
|
||||
htmlFor={`${id}-${name}`}
|
||||
@@ -82,7 +84,7 @@ export const Uploader = ({
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
|
||||
<p className={cn("mt-2 text-center text-sm text-slate-500", uploadMore && "text-xs")}>
|
||||
<span className="font-semibold">Click or drag to upload files.</span>
|
||||
<span className="font-semibold">{t("common.upload_input_description")}</span>
|
||||
</p>
|
||||
<input
|
||||
data-testid="upload-file-input"
|
||||
|
||||
@@ -226,7 +226,7 @@ export const PreviewSurvey = ({
|
||||
{previewMode === "mobile" && (
|
||||
<>
|
||||
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
||||
Preview
|
||||
{t("common.preview")}
|
||||
</p>
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<ResetProgressButton onClick={resetProgress} />
|
||||
@@ -310,7 +310,7 @@ export const PreviewSurvey = ({
|
||||
setIsFullScreenPreview(true);
|
||||
}
|
||||
}}
|
||||
aria-label={isFullScreenPreview ? "Shrink Preview" : "Expand Preview"}></button>
|
||||
aria-label={isFullScreenPreview ? t("environments.surveys.edit.shrink_preview") : t("environments.surveys.edit.expand_preview")}></button>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface ShuffleOptionsTypes {
|
||||
none?: ShuffleOptionType;
|
||||
all?: ShuffleOptionType;
|
||||
exceptLast?: ShuffleOptionType;
|
||||
reverseOrderOccasionally?: ShuffleOptionType;
|
||||
reverseOrderExceptLast?: ShuffleOptionType;
|
||||
}
|
||||
|
||||
interface ShuffleOptionSelectProps {
|
||||
|
||||
@@ -157,7 +157,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>{isAppSurvey ? "Your web app" : "Preview"}</p>
|
||||
<p>{isAppSurvey ? t("environments.surveys.edit.your_web_app") : t("common.preview")}</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
<ResetProgressButton onClick={resetQuestionProgress} />
|
||||
|
||||
@@ -269,7 +269,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByText("Start from scratch").click();
|
||||
await page.getByRole("button", { name: "Create survey", exact: true }).click();
|
||||
await page.locator("#multi-lang-toggle").click();
|
||||
await page.getByText("Multiple languages").click();
|
||||
await page.getByRole("combobox").click();
|
||||
await page.getByLabel("English (en)").click();
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
@@ -1386,17 +1386,22 @@ paths:
|
||||
put:
|
||||
operationId: uploadBulkContacts
|
||||
summary: Upload Bulk Contacts
|
||||
description: Uploads contacts in bulk. Each contact in the payload must have an
|
||||
'email' attribute present in their attributes array. The email attribute
|
||||
is mandatory and must be a valid email format. Without a valid email,
|
||||
the contact will be skipped during processing.
|
||||
description: >-
|
||||
Uploads contacts in bulk. This endpoint expects the bulk request
|
||||
shape: `contacts` must be an array, and each contact item must contain
|
||||
an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST
|
||||
/management/contacts`, this endpoint does not accept a top-level `attributes`
|
||||
object. Each contact must include an `email` attribute in its `attributes`
|
||||
array, and that email must be valid.
|
||||
tags:
|
||||
- Management API - Contacts
|
||||
requestBody:
|
||||
required: true
|
||||
description: The contacts to upload. Each contact must include an 'email'
|
||||
attribute in their attributes array. The email is used as the unique
|
||||
identifier for the contact.
|
||||
description: >-
|
||||
The contacts to upload. Use the full nested bulk body shown
|
||||
in the example or cURL snippet: `{ environmentId, contacts: [{ attributes:
|
||||
[{ attributeKey: { key, name }, value }] }] }`. Each contact must include
|
||||
an `email` attribute inside its `attributes` array.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1520,16 +1525,19 @@ paths:
|
||||
post:
|
||||
operationId: createContact
|
||||
summary: Create a contact
|
||||
description: Creates a contact in the database. Each contact must have a valid
|
||||
email address in the attributes. All attribute keys must already exist
|
||||
in the environment. The email is used as the unique identifier along
|
||||
description: Creates a single contact in the database. This endpoint expects
|
||||
a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`,
|
||||
which expects `contacts[].attributes[]` instead. Each contact must have
|
||||
a valid email address in the attributes. All attribute keys must already
|
||||
exist in the environment. The email is used as the unique identifier along
|
||||
with the environment.
|
||||
tags:
|
||||
- Management API - Contacts
|
||||
requestBody:
|
||||
required: true
|
||||
description: The contact to create. Must include an email attribute and all
|
||||
attribute keys must already exist in the environment.
|
||||
description: The single contact to create. Must include a top-level `attributes`
|
||||
object with an email attribute, and all attribute keys must already exist
|
||||
in the environment.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
|
||||
# Implementation: apps/web/app/api/v3/surveys/route.ts
|
||||
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
|
||||
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Formbricks API v3
|
||||
description: |
|
||||
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
|
||||
|
||||
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
|
||||
|
||||
**workspaceId (today)**
|
||||
Query param `workspaceId` is the **environment id** (survey container in the DB). The API uses the name *workspace* because the product is moving toward **Workspace** as the default container; until that exists, resolution is implemented in `workspace-context.ts` (single place to change when Environment is deprecated).
|
||||
|
||||
**Auth**
|
||||
Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation.
|
||||
|
||||
**Pagination**
|
||||
Cursor-based pagination with **limit** + opaque **cursor** token. Responses return `meta.nextCursor`; pass that value back as `cursor` to fetch the next page. Responses also include `meta.totalCount`, the total number of surveys matching the current filters across all pages. There is no `offset` in this contract.
|
||||
|
||||
**Filtering**
|
||||
Filters use explicit operator-style query parameters under the **`filter[...]` family**. This endpoint supports `filter[name][contains]`, `filter[status][in]`, and `filter[type][in]`. Multi-value filters use repeated keys or comma-separated values (e.g. `filter[status][in]=draft&filter[status][in]=inProgress` or `filter[status][in]=draft,inProgress`). Sorting remains a flat `sortBy` query parameter.
|
||||
|
||||
**Security**
|
||||
Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`.
|
||||
|
||||
**OpenAPI**
|
||||
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
|
||||
|
||||
**Next steps (out of scope for this spec)**
|
||||
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
|
||||
version: 0.1.0
|
||||
x-implementation-notes:
|
||||
route: apps/web/app/api/v3/surveys/route.ts
|
||||
query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
|
||||
auth: apps/web/app/api/v3/lib/auth.ts
|
||||
workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
|
||||
openapi-generated: false
|
||||
pagination-model: cursor
|
||||
cursor-pagination: supported
|
||||
paths:
|
||||
/api/v3/surveys:
|
||||
get:
|
||||
operationId: getSurveysV3
|
||||
summary: List surveys
|
||||
description: Returns surveys for the workspace. Session cookie or x-api-key.
|
||||
tags:
|
||||
- V3 Surveys
|
||||
parameters:
|
||||
- in: query
|
||||
name: workspaceId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: |
|
||||
Workspace identifier. **Today:** pass the **environment id** (the environment that contains the surveys). When Workspace replaces Environment in the data model, clients may pass workspace ids instead; resolution is centralized in workspace-context.
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
description: Page size (max 100)
|
||||
- in: query
|
||||
name: cursor
|
||||
schema:
|
||||
type: string
|
||||
description: |
|
||||
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request.
|
||||
- in: query
|
||||
name: filter[name][contains]
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: Case-insensitive substring match on survey name (same as in-app list filters).
|
||||
- in: query
|
||||
name: filter[status][in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
style: form
|
||||
explode: true
|
||||
description: |
|
||||
Survey status filter. Repeat the parameter (`filter[status][in]=draft&filter[status][in]=inProgress`) or use comma-separated values (`filter[status][in]=draft,inProgress`). Invalid values → **400**.
|
||||
- in: query
|
||||
name: filter[type][in]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [link, app]
|
||||
style: form
|
||||
explode: true
|
||||
description: Survey type filter (`link` / `app`). Same repeat-or-comma rules as `filter[status][in]`.
|
||||
- in: query
|
||||
name: sortBy
|
||||
schema:
|
||||
type: string
|
||||
enum: [createdAt, updatedAt, name, relevance]
|
||||
description: Sort order. Defaults to `updatedAt`. The `cursor` token is bound to the selected sort order.
|
||||
responses:
|
||||
"200":
|
||||
description: Surveys retrieved successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
description: Request correlation ID
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: "private, no-store"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data, meta]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyListItem"
|
||||
meta:
|
||||
type: object
|
||||
required: [limit, nextCursor, totalCount]
|
||||
properties:
|
||||
limit: { type: integer }
|
||||
nextCursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Opaque cursor for the next page. `null` when there are no more results.
|
||||
totalCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Total number of surveys matching the current filters across all pages.
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"401":
|
||||
description: Not authenticated (no valid session or API key)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"403":
|
||||
description: Forbidden — no access, or workspace/environment does not exist (404 not used; avoids existence leak)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: { type: integer }
|
||||
description: Seconds until the current rate-limit window resets
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
sessionAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: next-auth.session-token
|
||||
description: |
|
||||
NextAuth session JWT cookie. **Development:** often `next-auth.session-token`.
|
||||
**Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in.
|
||||
apiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: x-api-key
|
||||
description: |
|
||||
Management API key; must include **workspaceId** as an allowed environment with read, write, or manage permission.
|
||||
schemas:
|
||||
SurveyListItem:
|
||||
type: object
|
||||
description: |
|
||||
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
|
||||
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
environmentId: { type: string }
|
||||
type: { type: string, enum: [link, app, website, web] }
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
responseCount: { type: integer }
|
||||
creator: { type: object, nullable: true, properties: { name: { type: string } } }
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
|
||||
required: [title, status, detail, requestId]
|
||||
properties:
|
||||
type: { type: string, format: uri }
|
||||
title: { type: string }
|
||||
status: { type: integer }
|
||||
detail: { type: string }
|
||||
instance: { type: string }
|
||||
code:
|
||||
type: string
|
||||
enum: [bad_request, not_authenticated, forbidden, internal_server_error, too_many_requests]
|
||||
requestId: { type: string }
|
||||
details: { type: object }
|
||||
invalid_params:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
reason: { type: string }
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: "Multi-language Surveys"
|
||||
description: "Survey respondents in multiple-languages."
|
||||
icon: "language"
|
||||
---
|
||||
|
||||
If you'd like to survey users in multiple languages while keeping all results in the same survey, you can make use of [Multi-language Surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys#multi-language-surveys)
|
||||
@@ -77,6 +77,7 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Pin-protected surveys | ✅ | ✅ |
|
||||
| Webhooks | ✅ | ✅ |
|
||||
| Email follow-ups | ✅ | ✅ |
|
||||
| Multi-language surveys | ✅ | ✅ |
|
||||
| Multi-language UI | ✅ | ✅ |
|
||||
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
|
||||
| Domain Split Configuration | ✅ | ✅ |
|
||||
@@ -85,7 +86,6 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Whitelabel email follow-ups | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Contact management & segments | ❌ | ✅ |
|
||||
| Multi-language surveys | ❌ | ✅ |
|
||||
| Quota Management | ❌ | ✅ |
|
||||
| Audit Logs | ❌ | ✅ |
|
||||
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
|
||||
|
||||
@@ -30,13 +30,14 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
|
||||
|
||||

|
||||
|
||||
- Add your webhook listener endpoint & test it to make sure it can receive the test endpoint otherwise you will not be able to save it.
|
||||
- Add your webhook listener endpoint and test it to make sure the endpoint is reachable and accepts `POST`
|
||||
requests.
|
||||
|
||||

|
||||
|
||||
- Now add the triggers you want to listen to and the surveys!
|
||||
|
||||
- That’s it! Your webhooks will not start receiving data as soon as it arrives!
|
||||
- That’s it! Your webhooks will now start receiving data as soon as it arrives!
|
||||
|
||||

|
||||
|
||||
@@ -44,6 +45,31 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
|
||||
|
||||
Use our documented methods on the **Creation**, **List**, and **Deletion** endpoints of the Webhook API mentioned in our [API v2 playground](https://formbricks.com/docs/api-v2-reference/management-api-%3E-webhooks/get-webhooks).
|
||||
|
||||
## Testing Webhooks Locally
|
||||
|
||||
If you want to test a webhook consumer running on your machine before deploying it, you can expose your local
|
||||
endpoint with [ngrok](https://ngrok.com/docs/universal-gateway/http).
|
||||
|
||||
<Steps>
|
||||
<Step title="Start your local webhook listener">
|
||||
Run your local endpoint on a port like `3000`.
|
||||
</Step>
|
||||
<Step title="Expose it with ngrok">
|
||||
Create a public HTTPS URL for your local service, for example with `ngrok http http://localhost:3000`.
|
||||
</Step>
|
||||
|
||||
<Step title="Use the public URL in Formbricks">
|
||||
Paste the ngrok URL into your webhook endpoint, click **Test Endpoint**, and then save the webhook once the
|
||||
endpoint is reachable.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
To avoid sending unwanted test responses to production workflows, copy the survey to your [Test
|
||||
Environment](/xm-and-surveys/core-features/test-environment) and use that survey copy in your development
|
||||
workflow while validating the webhook setup.
|
||||
</Note>
|
||||
|
||||
If you encounter any issues or need help setting up webhooks, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). 😃
|
||||
|
||||
---
|
||||
@@ -220,49 +246,47 @@ We provide the following webhook payloads, `responseCreated`, `responseUpdated`,
|
||||
Example of Response Created webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:29.507Z",
|
||||
"variables": {}
|
||||
"welcome_card_cta": "clicked"
|
||||
},
|
||||
"event": "responseCreated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"welcome_card_cta": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:29.507Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseCreated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Updated
|
||||
@@ -270,51 +294,49 @@ Example of Response Created webhook payload:
|
||||
Example of Response Updated webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "Just browsing"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 3855.799999952316
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:33.696Z",
|
||||
"variables": {}
|
||||
"visit_reason": "Just browsing",
|
||||
"welcome_card_cta": "clicked"
|
||||
},
|
||||
"event": "responseUpdated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
"displayId": "displayId",
|
||||
"endingId": null,
|
||||
"finished": false,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"visit_reason": 3855.799999952316,
|
||||
"welcome_card_cta": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:33.696Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseUpdated",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Finished
|
||||
@@ -322,50 +344,48 @@ Example of Response Updated webhook payload:
|
||||
Example of Response Finished webhook payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
{
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"contact": null,
|
||||
"contactAttributes": null,
|
||||
"createdAt": "2025-07-24T07:47:29.507Z",
|
||||
"data": {
|
||||
"q1": "clicked",
|
||||
"q2": "accepted"
|
||||
},
|
||||
"displayId": "displayId",
|
||||
"endingId": "endingId",
|
||||
"finished": true,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"_total": 4947.899999035763,
|
||||
"q1": 2154.700000047684,
|
||||
"q2": 2793.199999988079
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:56.116Z",
|
||||
"variables": {}
|
||||
"newsletter_consent": "accepted",
|
||||
"welcome_card_cta": "clicked"
|
||||
},
|
||||
"event": "responseFinished",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
]
|
||||
"displayId": "displayId",
|
||||
"endingId": "endingId",
|
||||
"finished": true,
|
||||
"id": "responseId",
|
||||
"language": "en",
|
||||
"meta": {
|
||||
"country": "DE",
|
||||
"url": "https://app.formbricks.com/s/surveyId",
|
||||
"userAgent": {
|
||||
"browser": "Chrome",
|
||||
"device": "desktop",
|
||||
"os": "macOS"
|
||||
}
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
"tags": [],
|
||||
"ttc": {
|
||||
"_total": 4947.899999035763,
|
||||
"newsletter_consent": 2793.199999988079,
|
||||
"welcome_card_cta": 2154.700000047684
|
||||
},
|
||||
"updatedAt": "2025-07-24T07:47:56.116Z",
|
||||
"variables": {}
|
||||
},
|
||||
"event": "responseFinished",
|
||||
"webhookId": "webhookId"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -4,10 +4,6 @@ description: "Create surveys that support multiple languages using translations.
|
||||
icon: "language"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Multi-language surveys are part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)
|
||||
</Note>
|
||||
|
||||
How to deliver a specific language depends on the survey type (app or link survey):
|
||||
|
||||
- App & Website survey: Set a `language` attribute for the user. [Read this guide for App Surveys](#app-surveys-configuration)
|
||||
|
||||
@@ -73,12 +73,6 @@ Use the `setLanguage` function to set the user's preferred language for surveys.
|
||||
formbricks.setLanguage("de"); // ISO identifier or Alias set when creating language
|
||||
```
|
||||
|
||||
<Note>
|
||||
If a user has a language assigned, a survey has multi-language activated and it is missing a translation in
|
||||
the language of the user, the survey will not be displayed. Learn more about [Multi-language Surveys](/docs/xm-and-surveys/surveys/general-features/multi-language-surveys).
|
||||
</Note>
|
||||
|
||||
|
||||
### Logging Out Users
|
||||
|
||||
When a user logs out of your webpage, also log them out of Formbricks to prevent activity from being linked to the wrong user. Use the logout function:
|
||||
|
||||
+10
-3
@@ -82,15 +82,22 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"axios": ">=1.12.2",
|
||||
"@hono/node-server": "1.19.10",
|
||||
"axios": "1.13.5",
|
||||
"flatted": "3.4.2",
|
||||
"hono": "4.12.4",
|
||||
"@microsoft/api-extractor>minimatch": "10.2.4",
|
||||
"node-forge": ">=1.3.2",
|
||||
"rollup": "4.59.0",
|
||||
"socket.io-parser": "4.2.6",
|
||||
"tar": ">=7.5.11",
|
||||
"typeorm": ">=0.3.26",
|
||||
"fast-xml-parser": "5.4.2",
|
||||
"undici": "7.24.0",
|
||||
"fast-xml-parser": "5.5.7",
|
||||
"diff": ">=8.0.3"
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | fast-xml-parser (CVE-2026-25896/26278) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316) - awaiting Prisma update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "1.0.9",
|
||||
"react-email": "5.2.9"
|
||||
"@react-email/components": "1.0.10",
|
||||
"react-email": "5.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@react-email/preview-server": "5.2.9",
|
||||
"@react-email/preview-server": "5.2.10",
|
||||
"autoprefixer": "10.4.27",
|
||||
"clsx": "2.1.1",
|
||||
"postcss": "8.5.8",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
|
||||
import { getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { closeSurvey } from "@/lib/survey/widget";
|
||||
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
import { sendUpdatesToBackend } from "@/lib/user/update";
|
||||
import {
|
||||
@@ -316,6 +316,9 @@ export const setup = async (
|
||||
addEventListeners();
|
||||
addCleanupEventListeners();
|
||||
|
||||
// Preload surveys script so it's ready when a survey triggers
|
||||
preloadSurveysScript(configInput.appUrl);
|
||||
|
||||
setIsSetup(true);
|
||||
logger.debug("Set up complete");
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ vi.mock("@/lib/survey/no-code-action", () => ({
|
||||
checkPageUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// 9) Mock survey widget
|
||||
vi.mock("@/lib/survey/widget", () => ({
|
||||
closeSurvey: vi.fn(),
|
||||
preloadSurveysScript: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("setup.ts", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => Config>;
|
||||
let getInstanceLoggerMock: MockInstance<() => Logger>;
|
||||
|
||||
@@ -67,6 +67,8 @@ describe("widget-file", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
// @ts-expect-error -- cleaning up mock
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
|
||||
@@ -464,6 +466,214 @@ describe("widget-file", () => {
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
|
||||
const scriptLoadMockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
// Helper to get the script element passed to document.head.appendChild
|
||||
const getAppendedScript = (): Record<string, unknown> => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- accessing mock for test assertions
|
||||
const appendChildMock = vi.mocked(document.head.appendChild);
|
||||
for (const call of appendChildMock.mock.calls) {
|
||||
const el = call[0] as unknown as Record<string, unknown>;
|
||||
if (typeof el.src === "string" && el.src.includes("surveys.umd.cjs")) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
throw new Error("No script element for surveys.umd.cjs was appended to document.head");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock return values that may have been overridden by previous tests
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
// Test onerror first so surveysLoadPromise is reset to null for subsequent tests
|
||||
test("rejects when script fails to load (onerror) and allows retry", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
expect(scriptEl.src).toBe("https://fake.app/js/surveys.umd.cjs");
|
||||
expect(scriptEl.async).toBe(true);
|
||||
|
||||
// Simulate network error
|
||||
(scriptEl.onerror as (error: unknown) => void)("Network error");
|
||||
|
||||
// renderWidget catches the error internally — it resolves, not rejects
|
||||
await renderPromise;
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load Formbricks Surveys library:", "Network error");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("rejects when script loads but surveys global never becomes available (timeout)", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.useFakeTimers();
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
// Script loaded but window.formbricksSurveys is never set
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Advance past the 10s timeout (polls every 200ms)
|
||||
await vi.advanceTimersByTimeAsync(10001);
|
||||
|
||||
await renderPromise;
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to load Formbricks Surveys library:",
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("resolves after polling when surveys global becomes available and applies stored nonce", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// Set nonce before surveys load to test nonce application
|
||||
window.__formbricksNonce = "test-nonce-123";
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
// Simulate script loaded
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Set the global after script "loads" — simulates browser finishing execution
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
// Advance one polling interval for waitForSurveysGlobal to find it
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await renderPromise;
|
||||
|
||||
// Run remaining timers for survey.delay setTimeout
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
delete window.__formbricksNonce;
|
||||
});
|
||||
|
||||
test("deduplicates concurrent calls (returns cached promise)", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// After the previous successful test, surveysLoadPromise holds a resolved promise.
|
||||
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
|
||||
// should reuse the cached promise rather than creating a new script element.
|
||||
// @ts-expect-error -- cleaning up mock to force dedup path
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// No new script element should have been appended (dedup via early return or cached promise)
|
||||
const scriptAppendCalls = appendChildSpy.mock.calls.filter((call: unknown[]) => {
|
||||
const el = call[0] as Record<string, unknown> | undefined;
|
||||
return typeof el?.src === "string" && el.src.includes("surveys.umd.cjs");
|
||||
});
|
||||
expect(scriptAppendCalls.length).toBe(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
test("preloadSurveysScript adds a preload link and deduplicates subsequent calls", () => {
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
widget.preloadSurveysScript("https://fake.app");
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith("link");
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const linkEl = createElementSpy.mock.results[0].value as Record<string, string>;
|
||||
expect(linkEl.rel).toBe("preload");
|
||||
expect(linkEl.as).toBe("script");
|
||||
expect(linkEl.href).toBe("https://fake.app/js/surveys.umd.cjs");
|
||||
|
||||
// Second call should be a no-op (deduplication)
|
||||
widget.preloadSurveysScript("https://fake.app");
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
|
||||
@@ -106,7 +106,15 @@ export const renderWidget = async (
|
||||
const overlay = projectOverwrites.overlay ?? project.overlay;
|
||||
const placement = projectOverwrites.placement ?? project.placement;
|
||||
const isBrandingEnabled = project.inAppSurveyBranding;
|
||||
const formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
|
||||
let formbricksSurveys: TFormbricksSurveys;
|
||||
try {
|
||||
formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load surveys library: ${String(error)}`);
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
|
||||
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
|
||||
@@ -219,30 +227,87 @@ export const removeWidgetContainer = (): void => {
|
||||
document.getElementById(CONTAINER_ID)?.remove();
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
|
||||
const config = Config.getInstance();
|
||||
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
|
||||
const SURVEYS_POLL_INTERVAL_MS = 200;
|
||||
|
||||
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
|
||||
|
||||
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
|
||||
|
||||
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
// Apply stored nonce if it was set before surveys package loaded
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
const storedNonce = globalThis.window.__formbricksNonce;
|
||||
if (storedNonce) {
|
||||
globalThis.window.formbricksSurveys.setNonce(storedNonce);
|
||||
}
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
|
||||
reject(new Error("Formbricks Surveys library did not become available within timeout"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
return Promise.resolve(globalThis.window.formbricksSurveys);
|
||||
}
|
||||
|
||||
if (surveysLoadPromise) {
|
||||
return surveysLoadPromise;
|
||||
}
|
||||
|
||||
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
|
||||
const config = Config.getInstance();
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
waitForSurveysGlobal()
|
||||
.then(resolve)
|
||||
.catch((error: unknown) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
});
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return surveysLoadPromise;
|
||||
};
|
||||
|
||||
let isPreloaded = false;
|
||||
|
||||
export const preloadSurveysScript = (appUrl: string): void => {
|
||||
// Don't preload if already loaded or already preloading
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) return;
|
||||
if (isPreloaded) return;
|
||||
|
||||
isPreloaded = true;
|
||||
const link = document.createElement("link");
|
||||
link.rel = "preload";
|
||||
link.as = "script";
|
||||
link.href = `${appUrl}/js/surveys.umd.cjs`;
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
@@ -45,6 +45,8 @@ const baseLoggerConfig: LoggerOptions = {
|
||||
* - Both: optional pino-opentelemetry-transport for SigNoz log correlation when OTEL is configured
|
||||
*/
|
||||
const buildTransport = (): LoggerOptions["transport"] => {
|
||||
const isEdgeRuntime = process.env.NEXT_RUNTIME === "edge";
|
||||
|
||||
const hasOtelEndpoint =
|
||||
process.env.NEXT_RUNTIME === "nodejs" && Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
||||
|
||||
@@ -77,6 +79,11 @@ const buildTransport = (): LoggerOptions["transport"] => {
|
||||
};
|
||||
|
||||
if (!IS_PRODUCTION) {
|
||||
// Edge Runtime does not support worker_threads — skip pino-pretty to avoid crashes
|
||||
if (isEdgeRuntime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Development: pretty print + optional OTEL
|
||||
if (hasOtelEndpoint) {
|
||||
return { targets: [prettyTarget, otelTarget] };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user