Compare commits

..

12 Commits

Author SHA1 Message Date
Javi Aguilar d3c04876bf Squashed commit of the following:
commit 0ab6c07139dc603749e9d07dc971dac9800c0c75
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 20:36:56 2026 +0200

    fix to stop running pnpm install when dev server stops

commit c5bf0f30b3d2b44607b9be3e79f343463f7302ce
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 20:17:50 2026 +0200

    docs(workbench): emphasize token-efficient context gathering

commit e0bd91b3b8f65ec03e8c29d57fd66163bce01493
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 20:15:57 2026 +0200

    chore(workbench): add review gates, validator, and rename skills

    - Add human review gate lines to milestones, plans, templates, and require completion before downstream work.
    - Add workbench/scripts/validate-workbench.mjs to check structure, required sections, links, review gates, and placeholders.
    - Rename create-milestone, create-plan, document-decision, and generate-changelog skills under a workbench- prefix and symlink skills/ into .claude, .codex, and .cursor.
    - Update AGENTS.md, GUIDE.md, README.md, and checkpoints with review-gate rules, validator usage, and checkpoint proportionality guidance.

commit 370ade8fdaab98778f1ceb499ec604270c0d156d
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 19:48:12 2026 +0200

    chore: improve workflow dir structure and add skills

commit ab1f17004f1d60c91ae9f1618f360864dca36cb4
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 18:38:44 2026 +0200

    feat(workflows): add workspace runs page, create-workflow dialog,  improve loading states

    - Add /workspaces/[id]/workflows/runs page listing runs across all
      workflows, backed by new GET /api/v3/workflows/runs endpoint.
    - Replace inline "new workflow" action with a dialog capturing name and
      optional description; persist description on Workflow model and v3
      API.
    - Add loading skeletons for workflow list, builder, and runs pages, and
      a Beta badge in the main navigation workflows group.

commit c16872f7f59983a8fb04ce8d1704016c8d0d69f2
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 05:35:31 2026 +0200

    fix(a11y): avoid layout shift with loading button state

commit 872c8ecdfbf0ba5db4beea3e383731f791b06520
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 05:35:08 2026 +0200

    feat(workflows): polish builder/list and rename "active" to "enabled"

    - Rename WorkflowStatus enum value from "active" to "enabled" across DB, schema, types, API, and locales.
    - Extract workflow builder editor state to Jotai atoms.
    - Add list row dropdown with delete dialog and unique-name on create.
    - Add snap-to-grid, reorganize, and status pill to the canvas.

commit 4b5d679b0d936cd5b8fc200352288b0c641afd6a
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 04:20:53 2026 +0200

    feat(workflows): implement initial PoC of the MVP

commit a2f8eb4b25bd1a3042bc96a886d7d669b104a9e3
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 02:58:53 2026 +0200

    docs(workbench): complete 001-000 workflows PoC readiness

commit ef78ba5d2e3bc8b40c32b9966556554f430b51e2
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 02:26:23 2026 +0200

    docs(workbench): reframe workflows MVP as PoC and add 001-010 vertical slice plan

commit fa17580ca8c9be31500118b56daf1652bef5e077
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Fri May 22 02:07:39 2026 +0200

    docs(workbench): add workflows MVP blueprint, milestone, plans, and research

commit 8d3163f8fe8af7c8d85a4dec8af0d87ae5e8c460
Author: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date:   Thu May 21 22:13:47 2026 +0200

    chore: add workbench blueprint, cowork, and guidelines scaffolding
2026-05-22 20:45:12 +02:00
Bhagya Amarasinghe be5beaeed7 fix: harden Helm release secret lookups (#8118) 2026-05-22 11:54:20 +00:00
Dhruwang Jariwala bc56f99fd8 feat: cascade delete Hub feedback records on org deletion (ENG-973) (#8055)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:51:00 +00:00
Harsh Bhat 0f38627627 docs: restructure into Platform, Surveys, and Unify Feedback tabs (#8114)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-22 09:09:03 +00:00
Bhagya Amarasinghe a878bdff42 fix: limit JSON request body size (#8051)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-22 08:09:45 +00:00
Bhagya Amarasinghe d757e12c76 fix: return 404 when response is deleted mid-update (#8049) 2026-05-22 07:58:35 +00:00
Bhagya Amarasinghe 629febb2f7 fix: order Helm Hub migrations after Prisma (#8104) 2026-05-22 06:31:11 +00:00
Bhagya Amarasinghe 40b93cc834 fix: use Valkey for bundled Helm Redis (#8092) 2026-05-22 05:56:57 +00:00
Anshuman Pandey f41d2c14f1 fix: pin DNS and block redirects on webhook delivery in the response pipeline (#8095)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 04:46:20 +00:00
Matti Nannt af51414b03 fix: remove isAIDataAnalysisEnabled (ENG-1039) (#8109)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:54:36 +00:00
Matti Nannt a9e39dd4ab fix: validate displayId ownership on response creation (ENG-825) (#8046)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:11:19 +00:00
Johannes c8b0bb2225 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994) (#8101)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 11:41:23 +00:00
437 changed files with 24867 additions and 8541 deletions
+1
View File
@@ -0,0 +1 @@
../skills
+1
View File
@@ -0,0 +1 @@
../skills
+1
View File
@@ -0,0 +1 @@
../skills
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
version: v3.15.4
- name: Log in to GitHub Container Registry
env:
+52
View File
@@ -99,6 +99,58 @@ Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs coloc
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
## Workbench Workflow
The project workbench lives at `workbench/`.
Start substantial tasks with targeted workbench context, not a full-doc sweep. Use `rg`, indexes, headings, and linked records to find the smallest relevant set, then open only the sections needed for the task:
1. `workbench/blueprint/PRODUCT.md`
2. `workbench/blueprint/EPICS.md`
3. `workbench/blueprint/business-rules/`
4. `workbench/blueprint/decisions/`
5. `workbench/cowork/COORDINATOR.md`
6. `workbench/blueprint/MILESTONES.md`
7. The relevant plan, checkpoint, bug-fix, setup, env, security, check, or manual QA file.
`workbench/GUIDE.md` is the workflow source of truth. Keep `AGENTS.md` concise and route detailed workflow rules there.
### Workbench Token Budget
Prefer narrow input and compact output. Do not read entire workbench directories or paste long excerpts unless the task requires it. Summarize findings, link files, and report only changed records plus validation results. For routine implementation, one concise checkpoint is enough after an end-to-end pass.
### Blueprint vs Cowork
- `workbench/blueprint/` contains durable product and application truth: product definition, epics, milestones, business rules, decisions, design, security, checks, manual QA, and env var expectations.
- `workbench/cowork/` contains active execution records: plans, checkpoints, bug fixes, prompts, and coordination.
- `workbench/scratch/` and `workbench/local/` are ignored local spaces. Do not rely on their contents for durable project state.
### Blueprint Human Ownership
AI agents may help improve wording, structure, consistency, and traceability for `workbench/blueprint/` documents. The underlying concepts, original prompts or drafts, and final review must come from humans. Treat blueprint records as human-owned product truth: do not invent product direction, business rules, milestones, decisions, security posture, env expectations, checks, manual QA, or design guidance without explicit human input and review.
### Workbench Review Gates
Do not start planning or implementation from a milestone, plan, or bug-fix record unless it has a completed human review line such as `- [ ] Reviewed and refined by: Javier`. If the line is missing or still says `TBD`, stop and ask for human review. Keep checkpoints proportional: one checkpoint is enough when a plan is implemented end to end in one pass.
### Workbench Validation
After editing `workbench/`, `skills/`, or this workflow section in `AGENTS.md`, run `node workbench/scripts/validate-workbench.mjs workbench` and report any failures or relevant warnings. Before implementing from workbench records, run the validator when the workbench structure, links, or review gates may have changed since the plan was reviewed.
### Documentation Sync
When code changes alter product behavior, business rules, architecture decisions, env vars, setup, security posture, automated checks, or manual QA expectations, update the relevant workbench files in the same change. Avoid process churn: do not create or rewrite workbench docs for purely mechanical implementation details, formatting, or phase-by-phase narration when one concise checkpoint covers an end-to-end implementation.
Use checkpoints for completed plan phases. Use bug-fix records for scoped defects. Use decision records for durable tradeoffs. Use business-rule records for current domain behavior.
## Git Discipline
- Do not create commits unless explicitly asked.
- Preserve staged and unstaged work exactly.
- Do not stage, unstage, reset, or discard files unless explicitly asked.
- Before editing files in an existing repo, inspect `git status --short` and check relevant staged and unstaged diffs.
- Treat staged content as human-selected work in progress.
## Commit & Pull Request Guidelines
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
@@ -13,11 +13,13 @@ import {
MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlayCircleIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -187,6 +189,41 @@ export const MainNavigation = ({
},
],
},
{
id: "workflows",
name: (
<span className="inline-flex items-center gap-2">
<span>{t("common.workflows")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="text-[10px] font-semibold normal-case tracking-normal"
/>
</span>
),
items: [
{
name: t("common.workflows"),
href: `/workspaces/${workspace.id}/workflows`,
icon: WorkflowIcon,
isActive:
pathname?.startsWith(`/workspaces/${workspace.id}/workflows`) && !pathname?.includes("/runs"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
name: t("workspace.workflows.workflow_runs"),
href: `/workspaces/${workspace.id}/workflows/runs`,
icon: PlayCircleIcon,
isActive:
pathname?.startsWith(`/workspaces/${workspace.id}/workflows/runs`) ||
(pathname?.startsWith(`/workspaces/${workspace.id}/workflows/`) && pathname?.includes("/runs")),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
@@ -1,9 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const AccountSettingsLayout = async (
props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>
) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
@@ -66,11 +66,6 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -57,7 +57,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
@@ -66,7 +65,6 @@ describe("organization AI settings actions", () => {
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
@@ -114,18 +112,15 @@ describe("organization AI settings actions", () => {
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
@@ -194,7 +189,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
@@ -71,12 +71,11 @@ export const updateOrganizationNameAction = authenticatedActionClient
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
"isAISmartToolsEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
@@ -90,16 +89,10 @@ const resolveOrganizationAISettings = ({
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
};
};
@@ -50,29 +50,18 @@ export const AISettingsToggle = ({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
const handleToggle = async (checked: boolean) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
setLoadingField("isAISmartToolsEnabled");
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
data: { isAISmartToolsEnabled: checked },
});
if (response?.data) {
@@ -122,7 +111,7 @@ export const AISettingsToggle = ({
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
onToggle={handleToggle}
htmlId="ai-smart-tools-toggle"
title={t("workspace.settings.general.ai_smart_tools_enabled")}
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
@@ -130,16 +119,6 @@ export const AISettingsToggle = ({
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("workspace.settings.general.ai_data_analysis_enabled")}
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
@@ -9,7 +9,6 @@ import {
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
@@ -38,14 +37,11 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
const user = session?.user?.id ? await getUser(session.user.id) : null;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
]);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -4,7 +4,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
throw new InvalidInputError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -26,8 +26,8 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
SmilePlusIcon,
StarIcon,
User,
} from "lucide-react";
@@ -0,0 +1,3 @@
import { WorkflowBuilderLoading } from "@/modules/workflows/loading";
export default WorkflowBuilderLoading;
@@ -0,0 +1,13 @@
import { WorkflowBuilderPage } from "@/modules/workflows/pages/workflow-builder-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowPage = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const { workspaceId, workflowId } = await props.params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
return <WorkflowBuilderPage workspaceId={workspaceId} workflowId={workflowId} isReadOnly={isReadOnly} />;
};
export default WorkflowPage;
@@ -0,0 +1,3 @@
import { WorkflowRunDetailLoading } from "@/modules/workflows/loading";
export default WorkflowRunDetailLoading;
@@ -0,0 +1,13 @@
import { WorkflowRunDetailPage } from "@/modules/workflows/pages/workflow-run-detail-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRunDetail = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string; runId: string }> }>
) => {
const { workspaceId, workflowId, runId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkflowRunDetailPage workspaceId={workspaceId} workflowId={workflowId} runId={runId} />;
};
export default WorkflowRunDetail;
@@ -0,0 +1,3 @@
import { WorkflowRunsLoading } from "@/modules/workflows/loading";
export default WorkflowRunsLoading;
@@ -0,0 +1,13 @@
import { WorkflowRunsPage } from "@/modules/workflows/pages/workflow-runs-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRuns = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const { workspaceId, workflowId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkflowRunsPage workspaceId={workspaceId} workflowId={workflowId} />;
};
export default WorkflowRuns;
@@ -0,0 +1,3 @@
import { WorkflowsListLoading } from "@/modules/workflows/loading";
export default WorkflowsListLoading;
@@ -0,0 +1,11 @@
import { WorkflowsListPage } from "@/modules/workflows/pages/workflows-list-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
return <WorkflowsListPage workspaceId={workspaceId} isReadOnly={isReadOnly} />;
};
export default WorkflowsPage;
@@ -0,0 +1,3 @@
import { WorkspaceWorkflowRunsLoading } from "@/modules/workflows/loading";
export default WorkspaceWorkflowRunsLoading;
@@ -0,0 +1,11 @@
import { WorkspaceWorkflowRunsPage } from "@/modules/workflows/pages/workspace-workflow-runs-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRuns = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkspaceWorkflowRunsPage workspaceId={workspaceId} />;
};
export default WorkflowRuns;
+11 -2
View File
@@ -313,9 +313,18 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
test("returns 404 notFound for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
});
test("returns 500 internalServerError for unknown errors", async () => {
+4 -5
View File
@@ -29,11 +29,10 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -1,6 +1,7 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -32,7 +33,25 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
const jsonInput = await req.json();
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
workspaceId,
@@ -1,7 +1,12 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganization } from "@/lib/organization/service";
@@ -155,6 +160,16 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -11,6 +16,7 @@ import {
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -104,6 +110,16 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contact?.id ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
@@ -131,6 +147,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -6,6 +6,7 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -56,11 +57,17 @@ export const POST = withV1ApiWrapper({
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await req.json();
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
@@ -211,7 +218,7 @@ export const POST = withV1ApiWrapper({
response: responseData,
});
if (responseInput.finished) {
if (responseInputData.finished) {
await sendToPipeline({
event: "responseFinished",
workspaceId,
@@ -3,6 +3,7 @@ import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classe
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -84,8 +85,14 @@ export const PUT = withV1ApiWrapper({
let actionClassUpdate;
try {
actionClassUpdate = await req.json();
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -45,8 +46,14 @@ export const POST = withV1ApiWrapper({
try {
let actionClassInput;
try {
actionClassInput = await req.json();
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,6 +1,7 @@
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { TResponseData, ZResponseUpdateInput } from "@formbricks/types/responses";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -12,6 +13,11 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
type TUncheckedResponseUpdate = Record<string, unknown> & {
data: TResponseData;
language?: string;
};
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiV1Authentication | undefined,
@@ -120,10 +126,16 @@ export const PUT = withV1ApiWrapper({
auditLog.oldObject = result.response;
}
let responseUpdate;
let responseUpdate: TUncheckedResponseUpdate;
try {
responseUpdate = await req.json();
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -91,8 +92,14 @@ export const POST = withV1ApiWrapper({
try {
let jsonInput;
try {
jsonInput = await req.json();
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -19,8 +20,14 @@ export const POST = withV1ApiWrapper({
let storageInput;
try {
storageInput = await req.json();
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -9,6 +9,7 @@ import {
addLegacyProjectOverwrites,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -22,6 +23,12 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
type TSurveyUpdateBody = Record<string, unknown> & {
blocks?: Parameters<typeof validateSurveyInput>[0]["blocks"];
endings?: Parameters<typeof transformQuestionsToBlocks>[1];
questions?: Parameters<typeof transformQuestionsToBlocks>[0];
};
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
@@ -164,10 +171,16 @@ export const PUT = withV1ApiWrapper({
};
}
let surveyUpdate;
let surveyUpdate: TSurveyUpdateBody;
try {
surveyUpdate = await req.json();
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -188,7 +201,7 @@ export const PUT = withV1ApiWrapper({
if (hasQuestions) {
surveyUpdate.blocks = transformQuestionsToBlocks(
surveyUpdate.questions,
surveyUpdate.questions ?? [],
surveyUpdate.endings || result.survey.endings
);
surveyUpdate.questions = [];
@@ -208,7 +221,11 @@ export const PUT = withV1ApiWrapper({
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
const featureCheckResult = await checkFeaturePermissions(
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
organization,
result.survey
);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -51,7 +51,6 @@ const mockOrganization: TOrganization = {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithWorkspaceId["followUps"][number] = {
@@ -8,6 +8,7 @@ import {
addLegacyProjectOverwritesToList,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -84,8 +85,14 @@ export const POST = withV1ApiWrapper({
try {
let surveyInput;
try {
surveyInput = await req.json();
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
+9 -2
View File
@@ -2,6 +2,7 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -40,8 +41,14 @@ export const POST = withV1ApiWrapper({
let webhookInput;
try {
webhookInput = await req.json();
} catch {
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -2,7 +2,12 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -190,7 +195,19 @@ describe("createResponse V2", () => {
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["someOtherField"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
@@ -199,7 +216,7 @@ describe("createResponse V2", () => {
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
).rejects.toThrow(InvalidInputError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -12,6 +17,7 @@ import {
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -99,6 +105,16 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contactId ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -122,6 +138,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -2,6 +2,7 @@ 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 { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
@@ -414,6 +415,44 @@ describe("withV3ApiWrapper", () => {
]);
});
test("returns 413 problem response for oversized 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-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
"x-request-id": "req-payload-too-large",
},
}),
{} as never
);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
code: "payload_too_large",
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
requestId: "req-payload-too-large",
status: 413,
title: "Payload Too Large",
})
);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
+11 -2
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -16,6 +17,7 @@ import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemPayloadTooLarge,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
@@ -170,8 +172,15 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
bodyData = await parseJsonBodyWithLimit(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
ok: false,
response: problemPayloadTooLarge(requestId, error.message, instance),
};
}
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
+13 -13
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
@@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
@@ -39,7 +39,7 @@ describe("requireSessionWorkspaceAccess", () => {
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getWorkspace).not.toHaveBeenCalled();
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
@@ -55,11 +55,11 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getWorkspace).not.toHaveBeenCalled();
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
});
test("returns 403 when workspace is not found (avoid leaking existence)", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"ws_nonexistent",
@@ -72,12 +72,12 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
@@ -102,7 +102,7 @@ describe("requireSessionWorkspaceAccess", () => {
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
@@ -144,7 +144,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k");
});
@@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
@@ -179,7 +179,7 @@ describe("requireV3WorkspaceAccess", () => {
workspaceId: "proj_k",
organizationId: "org_k",
});
expect(getWorkspace).toHaveBeenCalledWith("proj_k");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k");
});
test("returns context for API key with write on workspace", async () => {
@@ -239,7 +239,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
const auth = {
...keyBase,
workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)],
+2 -13
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -14,7 +13,7 @@ import {
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y", identifier: "canonical-x" }],
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
@@ -22,7 +21,7 @@ describe("v3 problem responses", () => {
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", identifier: "canonical-x" }]);
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
@@ -119,13 +118,3 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
+12 -16
View File
@@ -6,7 +6,7 @@
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string; identifier?: string };
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
@@ -71,6 +71,17 @@ export function problemBadRequest(
});
}
export function problemPayloadTooLarge(
requestId: string,
detail: string = "Payload Too Large",
instance?: string
): Response {
return problemResponse(413, "Payload Too Large", detail, requestId, {
code: "payload_too_large",
instance,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
@@ -171,18 +182,3 @@ export function successResponse<T>(
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -1,34 +1,45 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns workspaceId and organizationId when workspace exists", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("ws_abc");
expect(result).toEqual({
workspaceId: "ws_abc",
organizationId: "org_123",
});
expect(getWorkspace).toHaveBeenCalledWith("ws_abc");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc");
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_abc");
});
test("resolves legacy environmentId to canonical workspaceId", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_canonical" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_456");
const result = await resolveV3WorkspaceContext("env_legacy");
expect(result).toEqual({
workspaceId: "ws_canonical",
organizationId: "org_456",
});
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_canonical");
});
test("throws when workspace does not exist", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
});
});
+6 -5
View File
@@ -6,7 +6,7 @@
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
/**
* Internal IDs derived from a V3 workspace identifier.
@@ -19,20 +19,21 @@ export type V3WorkspaceContext = {
};
/**
* Resolves a V3 API workspaceId to internal workspaceId and organizationId.
* Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId.
*
* @throws ResourceNotFoundError if the workspace does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
const workspace = await getWorkspace(workspaceId);
const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId);
if (!workspace) {
throw new ResourceNotFoundError("workspace", workspaceId);
}
const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id);
const canonicalId = workspace.id;
const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId);
return {
workspaceId: workspace.id,
workspaceId: canonicalId,
organizationId,
};
}
@@ -0,0 +1,318 @@
import { ApiKeyPermission } 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 { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
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("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "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, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
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(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+28 -122
View File
@@ -2,141 +2,42 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { getAuthorizedV3Survey } from "../authorization";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
...(error.normalizedCode && { identifier: error.normalizedCode }),
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: surveyParamsSchema,
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, authResult, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "readWrite",
requestId,
instance,
});
const survey = await getSurvey(surveyId);
if (response) {
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
@@ -145,9 +46,14 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
await deleteSurvey(surveyId);
const deletedSurvey = await deleteSurvey(surveyId);
return noContentResponse({ requestId });
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -1,71 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { getAuthorizedV3Survey } from "./authorization";
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
const survey = {
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
};
const surveyRecord = survey as unknown as NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
describe("getAuthorizedV3Survey", () => {
test("returns a generic forbidden response when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_1",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response?.status).toBe(403);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns the authorization response when workspace access is denied", async () => {
const forbiddenResponse = new Response(null, { status: 403 });
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(forbiddenResponse);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "readWrite",
requestId: "req_2",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response).toBe(forbiddenResponse);
});
test("returns the survey and authorization context when access is allowed", async () => {
const authResult = { workspaceId: survey.workspaceId, organizationId: "org_1" };
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_3",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result).toEqual({
survey,
authResult,
response: null,
});
});
});
@@ -1,37 +0,0 @@
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden } from "@/app/api/v3/lib/response";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getSurvey } from "@/lib/survey/service";
export async function getAuthorizedV3Survey(params: {
surveyId: string;
authentication: TV3Authentication;
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
@@ -1,120 +0,0 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["zh_hans_cn", "zh-Hans-CN"],
["ZH-hant-tw", "zh-Hant-TW"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
test("returns null for language-only tags", () => {
expect(normalizeV3SurveyLanguageTag("de")).toBeNull();
});
test("returns null for script-only tags without a region", () => {
expect(normalizeV3SurveyLanguageTag("zh_Hans")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us, zh_hans_cn")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US", "zh-Hans-CN"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
test("rejects language-only selectors", () => {
expect(parseV3SurveyLanguageQuery("de")).toEqual({
ok: false,
message: "Language 'de' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("matches configured script-region languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("ZH_hans_cn", [{ code: "zh-Hans-CN", enabled: true }])).toEqual({
ok: true,
code: "zh-Hans-CN",
});
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr-FR", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("ZH_hant_tw", languages)).toEqual({
ok: false,
reason: "unknown",
normalizedCode: "zh-Hant-TW",
message: "Language 'zh-Hant-TW' is not configured for this survey",
});
});
test("rejects language-only tags for surveys with a matching configured language", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({
ok: false,
reason: "invalid",
message: "Language 'de' is not a valid locale code",
});
});
test("resolves the implicit default locale for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en-US", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
-97
View File
@@ -1,97 +0,0 @@
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown"; message: string; normalizedCode?: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
const V3_SURVEY_LOCALE_CODE_REGEX = /^[a-z]{2}(?:-[A-Z][a-z]{3})?-[A-Z]{2}$/;
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
const normalizedLanguage = Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
if (!normalizedLanguage || !V3_SURVEY_LOCALE_CODE_REGEX.test(normalizedLanguage)) {
return null;
}
return normalizedLanguage;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
return {
ok: false,
reason: "unknown",
normalizedCode: normalizedRequestedLanguage,
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
@@ -1,290 +0,0 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: { cx: "enterprise" },
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en-US"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("filters script-region locale selectors while preserving maps", () => {
const survey = {
...baseSurvey,
languages: [
...baseSurvey.languages,
{
default: false,
enabled: true,
language: {
id: "lang_4",
code: "zh-Hans-CN",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", zh_hans_cn: "欢迎" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["ZH_hans_cn"] });
expect(resource).toMatchObject({
welcomeCard: { headline: { "zh-Hans-CN": "欢迎" } },
});
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr-FR"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de-DE"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects language-only selectors", () => {
expect(() => serializeV3SurveyResource(baseSurvey, { lang: ["de"] })).toThrow(
"Language 'de' is not a valid locale code"
);
});
test("exposes the normalized locale code for unknown language errors", () => {
try {
serializeV3SurveyResource(baseSurvey, { lang: ["ES_es"] });
} catch (error) {
if (!(error instanceof V3SurveyLanguageError)) {
throw error;
}
expect(error.message).toBe("Language 'es-ES' is not configured for this survey");
expect(error.normalizedCode).toBe("es-ES");
return;
}
throw new Error("Expected V3SurveyLanguageError");
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+3 -185
View File
@@ -1,195 +1,13 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(
message: string,
readonly normalizedCode?: string
) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
}
return languages;
}
function getDefaultLanguage(survey: TInternalSurvey): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: DEFAULT_V3_SURVEY_LANGUAGE;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message, result.normalizedCode);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: survey.metadata,
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
@@ -0,0 +1,59 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { disableWorkflow, getWorkflow } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await disableWorkflow(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow disable unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,59 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { enableWorkflow, getWorkflow } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await enableWorkflow(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (error instanceof z.ZodError || error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow enable unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,157 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowDefinition } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
deleteWorkflow,
getWorkflow,
getWorkflowByWorkspace,
updateWorkflow,
} from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
const ZUpdateWorkflowBody = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(500).nullable().optional(),
definition: ZWorkflowDefinition.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow detail unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const PATCH = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
body: ZUpdateWorkflowBody,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await updateWorkflow({
workflowId,
workspaceId: authResult.workspaceId,
name: parsedInput.body.name,
description: parsedInput.body.description,
definition: parsedInput.body.definition,
});
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return problemBadRequest(requestId, "A workflow with this name already exists", { instance });
}
if (error instanceof z.ZodError || error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow update unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await getWorkflowByWorkspace(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const deletedWorkflow = await deleteWorkflow(workflowId, authResult.workspaceId);
return successResponse(deletedWorkflow, { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,51 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getWorkflow, getWorkflowRun } from "@/modules/workflows/lib/service";
import { serializeWorkflowRun } from "../../../serializers";
const ZWorkflowRunParams = z.object({
workflowId: z.cuid2(),
runId: z.cuid2(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowRunParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const { workflowId, runId } = parsedInput.params;
const log = logger.withContext({ requestId, workflowId, runId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const run = await getWorkflowRun(workflowId, authResult.workspaceId, runId);
if (!run) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflowRun(run), { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow run detail unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,64 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowRunStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successListResponse } from "@/app/api/v3/lib/response";
import { getWorkflow, listWorkflowRuns } from "@/modules/workflows/lib/service";
import { serializeWorkflowRun } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
const ZWorkflowRunsQuery = z.object({
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowRunStatus.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
query: ZWorkflowRunsQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkflowRuns({
workflowId,
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.runs.map(serializeWorkflowRun), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow runs list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+222
View File
@@ -0,0 +1,222 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { createDefaultWorkflowDefinition } from "@/modules/workflows/lib/default-workflow";
import { createWorkflow, listWorkflows, listWorkspaceWorkflowRuns } from "@/modules/workflows/lib/service";
import { GET, POST } from "./route";
import { GET as GET_WORKSPACE_RUNS } from "./runs/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/workflows/lib/service", () => ({
createWorkflow: vi.fn(),
listWorkflows: vi.fn(),
listWorkspaceWorkflowRuns: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const workspaceId = "cm8cmpnjj000108jfdr9dfqe8";
const workflowId = "cm8cmpnjj000108jfdr9dfqe7";
function createRequest(url: string, init?: RequestInit): NextRequest {
return new NextRequest(url, init);
}
describe("/api/v3/workflows", () => {
beforeEach(() => {
vi.clearAllMocks();
getServerSession.mockResolvedValue({
expires: "2026-01-01",
user: { email: "u@example.com", id: "user_1", name: "User" },
} as never);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
organizationId: "org_1",
workspaceId,
});
vi.mocked(listWorkflows).mockResolvedValue({ nextCursor: null, workflows: [] } as never);
});
test("lists workflows through workspace-scoped v3 access", async () => {
const req = createRequest(`http://localhost/api/v3/workflows?workspaceId=${workspaceId}&limit=10`);
const res = await GET(req, {} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"read",
expect.any(String),
"/api/v3/workflows"
);
expect(listWorkflows).toHaveBeenCalledWith({
cursor: undefined,
limit: 10,
status: undefined,
workspaceId,
});
});
test("creates draft workflows through the v3 API", async () => {
const definition = createDefaultWorkflowDefinition();
vi.mocked(createWorkflow).mockResolvedValue({
createdAt: new Date("2026-04-07T10:00:00.000Z"),
createdBy: "user_1",
description: "Notify the team when a response matches the PoC branch.",
definition,
id: workflowId,
name: "Response completed workflow",
status: "draft",
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workspaceId,
} as never);
const req = createRequest("http://localhost/api/v3/workflows", {
body: JSON.stringify({
description: "Notify the team when a response matches the PoC branch.",
definition,
name: "Response completed workflow",
workspaceId,
}),
method: "POST",
});
const res = await POST(req, {} as never);
const body = await res.json();
expect(res.status).toBe(201);
expect(body.data.id).toBe(workflowId);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
expect.any(String),
"/api/v3/workflows"
);
expect(createWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
description: "Notify the team when a response matches the PoC branch.",
definition,
name: "Response completed workflow",
workspaceId,
})
);
});
test("lists workspace workflow runs through workspace-scoped v3 access", async () => {
vi.mocked(listWorkspaceWorkflowRuns).mockResolvedValue({
nextCursor: null,
runs: [
{
createdAt: new Date("2026-04-07T10:00:00.000Z"),
data: { steps: [], logs: [] },
error: null,
finishedAt: null,
id: "cm8cmpnjj000108jfdr9dfqe6",
responseId: "cm8cmpnjj000108jfdr9dfqe5",
startedAt: null,
status: "queued",
surveyId: "cm8cmpnjj000108jfdr9dfqe4",
triggerEvent: "response.completed",
triggerPayload: {},
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workflow: {
createdAt: new Date("2026-04-07T10:00:00.000Z"),
createdBy: "user_1",
definition: createDefaultWorkflowDefinition(),
description: null,
id: workflowId,
name: "Response completed workflow",
status: "enabled",
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workspaceId,
},
workflowId,
workspaceId,
},
],
} as never);
const req = createRequest(`http://localhost/api/v3/workflows/runs?workspaceId=${workspaceId}&limit=10`);
const res = await GET_WORKSPACE_RUNS(req, {} as never);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data[0].workflow.name).toBe("Response completed workflow");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"read",
expect.any(String),
"/api/v3/workflows/runs"
);
expect(listWorkspaceWorkflowRuns).toHaveBeenCalledWith({
cursor: undefined,
limit: 10,
status: undefined,
workspaceId,
});
});
test("rejects deferred compute actions before service calls", async () => {
const req = createRequest("http://localhost/api/v3/workflows", {
body: JSON.stringify({
definition: {
...createDefaultWorkflowDefinition(),
nodes: [
{
actionType: "compute",
config: {},
id: "compute-1",
type: "action",
},
],
},
name: "Unsupported workflow",
workspaceId,
}),
method: "POST",
});
const res = await POST(req, {} as never);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
expect(createWorkflow).not.toHaveBeenCalled();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowDefinition, ZWorkflowStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemInternalError,
successListResponse,
successResponse,
} from "@/app/api/v3/lib/response";
import { createWorkflow, listWorkflows } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "./serializers";
const ZWorkflowListQuery = z.object({
workspaceId: z.cuid2(),
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowStatus.optional(),
});
const ZCreateWorkflowBody = z.object({
workspaceId: z.cuid2(),
name: z.string().min(1).max(120),
description: z.string().max(500).nullable().optional(),
status: z.literal("draft").optional(),
definition: ZWorkflowDefinition,
});
const getAuthenticatedUserId = (authentication: unknown): string | undefined => {
if (authentication && typeof authentication === "object" && "user" in authentication) {
const user = (authentication as { user?: { id?: string } }).user;
return user?.id;
}
return undefined;
};
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
query: ZWorkflowListQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.query.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.query.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkflows({
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.workflows.map(serializeWorkflow), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflows list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZCreateWorkflowBody,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.body.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.body.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await createWorkflow({
workspaceId: authResult.workspaceId,
name: parsedInput.body.name,
description: parsedInput.body.description,
definition: parsedInput.body.definition,
createdBy: getAuthenticatedUserId(authentication),
});
return successResponse(serializeWorkflow(workflow), { requestId, status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return problemBadRequest(requestId, "Invalid workflow definition", { instance });
}
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return problemBadRequest(requestId, "A workflow with this name already exists", { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow create unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,53 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowRunStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemInternalError, successListResponse } from "@/app/api/v3/lib/response";
import { listWorkspaceWorkflowRuns } from "@/modules/workflows/lib/service";
import { serializeWorkflowRunWithWorkflow } from "../serializers";
const ZWorkspaceWorkflowRunsQuery = z.object({
workspaceId: z.cuid2(),
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowRunStatus.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
query: ZWorkspaceWorkflowRunsQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.query.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.query.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkspaceWorkflowRuns({
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.runs.map(serializeWorkflowRunWithWorkflow), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workspace workflow runs list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,44 @@
import type { Workflow, WorkflowRun } from "@prisma/client";
import { ZWorkflowDefinition, ZWorkflowRunData } from "@formbricks/types/workflows";
const serializeWorkflowRunSummary = (run: WorkflowRun) => ({
id: run.id,
workflowId: run.workflowId,
workspaceId: run.workspaceId,
status: run.status,
triggerEvent: run.triggerEvent,
surveyId: run.surveyId,
responseId: run.responseId,
error: run.error,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
});
export const serializeWorkflow = (workflow: Workflow & { runs?: WorkflowRun[] }) => ({
id: workflow.id,
name: workflow.name,
description: workflow.description,
status: workflow.status,
workspaceId: workflow.workspaceId,
createdBy: workflow.createdBy,
definition: ZWorkflowDefinition.parse(workflow.definition),
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
lastRun: workflow.runs?.[0] ? serializeWorkflowRunSummary(workflow.runs[0]) : null,
});
export const serializeWorkflowRun = (run: WorkflowRun) => ({
...serializeWorkflowRunSummary(run),
triggerPayload: run.triggerPayload,
data: ZWorkflowRunData.parse(run.data),
});
export const serializeWorkflowRunWithWorkflow = (run: WorkflowRun & { workflow: Workflow }) => ({
...serializeWorkflowRun(run),
workflow: {
id: run.workflow.id,
name: run.workflow.name,
},
});
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "./request-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
@@ -39,6 +40,40 @@ describe("parseAndValidateJsonBody", () => {
});
});
test("returns a payload too large response when the request body exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
body: "{}",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("payload_too_large");
expect(result.response.status).toBe(413);
await expect(result.response.json()).resolves.toEqual({
code: "payload_too_large",
message: "Payload Too Large",
details: {
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
@@ -1,8 +1,9 @@
import { z } from "zod";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body" | "payload_too_large";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
@@ -44,10 +45,18 @@ export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
let jsonInput: unknown;
try {
jsonInput = await request.json();
jsonInput = await parseJsonBodyWithLimit(request);
} catch (error) {
const details = { error: getErrorMessage(error) };
if (error instanceof RequestBodyTooLargeError) {
return {
details,
issue: "payload_too_large",
response: responses.payloadTooLargeResponse("Payload Too Large", details, true),
};
}
return {
details,
issue: "invalid_json",
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
RequestBodyTooLargeError,
parseJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./request-body";
const createStreamingRequest = (chunks: string[]): Request =>
new Request("http://localhost/api/test", {
method: "POST",
body: new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
describe("request body parsing", () => {
test("rejects a request when content-length exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
},
body: "{}",
});
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
name: "RequestBodyTooLargeError",
});
});
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
});
test("allows a body exactly at the body limit", async () => {
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
const request = new Request("http://localhost/api/test", {
method: "POST",
body: rawBody,
});
const body = await readRequestBodyWithLimit(request);
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
expect(body).toBe(rawBody);
});
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
body: "{invalid-json",
});
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
});
test("returns an empty string for requests without a body", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
});
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
});
});
+90
View File
@@ -0,0 +1,90 @@
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
export class RequestBodyTooLargeError extends Error {
readonly actualBytes: number | null;
readonly limitBytes: number;
constructor(limitBytes: number, actualBytes: number | null = null) {
super(`Request body must not exceed ${limitBytes} bytes`);
this.name = "RequestBodyTooLargeError";
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}
const textDecoder = new TextDecoder();
const getContentLength = (headers: Headers): number | null => {
const contentLength = headers.get("content-length");
if (!contentLength) {
return null;
}
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength < 0) {
return null;
}
return parsedContentLength;
};
const assertBodySize = (actualBytes: number, limitBytes: number): void => {
if (actualBytes > limitBytes) {
throw new RequestBodyTooLargeError(limitBytes, actualBytes);
}
};
export const readRequestBodyWithLimit = async (
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<string> => {
const contentLength = getContentLength(request.headers);
if (contentLength !== null) {
assertBodySize(contentLength, limitBytes);
}
if (!request.body) {
return "";
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > limitBytes) {
await reader.cancel().catch(() => undefined);
throw new RequestBodyTooLargeError(limitBytes, receivedBytes);
}
chunks.push(value);
}
if (chunks.length === 0) {
return "";
}
if (chunks.length === 1) {
return textDecoder.decode(chunks[0]);
}
const body = new Uint8Array(receivedBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return textDecoder.decode(body);
};
export const parseJsonBodyWithLimit = async <TJson = unknown>(
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<TJson> => JSON.parse(await readRequestBodyWithLimit(request, limitBytes)) as TJson;
+27 -1
View File
@@ -17,7 +17,8 @@ interface ApiErrorResponse {
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict";
| "conflict"
| "payload_too_large";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -80,6 +81,30 @@ const badRequestResponse = (
);
};
const payloadTooLargeResponse = (
message: string = "Payload Too Large",
details: ApiErrorResponse["details"] = {},
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "payload_too_large",
message,
details,
},
{
status: 413,
headers,
}
);
};
const methodNotAllowedResponse = (
res: CustomNextApiResponse,
allowedMethods: string[],
@@ -294,6 +319,7 @@ export const responses = {
unauthorizedResponse,
notFoundResponse,
successResponse,
payloadTooLargeResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
+2
View File
@@ -1859,6 +1859,7 @@ checksums:
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
@@ -1893,6 +1894,7 @@ checksums:
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
+35
View File
@@ -10,6 +10,7 @@ const mockGetJobsQueueingConfig = vi.fn();
const mockGetJobsWorkerBootstrapConfig = vi.fn();
const mockProcessResponsePipelineJob = vi.fn();
const mockProcessSurveySchedulingJob = vi.fn();
const mockProcessWorkflowRunJob = vi.fn();
const TEST_TIMEOUT_MS = 15_000;
const slowTest = (name: string, fn: () => Promise<void>): void => {
@@ -44,6 +45,10 @@ vi.mock("@/modules/survey/scheduling/lib/process-survey-scheduling-job", () => (
processSurveySchedulingJob: mockProcessSurveySchedulingJob,
}));
vi.mock("@/modules/workflows/lib/process-workflow-run-job", () => ({
processWorkflowRunJob: mockProcessWorkflowRunJob,
}));
describe("instrumentation-jobs", () => {
beforeEach(() => {
vi.resetModules();
@@ -109,6 +114,7 @@ describe("instrumentation-jobs", () => {
"response-pipeline.process": expect.any(Function),
"survey-scheduling.reconcile": expect.any(Function),
"test-log.process": mockExistingOverride,
"workflow-run.process": expect.any(Function),
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
@@ -116,6 +122,7 @@ describe("instrumentation-jobs", () => {
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
const responsePipelineOverride = overrides?.["response-pipeline.process"];
const surveySchedulingOverride = overrides?.["survey-scheduling.reconcile"];
const workflowRunOverride = overrides?.["workflow-run.process"];
await responsePipelineOverride?.(
{
@@ -144,6 +151,20 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
await workflowRunOverride?.(
{
workflowId: "workflow_123",
workflowRunId: "workflow_run_123",
workspaceId: "ws_123",
},
{
attempt: 1,
jobId: "job_789",
jobName: "workflow-run.process",
maxAttempts: 1,
queueName: "background-jobs",
}
);
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
{
@@ -172,6 +193,20 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
expect(mockProcessWorkflowRunJob).toHaveBeenCalledWith(
{
workflowId: "workflow_123",
workflowRunId: "workflow_run_123",
workspaceId: "ws_123",
},
{
attempt: 1,
jobId: "job_789",
jobName: "workflow-run.process",
maxAttempts: 1,
queueName: "background-jobs",
}
);
});
slowTest("reuses the in-flight startup promise", async () => {
+8
View File
@@ -3,6 +3,7 @@ import {
type JobsRuntimeHandle,
type TResponsePipelineJobData,
type TSurveySchedulingJobData,
type TWorkflowRunJobData,
removeRecurringSurveySchedulingJobSchedule,
startJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule,
@@ -17,6 +18,7 @@ import {
SURVEY_SCHEDULING_TIME_ZONE,
} from "@/modules/survey/scheduling/lib/constants";
import { processSurveySchedulingJob } from "@/modules/survey/scheduling/lib/process-survey-scheduling-job";
import { processWorkflowRunJob } from "@/modules/workflows/lib/process-workflow-run-job";
const WORKER_STARTUP_RETRY_DELAY_MS = 30_000;
@@ -32,6 +34,7 @@ type TJobsRuntimeGlobal = typeof globalThis & {
const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile";
const WORKFLOW_RUN_JOB_NAME = "workflow-run.process";
const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
@@ -39,6 +42,9 @@ const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = asy
const surveySchedulingJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processSurveySchedulingJob(data as TSurveySchedulingJobData, context);
};
const workflowRunJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processWorkflowRunJob(data as TWorkflowRunJobData, context);
};
const registerSurveySchedulingSchedule = async (): Promise<void> => {
await removeRecurringSurveySchedulingJobSchedule({
@@ -170,10 +176,12 @@ export const registerJobsWorker = async (): Promise<JobsRuntimeHandle | null> =>
...runtimeOptions.jobHandlerOverrides,
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
}
: {
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
};
globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => {
+3 -65
View File
@@ -3,7 +3,6 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
@@ -13,7 +12,6 @@ const mocks = vi.hoisted(() => ({
generateText: vi.fn(),
isAiConfigured: vi.fn(),
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
loggerError: vi.fn(),
}));
@@ -63,7 +61,6 @@ vi.mock("@/lib/organization/service", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
@@ -75,10 +72,8 @@ describe("AI organization service", () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
});
test("returns the instance AI status and organization settings", async () => {
@@ -89,9 +84,7 @@ describe("AI organization service", () => {
expect(result).toMatchObject({
organizationId: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
isAISmartToolsEntitled: true,
isAIDataAnalysisEntitled: true,
isInstanceConfigured: true,
});
});
@@ -105,29 +98,22 @@ describe("AI organization service", () => {
test("fails closed when the organization is not entitled to AI", async () => {
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("fails closed when the requested AI capability is disabled", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: "org_1",
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: true,
});
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("fails closed when the instance AI configuration is incomplete", async () => {
mocks.isAiConfigured.mockReturnValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("generates organization AI text with the configured package abstraction", async () => {
@@ -136,7 +122,6 @@ describe("AI organization service", () => {
const result = await generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
});
@@ -160,14 +145,12 @@ describe("AI organization service", () => {
await expect(
generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
})
).rejects.toThrow(modelError);
expect(mocks.loggerError).toHaveBeenCalledWith(
{
organizationId: "org_1",
capability: "smartTools",
isInstanceConfigured: true,
errorCode: undefined,
err: modelError,
@@ -176,46 +159,11 @@ describe("AI organization service", () => {
);
});
describe("getAIDataAnalysisUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
test("returns undefined when all checks pass", () => {
expect(getAIDataAnalysisUnavailableReason(baseConfig)).toBeUndefined();
});
test("returns not_in_plan when not entitled", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEntitled: false })).toBe(
"not_in_plan"
);
});
test("returns not_enabled when disabled at org level", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEnabled: false })).toBe(
"not_enabled"
);
});
test("returns instance_not_configured when instance AI is missing", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
"instance_not_configured"
);
});
});
describe("getAISmartToolsUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
@@ -240,15 +188,5 @@ describe("AI organization service", () => {
"instance_not_configured"
);
});
test("ignores data-analysis flags (smart tools is independent of data analysis state)", () => {
expect(
getAISmartToolsUnavailableReason({
...baseConfig,
isAIDataAnalysisEntitled: false,
isAIDataAnalysisEnabled: false,
})
).toBeUndefined();
});
});
});
+6 -33
View File
@@ -4,12 +4,11 @@ import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export const AI_ERROR_CODES = {
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
} as const;
@@ -18,9 +17,7 @@ export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
isAISmartToolsEntitled: boolean;
isAIDataAnalysisEntitled: boolean;
isInstanceConfigured: boolean;
}
@@ -33,32 +30,18 @@ export const getOrganizationAIConfig = async (organizationId: string): Promise<T
throw new ResourceNotFoundError("Organization", organizationId);
}
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
getIsAISmartToolsEnabled(organizationId),
getIsAIDataAnalysisEnabled(organizationId),
]);
const isAISmartToolsEntitled = await getIsAISmartToolsEnabled(organizationId);
return {
organizationId,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
isAISmartToolsEntitled,
isAIDataAnalysisEntitled,
isInstanceConfigured: isInstanceAIConfigured(),
};
};
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export const getAIDataAnalysisUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
if (!aiConfig.isAIDataAnalysisEntitled) return "not_in_plan";
if (!aiConfig.isAIDataAnalysisEnabled) return "not_enabled";
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
return undefined;
};
export const getAISmartToolsUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
@@ -69,25 +52,18 @@ export const getAISmartToolsUnavailableReason = (
};
export const assertOrganizationAIConfigured = async (
organizationId: string,
capability: "smartTools" | "dataAnalysis"
organizationId: string
): Promise<TOrganizationAIConfig> => {
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!isCapabilityEntitled) {
if (!aiConfig.isAISmartToolsEntitled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
}
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
if (!aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
}
@@ -97,15 +73,13 @@ export const assertOrganizationAIConfigured = async (
type TGenerateOrganizationAITextInput = {
organizationId: string;
capability: "smartTools" | "dataAnalysis";
} & Parameters<typeof generateText>[0];
export const generateOrganizationAIText = async ({
organizationId,
capability,
...options
}: TGenerateOrganizationAITextInput): Promise<Awaited<ReturnType<typeof generateText>>> => {
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
const aiConfig = await assertOrganizationAIConfigured(organizationId);
try {
return await generateText(options, env);
@@ -113,7 +87,6 @@ export const generateOrganizationAIText = async ({
logger.error(
{
organizationId,
capability,
isInstanceConfigured: aiConfig.isInstanceConfigured,
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
err: error,
+53 -1
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
export const selectDisplay = {
@@ -146,6 +146,58 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
}
);
export const getDisplayForResponseValidation = async (
displayId: string,
tx?: Prisma.TransactionClient
): Promise<{
surveyId: string;
workspaceId: string;
responseId: string | null;
contactId: string | null;
} | null> => {
validateInputs([displayId, ZId]);
const client = tx ?? prisma;
try {
const display = await client.display.findUnique({
where: { id: displayId },
select: {
surveyId: true,
contactId: true,
response: { select: { id: true } },
survey: { select: { workspaceId: true } },
},
});
if (!display) return null;
return {
surveyId: display.surveyId,
workspaceId: display.survey.workspaceId,
responseId: display.response?.id ?? null,
contactId: display.contactId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
throw error;
}
};
export const assertDisplayOwnership = async (
displayId: string,
workspaceId: string,
surveyId: string,
contactId: string | null,
tx?: Prisma.TransactionClient
): Promise<void> => {
const display = await getDisplayForResponseValidation(displayId, tx);
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
if (display.workspaceId !== workspaceId)
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
if (display.surveyId !== surveyId)
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
if (display.contactId !== null && display.contactId !== contactId)
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
};
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
@@ -3,14 +3,18 @@ import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import {
assertDisplayOwnership,
getDisplayCountBySurveyId,
getDisplayForResponseValidation,
getDisplaysByContactId,
getDisplaysBySurveyIdWithContact,
} from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
const mockResponseId = "clqnfg59i000208i426pb4wcv";
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
const mockDisplaysForContact = [
@@ -290,3 +294,96 @@ describe("getDisplaysBySurveyIdWithContact", () => {
});
});
});
const mockDisplayRecord = {
surveyId: mockSurveyId,
contactId: null as string | null,
response: null as { id: string } | null,
survey: { workspaceId: mockWorkspaceId },
};
describe("getDisplayForResponseValidation", () => {
test("returns null when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toBeNull();
});
test("returns mapped shape when display is found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
response: { id: mockResponseId },
} as any);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toEqual({
surveyId: mockSurveyId,
workspaceId: mockWorkspaceId,
responseId: mockResponseId,
contactId: mockContactId,
});
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
vi.mocked(prisma.display.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
});
});
describe("assertDisplayOwnership", () => {
test("throws InvalidInputError when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when workspaceId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when surveyId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when display is already linked to a response", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
response: { id: mockResponseId },
} as any);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when contactId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: "contact-a",
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
).rejects.toThrow(InvalidInputError);
});
test("resolves without error when all ownership checks pass", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
).resolves.toBeUndefined();
});
});
-1
View File
@@ -38,7 +38,6 @@ describe("auth", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+26 -5
View File
@@ -46,6 +46,13 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/hub/service", () => ({
deleteHubTenantData: vi.fn().mockResolvedValue({
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
error: null,
}),
}));
describe("Organization Service", () => {
beforeEach(() => {
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
@@ -73,7 +80,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -126,7 +132,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -179,7 +184,6 @@ describe("Organization Service", () => {
updatedAt: new Date(),
billing: expectedBilling,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -239,7 +243,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
workspaces: [
@@ -281,7 +284,6 @@ describe("Organization Service", () => {
usageCycleAnchor: expect.any(Date),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
@@ -355,6 +357,7 @@ describe("Organization Service", () => {
billing: { stripeCustomerId: "cus_123" },
memberships: [],
workspaces: [],
feedbackDirectories: [],
} as any);
await deleteOrganization("org1");
@@ -363,5 +366,23 @@ describe("Organization Service", () => {
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
}
});
test("should purge Hub-owned data for each feedback directory", async () => {
const { deleteHubTenantData } = await import("@/modules/hub/service");
vi.mocked(prisma.organization.delete).mockResolvedValue({
id: "org1",
name: "Test Org",
billing: null,
memberships: [],
workspaces: [],
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
} as any);
await deleteOrganization("org1");
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
});
+13 -2
View File
@@ -19,6 +19,7 @@ import { updateUser } from "@/lib/user/service";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getWorkspaces } from "@/lib/workspace/service";
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
import { deleteHubTenantData } from "@/modules/hub/service";
import { validateInputs } from "../utils/validate";
export const select = {
@@ -35,7 +36,6 @@ export const select = {
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -74,7 +74,6 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
@@ -294,6 +293,11 @@ export const deleteOrganization = async (organizationId: string) => {
id: true,
},
},
feedbackDirectories: {
select: {
id: true,
},
},
},
});
@@ -301,6 +305,13 @@ export const deleteOrganization = async (organizationId: string) => {
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
await cleanupStripeCustomer(stripeCustomerId);
}
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) for each
// directory tenant. Failures are logged inside the gateway and do not roll back the
// local delete.
for (const directory of deletedOrganization.feedbackDirectories) {
await deleteHubTenantData(directory.id);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+31
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -324,5 +325,35 @@ describe("updateResponse", () => {
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
});
});
+8
View File
@@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -569,6 +570,13 @@ export const updateResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Response", responseId);
}
throw new DatabaseError(error.message);
}
@@ -228,7 +228,6 @@ export const mockOrganizationOutput: TOrganization = {
createdAt: currentDate,
updatedAt: currentDate,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
limits: {
-2
View File
@@ -67,7 +67,6 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
{
id: "org2",
@@ -85,7 +84,6 @@ describe("User Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
@@ -18,6 +18,7 @@ import {
ValidationError,
isExpectedError,
} from "@formbricks/types/errors";
import { RequestBodyTooLargeError } from "@/app/lib/api/request-body";
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
@@ -78,6 +79,7 @@ describe("isExpectedError (shared helper)", () => {
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
"RequestBodyTooLargeError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -97,6 +99,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
{ ErrorClass: RequestBodyTooLargeError, args: [2 * 1024 * 1024] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"no_text_found": "Kein Text gefunden",
"none": "None",
"none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht autorisiert",
@@ -505,6 +506,7 @@
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace": "Arbeitsbereich",
"workspace_created_successfully": "Workspace erfolgreich erstellt",
"workspace_creation_description": "Organisiere Umfragen in Workspaces für eine bessere Zugriffskontrolle.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",
"attribute_key_required": "Schlüssel ist erforderlich",
"attribute_key_reserved_future_default": "Der Schlüssel ist für zukünftige Standardattribute reserviert ({reservedKeys}). Bitte wähle einen anderen Schlüssel.",
"attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen",
"attribute_label": "Bezeichnung",
"attribute_label_placeholder": "z. B. Geburtsdatum",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Persönlichen Link erstellen",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu erstellen.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_csv_reserved_column_names": "Reservierte CSV-Spaltennamen: {columns}. Diese Namen sind für zukünftige Standardattribute ({reservedKeys}) reserviert und können nicht als neue Attribute erstellt werden.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_activity_yet": "Noch keine Aktivität",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
},
"general": {
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
"ai_data_analysis_enabled_description": "KI nutzen, um mehr aus deinen Daten herauszuholen richte Dashboards, Diagramme, Berichte und mehr ein. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks KI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte deine:n Administrator:in, AI_PROVIDER, AI_MODEL und die passenden Provider-Zugangsdaten zu setzen, bevor du KI-Funktionen aktivierst.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "„Umfrage geschlossen“-Nachricht anpassen",
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_theme_in_look_and_feel_settings": "Passe das Theme in den <lookFeelLink>Look & Feel</lookFeelLink> Einstellungen an.",
"ai_data_analysis_disabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_features_not_enabled": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
@@ -3872,6 +3873,75 @@
"value_number": "Wert (Anzahl)",
"value_text": "Wert (Text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Nutze jeden Berührungspunkt, um die Einfachheit der Kundeninteraktion zu verstehen.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"no_text_found": "No text found",
"none": "None",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
@@ -505,6 +506,7 @@
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace": "Workspace",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
"attribute_key_required": "Key is required",
"attribute_key_reserved_future_default": "Key is reserved for future default attributes ({reservedKeys}). Please choose a different key.",
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
"attribute_label": "Label",
"attribute_label_placeholder": "e.g. Date of Birth",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
"invalid_csv_reserved_column_names": "Reserved CSV column name(s): {columns}. These names are reserved for future default attributes ({reservedKeys}) and cannot be created as new attributes.",
"invalid_date_format": "Invalid date format. Please use a valid date.",
"invalid_number_format": "Invalid number format. Please enter a valid number.",
"no_activity_yet": "No activity yet",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Workspaces being granted access"
},
"general": {
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Manage AI-powered features for this organization.",
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_theme_in_look_and_feel_settings": "Adjust the theme in the <lookFeelLink>Look & Feel</lookFeelLink> Settings.",
"ai_data_analysis_disabled": "AI data analysis is disabled for this organization.",
"ai_features_not_enabled": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
@@ -3872,6 +3873,75 @@
"value_number": "Value (Number)",
"value_text": "Value (Text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Leverage every touchpoint to understand ease of customer interaction.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Sin resultados",
"no_surveys_found": "No se encontraron encuestas.",
"no_text_found": "No se encontró texto",
"none": "None",
"none_of_the_above": "Ninguna de las anteriores",
"not_authenticated": "No estás autenticado para realizar esta acción.",
"not_authorized": "No autorizado",
@@ -505,6 +506,7 @@
"website_survey": "Encuesta de sitio web",
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Workflows",
"workspace": "Espacio de trabajo",
"workspace_created_successfully": "Espacio de trabajo creado correctamente",
"workspace_creation_description": "Organiza las encuestas en espacios de trabajo para un mejor control de acceso.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",
"attribute_key_required": "La clave es obligatoria",
"attribute_key_reserved_future_default": "La clave está reservada para atributos predeterminados futuros ({reservedKeys}). Por favor, elige una clave diferente.",
"attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Generar enlace personal",
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
"invalid_csv_reserved_column_names": "Nombre(s) de columna CSV reservado(s): {columns}. Estos nombres están reservados para atributos predeterminados futuros ({reservedKeys}) y no se pueden crear como nuevos atributos.",
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
"no_activity_yet": "Aún no hay actividad",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
"ai_data_analysis_disabled": "El análisis de datos con IA está deshabilitado para esta organización.",
"ai_features_not_enabled": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
@@ -3872,6 +3873,75 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Aprovecha cada punto de contacto para entender la facilidad de interacción del cliente.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
"no_text_found": "Aucun texte trouvé",
"none": "None",
"none_of_the_above": "Aucun des éléments ci-dessus",
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
"not_authorized": "Non autorisé",
@@ -505,6 +506,7 @@
"website_survey": "Sondage de site web",
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace": "Espace de travail",
"workspace_created_successfully": "Espace de travail créé avec succès",
"workspace_creation_description": "Organise tes enquêtes dans des espaces de travail pour un meilleur contrôle d'accès.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",
"attribute_key_required": "La clé est requise",
"attribute_key_reserved_future_default": "La clé est réservée pour les attributs par défaut futurs ({reservedKeys}). Veuillez choisir une clé différente.",
"attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr: uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre",
"attribute_label": "Étiquette",
"attribute_label_placeholder": "ex. Date de naissance",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
"invalid_csv_reserved_column_names": "Nom(s) de colonne CSV réservé(s) : {columns}. Ces noms sont réservés pour les attributs par défaut futurs ({reservedKeys}) et ne peuvent pas être créés en tant que nouveaux attributs.",
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
"no_activity_yet": "Aucune activité pour le moment",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Espaces de travail en cours d'ajout"
},
"general": {
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
"ai_enabled": "IA Formbricks",
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
"ai_instance_not_configured": "L'IA est configurée au niveau de l'instance via des variables d'environnement. Demandez à votre administrateur de définir AI_PROVIDER, les identifiants du fournisseur et la liste de modèles correspondante avant d'activer les fonctionnalités d'IA.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
"adjust_theme_in_look_and_feel_settings": "Ajuste le thème dans les paramètres <lookFeelLink>Apparence et ressenti</lookFeelLink>.",
"ai_data_analysis_disabled": "L'analyse de données par IA est désactivée pour cette organisation.",
"ai_features_not_enabled": "Les fonctionnalités IA ne sont pas activées pour cette organisation.",
"ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.",
"ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.",
@@ -3872,6 +3873,75 @@
"value_number": "Valeur (Nombre)",
"value_text": "Valeur (texte)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Tirez parti de chaque point de contact pour comprendre la facilité d'interaction avec le client.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Nincs találat",
"no_surveys_found": "Nem találhatók kérdőívek.",
"no_text_found": "Nem található szöveg",
"none": "None",
"none_of_the_above": "A fentiek közül egyik sem",
"not_authenticated": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.",
"not_authorized": "Nincs felhatalmazva",
@@ -505,6 +506,7 @@
"website_survey": "Webhely kérdőív",
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workflows": "Workflows",
"workspace": "Munkaterület",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók. Betűvel kell kezdődnie.",
"attribute_key_placeholder": "például: szuletesi_ido",
"attribute_key_required": "A kulcs kötelező",
"attribute_key_reserved_future_default": "A kulcs le van foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}). Kérem, válasszon egy másik kulcsot.",
"attribute_key_safe_identifier_required": "A kulcs csak biztonságos azonosító lehet: csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók, és betűvel kell kezdődnie",
"attribute_label": "Címke",
"attribute_label_placeholder": "például: Születési idő",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Személyes hivatkozás előállítása",
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
"invalid_csv_reserved_column_names": "Fenntartott CSV oszlopnév/nevek: {columns}. Ezek a nevek le vannak foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}), és nem hozhatók létre új attribútumokként.",
"invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.",
"no_activity_yet": "Még nincs tevékenység",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
},
"general": {
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
"ai_data_analysis_disabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
"ai_features_not_enabled": "Az AI funkciók nincsenek engedélyezve ezen szervezet számára.",
"ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.",
"ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.",
@@ -3872,6 +3873,75 @@
"value_number": "Érték (szám)",
"value_text": "Érték (szöveg)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Minden érintkezési pont kihasználása az ügyfelekkel való interakció egyszerűségének megértéséhez.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
"no_text_found": "テキストが見つかりません",
"none": "None",
"none_of_the_above": "いずれも該当しません",
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
@@ -505,6 +506,7 @@
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "Workflows",
"workspace": "ワークスペース",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth",
"attribute_key_required": "キーは必須です",
"attribute_key_reserved_future_default": "このキーは将来のデフォルト属性用に予約されています({reservedKeys})。別のキーを選択してください。",
"attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります",
"attribute_label": "ラベル",
"attribute_label_placeholder": "例: 生年月日",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
"invalid_csv_reserved_column_names": "予約されたCSV列名: {columns}。これらの名前は将来のデフォルト属性({reservedKeys})用に予約されており、新しい属性として作成できません。",
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
"no_activity_yet": "まだアクティビティがありません",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "アクセス権が付与されるワークスペース"
},
"general": {
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "この組織のAI機能を管理します。",
"ai_instance_not_configured": "AI は環境変数を使ってインスタンスレベルで設定されます。AI 機能を有効にする前に、管理者に AI_PROVIDER、このプロバイダーの認証情報、および対応するモデル一覧を設定してもらってください。",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "「フォームはクローズしました」メッセージを調整",
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
"ai_data_analysis_disabled": "この組織ではAIデータ分析が無効になっています。",
"ai_features_not_enabled": "この組織ではAI機能が有効になっていません。",
"ai_instance_not_configured": "AIが設定されていません。管理者にお問い合わせください。",
"ai_smart_tools_disabled": "この組織ではAIスマートツールが無効になっています。",
@@ -3872,6 +3873,75 @@
"value_number": "値(数値)",
"value_text": "値 (テキスト)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "あらゆるタッチポイントを活用して、顧客のインタラクションの容易さを把握します。",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Geen resultaten",
"no_surveys_found": "Geen enquêtes gevonden.",
"no_text_found": "Geen tekst gevonden",
"none": "None",
"none_of_the_above": "Geen van bovenstaande",
"not_authenticated": "U bent niet geverifieerd om deze actie uit te voeren.",
"not_authorized": "Niet geautoriseerd",
@@ -505,6 +506,7 @@
"website_survey": "Website-enquête",
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace": "Werkruimte",
"workspace_created_successfully": "Werkruimte succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",
"attribute_key_required": "Sleutel is verplicht",
"attribute_key_reserved_future_default": "Sleutel is gereserveerd voor toekomstige standaardattributen ({reservedKeys}). Kies een andere sleutel.",
"attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter",
"attribute_label": "Label",
"attribute_label_placeholder": "bijv. Geboortedatum",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Persoonlijke link genereren",
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
"invalid_csv_reserved_column_names": "Gereserveerde CSV-kolomnaam/namen: {columns}. Deze namen zijn gereserveerd voor toekomstige standaardattributen ({reservedKeys}) en kunnen niet als nieuwe attributen worden aangemaakt.",
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
"no_activity_yet": "Nog geen activiteit",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Werkruimtes die toegang krijgen"
},
"general": {
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
"ai_instance_not_configured": "AI wordt op instantieniveau geconfigureerd via omgevingsvariabelen. Vraag je beheerder om AI_PROVIDER, de inloggegevens voor die provider en de bijbehorende modellenlijst in te stellen voordat AI-functies worden ingeschakeld.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Pas het bericht 'Enquête gesloten' aan",
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
"ai_data_analysis_disabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
"ai_features_not_enabled": "AI-functies zijn niet ingeschakeld voor deze organisatie.",
"ai_instance_not_configured": "AI is niet geconfigureerd. Neem contact op met je beheerder.",
"ai_smart_tools_disabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.",
@@ -3872,6 +3873,75 @@
"value_number": "Waarde (getal)",
"value_text": "Waarde (tekst)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Benut elk contactpunt om inzicht te krijgen in het gemak van klantinteractie.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Nenhum resultado",
"no_surveys_found": "Não foram encontradas pesquisas.",
"no_text_found": "Nenhum texto encontrado",
"none": "None",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Você não está autenticado para realizar essa ação.",
"not_authorized": "Não autorizado",
@@ -505,6 +506,7 @@
"website_survey": "Pesquisa de Site",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Workflows",
"workspace": "Espaço de trabalho",
"workspace_created_successfully": "Workspace criado com sucesso",
"workspace_creation_description": "Organize pesquisas em workspaces para melhor controle de acesso.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolha uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex: Data de nascimento",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Esses nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
"no_activity_yet": "Nenhuma atividade ainda",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Workspaces recebendo acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
"ai_instance_not_configured": "A IA é configurada no nível da instância por meio de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse provedor e a lista de modelos correspondente antes de habilitar os recursos de IA.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Ajustar mensagem 'Pesquisa Encerrada''",
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
"adjust_theme_in_look_and_feel_settings": "Ajuste o tema nas configurações de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desabilitada para esta organização.",
"ai_features_not_enabled": "Os recursos de IA não estão habilitados para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Entre em contato com seu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.",
@@ -3872,6 +3873,75 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Aproveite cada ponto de contato para entender a facilidade de interação do cliente.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Nenhum resultado",
"no_surveys_found": "Nenhum inquérito encontrado.",
"no_text_found": "Nenhum texto encontrado",
"none": "None",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Não está autenticado para realizar esta ação.",
"not_authorized": "Não autorizado",
@@ -505,6 +506,7 @@
"website_survey": "Inquérito do Website",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Workflows",
"workspace": "Espaço de trabalho",
"workspace_created_successfully": "Espaço de trabalho criado com sucesso",
"workspace_creation_description": "Organiza inquéritos em espaços de trabalho para um melhor controlo de acesso.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",
"attribute_key_required": "A chave é obrigatória",
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolhe uma chave diferente.",
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra",
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex. Data de nascimento",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Estes nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
"no_activity_yet": "Ainda sem atividade",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Workspaces a receber acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
"ai_enabled": "IA da Formbricks",
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
"ai_instance_not_configured": "A IA é configurada ao nível da instância através de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse fornecedor e a lista de modelos correspondente antes de ativar as funcionalidades de IA.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Ajustar mensagem de 'Inquérito Fechado'",
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
"adjust_theme_in_look_and_feel_settings": "Ajusta o tema nas definições de <lookFeelLink>Aparência</lookFeelLink>.",
"ai_data_analysis_disabled": "A análise de dados por IA está desativada para esta organização.",
"ai_features_not_enabled": "As funcionalidades de IA não estão ativadas para esta organização.",
"ai_instance_not_configured": "A IA não está configurada. Contacta o teu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.",
@@ -3872,6 +3873,75 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Aproveite todos os pontos de contato para entender a facilidade de interação do cliente.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Nicio rezultat",
"no_surveys_found": "Nu au fost găsite sondaje.",
"no_text_found": "Niciun text găsit",
"none": "None",
"none_of_the_above": "Niciuna dintre cele de mai sus",
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
"not_authorized": "Neautorizat",
@@ -505,6 +506,7 @@
"website_survey": "Chestionar despre site",
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace": "Spațiu de lucru",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",
"attribute_key_required": "Cheia este obligatorie",
"attribute_key_reserved_future_default": "Cheia este rezervată pentru atribute implicite viitoare ({reservedKeys}). Te rugăm să alegi o cheie diferită.",
"attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă",
"attribute_label": "Etichetă",
"attribute_label_placeholder": "ex: Data nașterii",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Generează link personal",
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
"invalid_csv_reserved_column_names": "Nume de coloană CSV rezervate: {columns}. Aceste nume sunt rezervate pentru atribute implicite viitoare ({reservedKeys}) și nu pot fi create ca atribute noi.",
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
"no_activity_yet": "Nicio activitate încă",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
},
"general": {
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
"ai_instance_not_configured": "AI este configurată la nivel de instanță prin variabile de mediu. Cere administratorului să configureze AI_PROVIDER, credențialele acelui furnizor și lista de modele corespunzătoare înainte de a activa funcționalitățile AI.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'",
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
"adjust_theme_in_look_and_feel_settings": "Ajustează tema în setările <lookFeelLink>Aspect și Experiență</lookFeelLink>.",
"ai_data_analysis_disabled": "Analiza de date AI este dezactivată pentru această organizație.",
"ai_features_not_enabled": "Funcțiile AI nu sunt activate pentru această organizație.",
"ai_instance_not_configured": "AI nu este configurat. Contactează administratorul.",
"ai_smart_tools_disabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
@@ -3872,6 +3873,75 @@
"value_number": "Valoare (număr)",
"value_text": "Valoare (Text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Valorificați fiecare punct de contact pentru a înțelege ușurința interacțiunilor cu clienții.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Нет результатов",
"no_surveys_found": "Опросы не найдены.",
"no_text_found": "Текст не найден",
"none": "None",
"none_of_the_above": "Ничего из вышеперечисленного",
"not_authenticated": "У вас нет прав для выполнения этого действия.",
"not_authorized": "Нет доступа",
@@ -505,6 +506,7 @@
"website_survey": "Опрос сайта",
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Workflows",
"workspace": "Рабочее пространство",
"workspace_created_successfully": "Рабочее пространство успешно создано",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth",
"attribute_key_required": "Ключ обязателен",
"attribute_key_reserved_future_default": "Ключ зарезервирован для будущих атрибутов по умолчанию ({reservedKeys}). Пожалуйста, выбери другой ключ.",
"attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы",
"attribute_label": "Метка",
"attribute_label_placeholder": "например, дата рождения",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Сгенерировать персональную ссылку",
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
"invalid_csv_reserved_column_names": "Зарезервированные названия столбцов CSV: {columns}. Эти названия зарезервированы для будущих атрибутов по умолчанию ({reservedKeys}) и не могут быть созданы как новые атрибуты.",
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
"no_activity_yet": "Пока нет активности",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
},
"general": {
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
"ai_instance_not_configured": "ИИ настраивается на уровне инстанса через переменные окружения. Попросите администратора настроить AI_PROVIDER, учетные данные этого провайдера и соответствующий список моделей перед включением функций ИИ.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Изменить сообщение «Опрос закрыт»",
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
"ai_data_analysis_disabled": "Анализ данных с помощью ИИ отключён для этой организации.",
"ai_features_not_enabled": "Функции ИИ не включены для этой организации.",
"ai_instance_not_configured": "ИИ не настроен. Свяжись с администратором.",
"ai_smart_tools_disabled": "Умные инструменты ИИ отключены для этой организации.",
@@ -3872,6 +3873,75 @@
"value_number": "Значение (число)",
"value_text": "Значение (текст)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Используйте каждый контакт с клиентом, чтобы понять, насколько легко с вами взаимодействовать.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Inga resultat",
"no_surveys_found": "Inga enkäter hittades.",
"no_text_found": "Ingen text hittades",
"none": "None",
"none_of_the_above": "Inget av ovanstående",
"not_authenticated": "Du är inte autentiserad för att utföra denna åtgärd.",
"not_authorized": "Ej behörig",
@@ -505,6 +506,7 @@
"website_survey": "Webbplatsenkät",
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Workflows",
"workspace": "Arbetsyta",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",
"attribute_key_required": "Nyckel krävs",
"attribute_key_reserved_future_default": "Nyckeln är reserverad för framtida standardattribut ({reservedKeys}). Välj en annan nyckel.",
"attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav",
"attribute_label": "Etikett",
"attribute_label_placeholder": "t.ex. Födelsedatum",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Generera personlig länk",
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
"invalid_csv_reserved_column_names": "Reserverade CSV-kolumnnamn: {columns}. Dessa namn är reserverade för framtida standardattribut ({reservedKeys}) och kan inte skapas som nya attribut.",
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
"no_activity_yet": "Ingen aktivitet än",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
},
"general": {
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
"ai_instance_not_configured": "AI konfigureras på instansnivå via miljövariabler. Be din administratör att ange AI_PROVIDER, autentiseringsuppgifterna för den leverantören och den tillhörande modellistan innan AI-funktioner aktiveras.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "Justera meddelande för 'Enkät stängd'",
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
"ai_data_analysis_disabled": "AI-dataanalys är inaktiverad för den här organisationen.",
"ai_features_not_enabled": "AI-funktioner är inte aktiverade för den här organisationen.",
"ai_instance_not_configured": "AI är inte konfigurerad. Kontakta din administratör.",
"ai_smart_tools_disabled": "AI smarta verktyg är inaktiverade för den här organisationen.",
@@ -3872,6 +3873,75 @@
"value_number": "Värde (antal)",
"value_text": "Värde (text)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Utnyttja varje kontaktpunkt för att förstå hur enkel kundinteraktionen är.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "Sonuç yok",
"no_surveys_found": "Survey bulunamadı.",
"no_text_found": "Metin bulunamadı",
"none": "None",
"none_of_the_above": "Yukarıdakilerin hiçbiri",
"not_authenticated": "Bu işlemi gerçekleştirmek için yetkiniz yok.",
"not_authorized": "Yetkisiz",
@@ -505,6 +506,7 @@
"website_survey": "Web Sitesi Anketi",
"weeks": "hafta",
"welcome_card": "Karşılama kartı",
"workflows": "Workflows",
"workspace": "Çalışma Alanı",
"workspace_created_successfully": "Workspace başarıyla oluşturuldu",
"workspace_creation_description": "Daha iyi erişim kontrolü için survey'leri çalışma alanlarında düzenleyin.",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "Yalnızca küçük harfler, rakamlar ve alt çizgiler. Bir harfle başlamalıdır.",
"attribute_key_placeholder": "örn. dogum_tarihi",
"attribute_key_required": "Anahtar gereklidir",
"attribute_key_reserved_future_default": "Anahtar, gelecekteki varsayılan özellikler için ayrılmıştır ({reservedKeys}). Lütfen farklı bir anahtar seçin.",
"attribute_key_safe_identifier_required": "Anahtar güvenli bir tanımlayıcı olmalıdır: yalnızca küçük harfler, rakamlar ve alt çizgiler içermeli ve bir harfle başlamalıdır",
"attribute_label": "Etiket",
"attribute_label_placeholder": "örn. Doğum Tarihi",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "Kişisel Bağlantı Oluştur",
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir anket seç.",
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harf, rakam ve alt çizgi içerebilir ve bir harfle başlamalıdır.",
"invalid_csv_reserved_column_names": "Ayrılmış CSV sütun adı/adları: {columns}. Bu adlar gelecekteki varsayılan özellikler ({reservedKeys}) için ayrılmıştır ve yeni özellik olarak oluşturulamaz.",
"invalid_date_format": "Geçersiz tarih formatı. Lütfen geçerli bir tarih kullanın.",
"invalid_number_format": "Geçersiz sayı formatı. Lütfen geçerli bir sayı girin.",
"no_activity_yet": "Henüz aktivite yok",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "Erişim verilen çalışma alanları"
},
"general": {
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
"ai_data_analysis_enabled_description": "Verilerinden daha fazlasını elde etmek, kontrol panelleri, grafikler, raporlar ve daha fazlasını kurmak için yapay zeka. Deneyim verilerine dokunur.",
"ai_enabled": "Formbricks Yapay Zeka",
"ai_enabled_description": "Bu organizasyon için yapay zeka destekli özellikleri yönet.",
"ai_instance_not_configured": "Yapay zeka, ortam değişkenleri aracılığıyla instance seviyesinde yapılandırılır. Yapay zeka özelliklerini etkinleştirmeden önce yöneticinden AI_PROVIDER, AI_MODEL ve eşleşen sağlayıcı kimlik bilgilerini ayarlamasını iste.",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "\"Anket Kapatıldı\" mesajını düzenle",
"adjust_survey_closed_message_description": "Anket kapalıyken ziyaretçilerin gördüğü mesajı değiştir.",
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
"ai_data_analysis_disabled": "Bu organizasyon için yapay zeka veri analizi devre dışı.",
"ai_features_not_enabled": "Bu organizasyon için yapay zeka özellikleri etkinleştirilmemiş.",
"ai_instance_not_configured": "Yapay zeka yapılandırılmamış. Yöneticinle iletişime geç.",
"ai_smart_tools_disabled": "Bu organizasyon için yapay zeka akıllı araçları devre dışı.",
@@ -3872,6 +3873,75 @@
"value_number": "Değer (Sayı)",
"value_text": "Değer (Metin)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "CES",
"ces_description": "Müşteri etkileşiminin kolaylığını anlamak için her temas noktasından yararlanın.",
+73 -3
View File
@@ -333,6 +333,7 @@
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
"no_text_found": "未找到文本",
"none": "None",
"none_of_the_above": "以上 都 不 是",
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
@@ -505,6 +506,7 @@
"website_survey": "网站 调查",
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "Workflows",
"workspace": "工作区",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
@@ -1935,6 +1937,7 @@
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如:date_of_birth",
"attribute_key_required": "键为必填项",
"attribute_key_reserved_future_default": "该键已保留用于未来的默认属性({reservedKeys})。请选择其他键。",
"attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头",
"attribute_label": "标签",
"attribute_label_placeholder": "例如:出生日期",
@@ -1969,6 +1972,7 @@
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
"invalid_csv_reserved_column_names": "CSV 列名已被保留:{columns}。这些名称已保留用于未来的默认属性({reservedKeys}),无法创建为新属性。",
"invalid_date_format": "日期格式无效。请使用有效日期。",
"invalid_number_format": "数字格式无效。请输入有效的数字。",
"no_activity_yet": "暂无活动",
@@ -2610,8 +2614,6 @@
"workspaces_being_added": "将被授权访问的工作区"
},
"general": {
"ai_data_analysis_enabled": "数据增强与分析(AI",
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
"ai_instance_not_configured": "AI 通过环境变量在实例级别进行配置。启用 AI 功能前,请让管理员设置 AI_PROVIDER、该提供商的凭据以及对应的模型列表。",
@@ -2818,7 +2820,6 @@
"adjust_survey_closed_message": "调整 \"调查 关闭\" 消息",
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
"ai_data_analysis_disabled": "此组织已禁用 AI 数据分析。",
"ai_features_not_enabled": "此组织未启用 AI 功能。",
"ai_instance_not_configured": "AI 未配置。请联系您的管理员。",
"ai_smart_tools_disabled": "此组织已禁用 AI 智能工具。",
@@ -3872,6 +3873,75 @@
"value_number": "值(数量)",
"value_text": "值(文本)"
},
"workflows": {
"any_completed_response": "Any completed response in this workspace",
"any_survey": "Any survey",
"back_to_workflows": "Back to workflows",
"builder": "Builder",
"collapse_node_config": "Collapse node configuration",
"condition_left_path": "Condition data path",
"condition_right_value": "Condition value",
"create_failed": "Could not create workflow",
"create_success": "Workflow created",
"create_workflow": "Create workflow",
"create_workflow_description": "Enter a name and optional description for your workflow.",
"disable": "Disable",
"disabled": "Workflow disabled",
"email_preview_summary": "Preview email to {to}. No message will be sent.",
"email_subject": "Email subject",
"email_to": "Email recipient",
"enable": "Enable",
"enabled": "Workflow enabled",
"error": "Error",
"expand_node_config": "Expand node configuration",
"if_else": "If/Else",
"if_else_summary": "Selects the next path from the response payload.",
"last_run": "Last run",
"lifecycle_failed": "Could not update workflow status",
"load_failed": "Could not load workflow",
"no_runs": "No workflow runs yet.",
"no_workflows": "No workflows yet",
"no_workflows_description": "Create a PoC workflow that reacts to completed responses without sending real emails or webhooks.",
"node_config": "Node configuration",
"please_enter_name": "Please enter a workflow name",
"reorganize": "Reorganize",
"response": "Response",
"response_completed": "Response Completed",
"run": "Run",
"run_detail": "Run detail",
"run_status": {
"canceled": "Canceled",
"completed": "Completed",
"failed": "Failed",
"queued": "Queued",
"running": "Running"
},
"runs": "Runs",
"save_failed": "Could not save workflow",
"saved": "Workflow saved",
"select_node": "Select a node to configure it.",
"send_email_preview": "Send Email Preview",
"send_webhook_preview": "Send Webhook Preview",
"snap_to_canvas": "Snap to canvas",
"status": {
"disabled": "Disabled",
"draft": "Draft",
"enabled": "Enabled"
},
"step_outputs": "Step outputs",
"survey_trigger_summary": "Responses completed for survey {surveyId}",
"trigger_payload": "Trigger payload",
"validation_failed": "Workflow definition is invalid",
"webhook_preview_note": "The PoC records this preview envelope only. It does not make an outbound request.",
"webhook_preview_summary": "Preview webhook envelope for {url}. No request will be sent.",
"webhook_url": "Webhook URL",
"workflow": "Workflow",
"workflow_description_optional": "Description (Optional)",
"workflow_description_placeholder": "Workflow description",
"workflow_name": "Workflow Name",
"workflow_name_placeholder": "Workflow name",
"workflow_runs": "Workflow Runs"
},
"xm-templates": {
"ces": "客户努力评分",
"ces_description": "利用 每个 接触点 来 了解 客户 互动 的 轻松 程度",

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