From 064e47ff59a5121ba297025eb7b56356c1023a04 Mon Sep 17 00:00:00 2001 From: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:15:40 +0530 Subject: [PATCH 1/5] docs(unify-feedback): add Enrichment page and promote MCP to a top-level nav tab (#8642) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Johannes --- docs/docs.json | 15 ++-- docs/unify-feedback/enrichment.mdx | 115 +++++++++++++++++++++++++++++ docs/unify-feedback/overview.mdx | 8 +- 3 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 docs/unify-feedback/enrichment.mdx diff --git a/docs/docs.json b/docs/docs.json index c819ba866fed..54a7794a484c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -86,11 +86,6 @@ "platform/features/user-management/two-factor-auth" ] }, - { - "group": "MCP", - "icon": "robot", - "pages": ["platform/mcp/overview", "platform/mcp/setup"] - }, "platform/features/styling-theme", "platform/features/email-customization" ] @@ -235,6 +230,7 @@ "unify-feedback/feedback-datasets", "unify-feedback/feedback-sources", "unify-feedback/feedback-records", + "unify-feedback/enrichment", "unify-feedback/dashboards-charts", "unify-feedback/topics-subtopics" ] @@ -430,6 +426,15 @@ ], "tab": "Development" }, + { + "groups": [ + { + "group": "MCP", + "pages": ["platform/mcp/overview", "platform/mcp/setup"] + } + ], + "tab": "MCP" + }, { "groups": [ { diff --git a/docs/unify-feedback/enrichment.mdx b/docs/unify-feedback/enrichment.mdx new file mode 100644 index 000000000000..faa575cae63f --- /dev/null +++ b/docs/unify-feedback/enrichment.mdx @@ -0,0 +1,115 @@ +--- +title: "Enrichment" +description: "AI enrichment that adds sentiment, emotions, and a consolidated language to open-text Feedback Records." +icon: "sparkles" +--- + +Enrichment automatically adds AI-generated context to your open-text feedback. When a Feedback Record has a text answer (`value_text`), Formbricks analyzes it and attaches three kinds of enrichment: + +- **Sentiment**: how positive or negative the feedback is. +- **Emotions**: which emotions the text expresses. +- **Language consolidation**: a translated copy of the text in one common language, so feedback written in different languages can be compared and analyzed together. + +These fields turn free text, which is normally hard to count, into structured signals you can filter, chart, and trend over time. + +## How enrichment works + +Enrichment is **automatic and per-record**. Individual records are never triggered or configured by hand; the only setting is the translation target language, which is configured once at the service level (see [Language consolidation](#language-consolidation)): + +1. A Feedback Record is ingested with a text answer in `value_text`. +2. Formbricks enriches the record **asynchronously**. The enrichment fields are absent until processing finishes, so a record may show as **Not enriched** for a short time after it arrives. +3. Once complete, the sentiment, emotion, and translation fields appear on the record and in charts. + +Only records with open text receive AI enrichment. Records that only carry a rating, NPS score, or categorical choice have no text to analyze. Categorical answers are still consolidated across languages through their **Value ID**, which is not AI enrichment. See [Consolidating the same option across languages](#consolidating-the-same-option-across-languages). + + + Enrichment fields are read-only. Editing a record's `value_text` clears its + enrichment and re-queues it for processing. Editing a record's `language` + re-runs only the translation. + + +## Sentiment enrichment + +Sentiment classifies each text answer on a polarity scale and assigns a signed score. + +**Sentiment label** is one of: + +| Label | Meaning | +| --- | --- | +| **Very positive** | Strongly positive feedback | +| **Positive** | Positive feedback | +| **Neutral** | Neither positive nor negative | +| **Negative** | Negative feedback | +| **Very negative** | Strongly negative feedback | +| **Mixed** | Contains both positive and negative signals | + +**Sentiment score** is a number from **-1.0** (very negative) to **1.0** (very positive). `Mixed` records score near 0. + +Where you see it: + +- **Record detail drawer**, under the **AI enrichment** section, e.g. `Positive (0.82)`. +- **Topics & Subtopics**, as a colored sentiment badge on each record card. +- **Charts**, where you can group by **Sentiment** or chart the average **Sentiment Score**. Records that haven't been enriched yet fall into a **Not enriched** bucket. + +## Emotion enrichment + +Emotion enrichment detects which emotions a text answer expresses. It is **multi-label**: a single record can carry several emotions at once (or none, before it's enriched). + +The six recognized emotions are: + +- **Joy** +- **Anger** +- **Sadness** +- **Fear** +- **Surprise** +- **Disgust** + +Where you see it: + +- **Record detail drawer**, listed in the **AI enrichment** section. +- **Topics & Subtopics**, as one colored badge per detected emotion on each record card. +- **Charts**, where you can chart the count of each emotion. + + + Because emotions are a multi-label set, filter charts on a single emotion + using the **contains** operator rather than an exact-match equals. + + +## Language consolidation + +Feedback often arrives in many languages, which makes it hard to read and analyze together. Language consolidation solves this in two ways. + +### Translation into a common language + +Every open-text answer is translated into your organization's configured target language and stored alongside the original: + +- The **translated text** is shown throughout Unify Feedback in place of the original, with a **Translated to {language}** chip. The original text is always available in the tooltip and in the record detail drawer. +- In **Topics & Subtopics**, a **Comment language** toggle lets you switch between the original and the translated text. + +This means an analyst working in English can read, cluster, and chart feedback that was originally submitted in German, French, Spanish, and more, all consolidated into one comparable language. + + + The target language is set at the service level for your organization. On + Formbricks Cloud it is managed for you; self-hosted instances configure it in + the Hub service. See [Formbricks Hub](/unify-feedback/formbricks-hub). + + +### Consolidating the same option across languages + +For multiple-choice answers, Formbricks also carries a stable **Value ID**, the option's identifier from the source system (for example, a survey choice id). This lets charts group the **same option** together even when its label was captured in different languages or later edited, instead of splitting it into a separate category per localized label. + +When charting choice questions, group by **Value (Option)** rather than the raw text value to keep categories consolidated across languages. + +## Using enrichment in charts + +All enrichment fields are available in [Dashboards & Charts](/unify-feedback/dashboards-charts) as dimensions and measures. Records that haven't finished enriching are grouped under a **Not enriched** value, so you can see enrichment coverage at a glance. + +## Requirements + +- A Feedback Dataset with records that contain open text. +- Enrichment runs on the Formbricks Hub. No local AI provider configuration is required to read enrichment fields. + + + Unify Feedback is an enterprise feature. Enable it on Formbricks Cloud with a + paid plan, or self-host with a license. + diff --git a/docs/unify-feedback/overview.mdx b/docs/unify-feedback/overview.mdx index 001195b5e4df..f4a77a748b1e 100644 --- a/docs/unify-feedback/overview.mdx +++ b/docs/unify-feedback/overview.mdx @@ -15,8 +15,9 @@ Most companies collect feedback in many places: surveys, support tickets, app st 1. **Create a Feedback Dataset.** A dataset is a tenant-scoped bucket for related feedback (for example, "Product Feedback" or "Support 2026"). 2. **Connect Sources.** Pull data from Formbricks surveys, upload CSVs, or push records via the API. 3. **Explore Records.** Browse, filter, edit, and tag individual Feedback Records. -4. **Discover Topics.** Use vector based Topics & Subtopics (Preview) to cluster open-text feedback. -5. **Visualize.** Build Charts and group them on Dashboards to share insights with your team. +4. **Enrich.** Open-text feedback is automatically enriched with sentiment, emotions, and a consolidated language. +5. **Discover Topics.** Use vector-based Topics & Subtopics (Preview) to cluster open-text feedback. +6. **Visualize.** Build Charts and group them on Dashboards to share insights with your team. @@ -28,6 +29,9 @@ Most companies collect feedback in many places: surveys, support tickets, app st Connectors that bring data into a dataset. + + Sentiment, emotions, and language consolidation on open text. + Visualize and share feedback insights. From 1ad3235272f382c21d379a94a8930059fc7078e4 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:40:35 +0200 Subject: [PATCH 2/5] feat: formbricks workflows - epic PR (#8281) Co-authored-by: Dhruwang Co-authored-by: Tiago Farto Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com> Co-authored-by: Johannes --- .github/workflows/integration-tests.yml | 17 +- apps/web/Dockerfile | 16 +- .../components/MainNavigation.tsx | 15 + .../[workspaceId]/workflows/(list)/layout.tsx | 46 + .../workflows/(list)/loading.tsx | 1 + .../[workspaceId]/workflows/(list)/page.tsx | 25 + .../(list)/query-client-provider.tsx | 14 + .../workflows/(list)/runs/loading.tsx | 1 + .../workflows/(list)/runs/page.tsx | 8 + .../[workflowId]/(detail)/layout.tsx | 54 + .../[workflowId]/(detail)/loading.tsx | 1 + .../workflows/[workflowId]/(detail)/page.tsx | 34 + .../[workflowId]/(detail)/runs/loading.tsx | 1 + .../[workflowId]/(detail)/runs/page.tsx | 10 + .../[workspaceId]/workflows/layout.tsx | 8 + .../workflows/query-client-provider.tsx | 10 + .../oauth-protected-resource/route.test.ts | 2 + apps/web/app/api/mcp/route.test.ts | 74 +- .../workflows/[workflowId]/archive/route.ts | 19 + .../workflows/[workflowId]/disable/route.ts | 19 + .../workflows/[workflowId]/duplicate/route.ts | 20 + .../v3/workflows/[workflowId]/enable/route.ts | 19 + .../api/v3/workflows/[workflowId]/route.ts | 45 + .../v3/workflows/[workflowId]/test/route.ts | 19 + .../workflows/[workflowId]/unarchive/route.ts | 19 + .../app/api/v3/workflows/lib/context.test.ts | 257 ++ apps/web/app/api/v3/workflows/lib/context.ts | 148 + apps/web/app/api/v3/workflows/route.ts | 26 + .../api/v3/workflows/runs/[runId]/route.ts | 20 + apps/web/app/api/v3/workflows/runs/route.ts | 17 + apps/web/i18n.lock | 148 +- apps/web/instrumentation-jobs.test.ts | 94 + apps/web/instrumentation-jobs.ts | 45 + apps/web/integration/gen-boolean-client.mjs | 84 +- apps/web/lib/jobs/pool-exhaustion.test.ts | 35 + apps/web/lib/jobs/pool-exhaustion.ts | 24 + apps/web/lib/localStorage.ts | 1 + apps/web/lib/organization/service.test.ts | 52 + apps/web/lib/organization/service.ts | 39 + apps/web/lib/utils/email.ts | 8 + apps/web/locales/de-DE.json | 152 +- apps/web/locales/en-US.json | 152 +- apps/web/locales/es-ES.json | 152 +- apps/web/locales/fr-FR.json | 152 +- apps/web/locales/hu-HU.json | 152 +- apps/web/locales/ja-JP.json | 152 +- apps/web/locales/nl-NL.json | 152 +- apps/web/locales/pt-BR.json | 152 +- apps/web/locales/pt-PT.json | 152 +- apps/web/locales/ro-RO.json | 152 +- apps/web/locales/ru-RU.json | 152 +- apps/web/locales/sv-SE.json | 152 +- apps/web/locales/tr-TR.json | 152 +- apps/web/locales/zh-Hans-CN.json | 152 +- apps/web/locales/zh-Hant-TW.json | 152 +- .../modules/auth/lib/mcp-oauth-dcr.test.ts | 8 +- .../auth/lib/mcp-oauth-provider-options.ts | 20 +- .../auth/lib/oauth-client-metadata.test.ts | 2 + .../modules/auth/lib/oauth-client-metadata.ts | 4 + apps/web/modules/auth/lib/oauth-urls.ts | 4 + .../web/modules/billing/lib/stripe-catalog.ts | 1 + .../modules/ee/audit-logs/types/audit-log.ts | 1 + .../ee/billing/components/plan-comparison.tsx | 5 + .../ee/billing/components/pricing-table.tsx | 1 + .../modules/ee/billing/lib/metering.test.ts | 64 + apps/web/modules/ee/billing/lib/metering.ts | 38 + .../billing/lib/organization-billing.test.ts | 74 +- .../ee/billing/lib/organization-billing.ts | 15 + .../lib/stripe-billing-catalog.test.ts | 47 + .../ee/billing/lib/stripe-billing-catalog.ts | 8 + .../ee/license-check/lib/utils.test.ts | 29 +- .../web/modules/ee/license-check/lib/utils.ts | 13 +- .../components/canvas/add-button-edge.tsx | 28 + .../components/canvas/canvas-controls.tsx | 109 + .../canvas/workflow-add-trigger-picker.tsx | 87 + .../canvas/workflow-canvas-node.tsx | 175 ++ .../components/canvas/workflow-canvas.css | 20 + .../components/canvas/workflow-canvas.tsx | 285 ++ .../canvas/workflow-validation-status.tsx | 90 + .../inspector/workflow-email-action-form.tsx | 328 ++ .../workflow-email-recipient-field.tsx | 143 + .../components/inspector/workflow-field.tsx | 62 + .../inspector/workflow-inspector-panel.tsx | 45 + .../inspector/workflow-node-config-panel.tsx | 101 + .../inspector/workflow-trigger-form.tsx | 243 ++ .../components/runs/run-json-code.tsx | 23 + .../components/runs/run-json-section.tsx | 14 + .../components/runs/run-steps-body.tsx | 42 + .../components/runs/run-summary-section.tsx | 62 + .../runs/workflow-run-detail-drawer.tsx | 82 + .../components/runs/workflow-run-steps.tsx | 63 + .../components/runs/workflow-runs-table.tsx | 155 + .../workflow-auto-save-indicator.tsx | 97 + .../components/workflow-editor-provider.tsx | 12 + .../workflow-email-authoring-context.tsx | 23 + .../components/workflow-filter-dropdown.tsx | 73 + .../components/workflow-header-cta.tsx | 175 ++ .../components/workflow-list-actions.tsx | 218 ++ .../components/workflow-page-title.tsx | 132 + .../workflow-secondary-navigation.tsx | 36 + .../components/workflow-sort-dropdown.tsx | 65 + .../components/workflow-status-pill.tsx | 18 + .../workflow-validation-problems-dialog.tsx | 118 + .../components/workflows-empty-state.tsx | 29 + .../components/workflows-upgrade-prompt.tsx | 38 + .../workspace-workflows-header-cta.tsx | 65 + ...rkspace-workflows-secondary-navigation.tsx | 34 + .../modules/ee/workflows/hooks/test-utils.ts | 17 + .../hooks/use-archive-workflow.test.ts | 114 + .../workflows/hooks/use-archive-workflow.ts | 33 + .../hooks/use-create-workflow.test.ts | 78 + .../ee/workflows/hooks/use-create-workflow.ts | 17 + .../hooks/use-debounced-value.test.ts | 41 + .../ee/workflows/hooks/use-debounced-value.ts | 14 + .../hooks/use-delete-workflow.test.ts | 105 + .../ee/workflows/hooks/use-delete-workflow.ts | 33 + .../hooks/use-duplicate-workflow.test.ts | 47 + .../workflows/hooks/use-duplicate-workflow.ts | 17 + ...use-reconcile-trigger-ending-cards.test.ts | 182 ++ .../use-reconcile-trigger-ending-cards.ts | 69 + .../hooks/use-unarchive-workflow.test.ts | 45 + .../workflows/hooks/use-unarchive-workflow.ts | 21 + .../hooks/use-workflow-builder.test.ts | 734 +++++ .../workflows/hooks/use-workflow-builder.ts | 466 +++ .../use-workflow-node-field-focus.test.ts | 92 + .../hooks/use-workflow-node-field-focus.ts | 69 + .../hooks/use-workflow-node-url-sync.test.ts | 114 + .../hooks/use-workflow-node-url-sync.ts | 77 + .../workflows/hooks/use-workflow-run.test.ts | 87 + .../ee/workflows/hooks/use-workflow-run.ts | 16 + .../workflows/hooks/use-workflow-runs.test.ts | 122 + .../ee/workflows/hooks/use-workflow-runs.ts | 40 + .../ee/workflows/hooks/use-workflows.test.ts | 103 + .../ee/workflows/hooks/use-workflows.ts | 48 + .../ee/workflows/lib/api-client.test.ts | 386 +++ .../modules/ee/workflows/lib/api-client.ts | 280 ++ .../ee/workflows/lib/api-error.test.ts | 62 + .../web/modules/ee/workflows/lib/api-error.ts | 34 + .../web/modules/ee/workflows/lib/auth.test.ts | 96 + apps/web/modules/ee/workflows/lib/auth.ts | 29 + .../ee/workflows/lib/bound-survey.test.ts | 50 + .../modules/ee/workflows/lib/bound-survey.ts | 20 + .../ee/workflows/lib/default-workflow.test.ts | 31 + .../ee/workflows/lib/default-workflow.ts | 11 + .../workflows/lib/definition-to-flow.test.ts | 197 ++ .../ee/workflows/lib/definition-to-flow.ts | 208 ++ .../modules/ee/workflows/lib/display.test.ts | 273 ++ apps/web/modules/ee/workflows/lib/display.ts | 157 + .../lib/email-authoring-context.test.ts | 129 + .../workflows/lib/email-authoring-context.ts | 87 + .../ee/workflows/lib/format-date.test.ts | 56 + .../modules/ee/workflows/lib/format-date.ts | 58 + .../ee/workflows/lib/list-filters.test.ts | 52 + .../modules/ee/workflows/lib/list-filters.ts | 34 + .../ee/workflows/lib/node-registry.test.ts | 97 + .../modules/ee/workflows/lib/node-registry.ts | 86 + .../modules/ee/workflows/lib/query.test.ts | 143 + apps/web/modules/ee/workflows/lib/query.ts | 72 + .../ee/workflows/lib/run-display.test.ts | 73 + .../modules/ee/workflows/lib/run-display.ts | 27 + .../ee/workflows/lib/runner/dispatch.test.ts | 18 + .../ee/workflows/lib/runner/dispatch.ts | 22 + .../enqueue-response-completed-runs.test.ts | 331 ++ .../runner/enqueue-response-completed-runs.ts | 322 ++ .../lib/runner/mark-dispatched.test.ts | 39 + .../workflows/lib/runner/mark-dispatched.ts | 36 + .../lib/runner/match-workflows.test.ts | 116 + .../workflows/lib/runner/match-workflows.ts | 70 + ...-run-job.render-parity.integration.test.ts | 404 +++ .../runner/process-workflow-run-job.test.ts | 806 +++++ .../lib/runner/process-workflow-run-job.ts | 688 +++++ ...process-workflow-run-reconcile-job.test.ts | 113 + .../process-workflow-run-reconcile-job.ts | 51 + .../lib/runner/reconcile-constants.ts | 32 + .../runner/reconcile-orphaned-runs.test.ts | 138 + .../lib/runner/reconcile-orphaned-runs.ts | 146 + .../reconcile-stuck-running-runs.test.ts | 123 + .../runner/reconcile-stuck-running-runs.ts | 134 + .../lib/trigger-ending-cards.test.ts | 83 + .../ee/workflows/lib/trigger-ending-cards.ts | 64 + .../hooks/use-trigger-survey-picker.test.ts | 161 + .../list/hooks/use-trigger-survey-picker.ts | 138 + .../ee/workflows/list/hooks/use-workflows.ts | 30 + .../modules/ee/workflows/list/lib/query.ts | 18 + .../workflows/list/lib/v3-workflows-client.ts | 53 + apps/web/modules/ee/workflows/loading.tsx | 96 + .../workflows/pages/workflow-builder-page.tsx | 88 + .../ee/workflows/pages/workflow-runs-page.tsx | 39 + .../workflows/pages/workflows-list-page.tsx | 253 ++ .../pages/workspace-workflow-runs-page.tsx | 39 + .../modules/ee/workflows/state/editor.test.ts | 921 ++++++ apps/web/modules/ee/workflows/state/editor.ts | 794 +++++ apps/web/modules/ee/workflows/types.ts | 9 + .../types/email-authoring-context.ts | 16 + apps/web/modules/email/index.tsx | 22 +- .../email/lib/survey-response-email.test.ts | 283 ++ .../email/lib/survey-response-email.ts | 223 ++ .../modules/entitlements/lib/checks.test.ts | 23 +- apps/web/modules/entitlements/lib/checks.ts | 1 + .../entitlements/lib/cloud-provider.test.ts | 13 +- .../entitlements/lib/cloud-provider.ts | 2 + .../lib/self-hosted-provider.test.ts | 29 +- .../entitlements/lib/self-hosted-provider.ts | 5 + apps/web/modules/entitlements/lib/types.ts | 3 + apps/web/modules/mcp/auth.test.ts | 17 +- apps/web/modules/mcp/auth.ts | 3 +- apps/web/modules/mcp/server.ts | 2 + .../modules/mcp/tools/guard-scopes.test.ts | 96 + apps/web/modules/mcp/tools/guard-scopes.ts | 61 +- .../web/modules/mcp/tools/run-mcp-mutation.ts | 63 + apps/web/modules/mcp/tools/surveys.ts | 202 +- .../web/modules/mcp/tools/workflow-schemas.ts | 136 + apps/web/modules/mcp/tools/workflows.test.ts | 541 ++++ apps/web/modules/mcp/tools/workflows.ts | 452 +++ apps/web/modules/mcp/tools/workspaces.test.ts | 15 + apps/web/modules/mcp/tools/workspaces.ts | 26 +- .../lib/process-response-pipeline-job.test.ts | 56 + .../lib/process-response-pipeline-job.ts | 44 +- .../follow-up-action-multi-email-input.tsx | 21 +- .../follow-ups/components/follow-up-modal.tsx | 98 +- .../follow-ups/components/follow-ups-view.tsx | 50 +- .../lib/email-send-to-options.test.ts | 147 + .../follow-ups/lib/email-send-to-options.ts | 89 + .../modules/survey/follow-ups/lib/email.ts | 125 +- .../modules/ui/components/button/index.tsx | 8 +- .../ui/components/card-table/index.tsx | 47 + .../components/editor/components/editor.tsx | 35 +- .../ui/components/editor/styles-editor.css | 4 + .../ui/components/page-header/index.tsx | 2 +- .../modules/ui/components/textarea/index.tsx | 22 + apps/web/modules/ui/globals.css | 3 +- apps/web/next.config.mjs | 2 +- apps/web/package.json | 4 + apps/web/vitestSetup.ts | 9 + .../.redocly.lint-ignore.yaml | 10 + docs/api-v3-reference/openapi.yml | 2649 +++++++++++++---- .../components/parameters/WorkflowIdPath.yml | 7 + .../parameters/WorkflowRunIdPath.yml | 7 + .../responses/WorkflowResourceResponse.yml | 19 + .../schemas/CreateWorkflowRequest.yml | 26 + .../schemas/DuplicateWorkflowRequest.yml | 10 + .../src/components/schemas/InvalidParam.yml | 17 +- .../schemas/PatchWorkflowRequest.yml | 23 + .../src/components/schemas/Problem.yml | 3 + .../components/schemas/WorkflowDefinition.yml | 68 + .../src/components/schemas/WorkflowEdge.yml | 28 + .../components/schemas/WorkflowListItem.yml | 84 + .../src/components/schemas/WorkflowNodeUi.yml | 19 + .../components/schemas/WorkflowResource.yml | 13 + .../WorkflowResponseCompletedTriggerNode.yml | 47 + .../components/schemas/WorkflowRunData.yml | 20 + .../schemas/WorkflowRunListItem.yml | 17 + .../src/components/schemas/WorkflowRunLog.yml | 58 + .../schemas/WorkflowRunLogStatus.yml | 8 + .../schemas/WorkflowRunResource.yml | 51 + .../components/schemas/WorkflowRunStatus.yml | 18 + .../schemas/WorkflowRunStepResult.yml | 32 + .../components/schemas/WorkflowRunSummary.yml | 95 + .../schemas/WorkflowSendEmailActionConfig.yml | 80 + .../schemas/WorkflowSendEmailActionNode.yml | 31 + .../src/components/schemas/WorkflowStatus.yml | 26 + .../components/schemas/WorkflowTestResult.yml | 43 + .../schemas/WorkflowTriggerPayload.yml | 38 + docs/api-v3-reference/src/openapi.yml | 81 +- .../src/paths/api_v3_workflows.yml | 224 ++ .../src/paths/api_v3_workflows_runs.yml | 100 + .../paths/api_v3_workflows_runs_{runId}.yml | 66 + .../paths/api_v3_workflows_{workflowId}.yml | 166 ++ .../api_v3_workflows_{workflowId}_archive.yml | 41 + .../api_v3_workflows_{workflowId}_disable.yml | 34 + ...pi_v3_workflows_{workflowId}_duplicate.yml | 64 + .../api_v3_workflows_{workflowId}_enable.yml | 64 + .../api_v3_workflows_{workflowId}_test.yml | 92 + ...pi_v3_workflows_{workflowId}_unarchive.yml | 39 + .../technical-handbook/mcp-server.mdx | 321 +- docs/docs.json | 25 +- .../platform/workflows/workflow-builder.webp | Bin 0 -> 38774 bytes .../workflows/workflow-run-details.webp | Bin 0 -> 44924 bytes .../platform/workflows/workflow-runs.webp | Bin 0 -> 56692 bytes .../features/integrations/overview.mdx | 4 + docs/platform/introduction.mdx | 2 + docs/platform/mcp/overview.mdx | 32 +- docs/platform/mcp/setup.mdx | 15 +- docs/platform/what-is-formbricks.mdx | 16 + .../enterprise-features/workflows.mdx | 10 + docs/self-hosting/advanced/license.mdx | 1 + .../configuration/environment-variables.mdx | 9 +- .../self-hosting/configuration/job-runner.mdx | 188 ++ .../general-features/email-followups.mdx | 6 + docs/unify-feedback/overview.mdx | 2 +- docs/workflows/build.mdx | 110 + docs/workflows/manage-and-monitor.mdx | 93 + docs/workflows/overview.mdx | 102 + package.json | 10 +- packages/database/.gitignore | 2 +- packages/database/README.md | 11 +- packages/database/json-types.ts | 14 + .../migration.sql | 151 + .../migration.sql | 4 + .../migration.sql | 4 + .../migration.sql | 6 + packages/database/package.json | 11 +- packages/database/prisma.config.ts | 22 + .../{schema.prisma => schema/main.prisma} | 7 +- packages/database/schema/workflows.prisma | 120 + packages/database/src/prisma.ts | 4 + .../database/src/scripts/create-migration.ts | 2 +- .../database/src/scripts/migration-runner.ts | 8 +- packages/database/src/seed.ts | 342 +++ packages/database/src/seed/constants.ts | 3 + packages/database/zod/workflows.ts | 95 + packages/email/src/lib/mock-translate.ts | 2 - packages/jobs/src/constants.ts | 2 + packages/jobs/src/contracts.ts | 14 +- packages/jobs/src/definitions.ts | 14 + packages/jobs/src/index.ts | 9 + packages/jobs/src/processors.test.ts | 29 + .../src/processors/workflow-run-reconcile.ts | 20 + packages/jobs/src/processors/workflow-run.ts | 27 + packages/jobs/src/queue.test.ts | 90 + packages/jobs/src/queue.ts | 75 + packages/jobs/src/types.ts | 15 + packages/types/organizations.ts | 7 +- packages/workflows/README.md | 40 + packages/workflows/eslint.config.mjs | 3 + packages/workflows/package.json | 43 + packages/workflows/src/contracts/common.ts | 41 + .../workflows/src/contracts/index.test.ts | 203 ++ packages/workflows/src/contracts/index.ts | 4 + packages/workflows/src/contracts/inputs.ts | 112 + .../workflows/src/contracts/operations.ts | 132 + packages/workflows/src/contracts/resources.ts | 153 + .../src/contracts/spec-drift.test.ts | 160 + packages/workflows/src/errors.test.ts | 89 + packages/workflows/src/errors.ts | 244 ++ packages/workflows/src/execution/index.ts | 1 + packages/workflows/src/execution/plan.test.ts | 90 + packages/workflows/src/execution/plan.ts | 84 + .../src/handlers/audit-redaction.test.ts | 137 + .../workflows/src/handlers/audit-redaction.ts | 80 + packages/workflows/src/handlers/context.ts | 89 + .../workflows/src/handlers/cursor.test.ts | 27 + packages/workflows/src/handlers/cursor.ts | 83 + .../src/handlers/parse-list-query.ts | 77 + .../src/handlers/runs-cursor.test.ts | 58 + .../workflows/src/handlers/runs-cursor.ts | 44 + .../src/handlers/serializers.test.ts | 212 ++ .../workflows/src/handlers/serializers.ts | 98 + .../src/handlers/workflows.handlers.test.ts | 1276 ++++++++ .../src/handlers/workflows.handlers.ts | 623 ++++ packages/workflows/src/http.ts | 18 + packages/workflows/src/index.ts | 4 + packages/workflows/src/recipients.test.ts | 15 + packages/workflows/src/recipients.ts | 12 + packages/workflows/src/responses.ts | 28 + packages/workflows/src/server/index.ts | 35 + packages/workflows/src/services/ports.ts | 278 ++ .../src/services/workflows.service.test.ts | 585 ++++ .../src/services/workflows.service.ts | 365 +++ packages/workflows/src/types/README.md | 826 +++++ .../workflow-definition.full.json | 183 ++ .../workflow-executable-definition.full.json | 54 + .../__fixtures__/workflow-run-data.full.json | 35 + .../__fixtures__/workflow-run-log.full.json | 19 + .../workflow-trigger-payload.full.json | 13 + .../__fixtures__/workflow-version.full.json | 62 + packages/workflows/src/types/actions/enum.ts | 8 + packages/workflows/src/types/actions/index.ts | 18 + .../workflows/src/types/actions/send-email.ts | 55 + packages/workflows/src/types/common.ts | 45 + .../workflows/src/types/conditions/base.ts | 77 + .../workflows/src/types/conditions/if-else.ts | 12 + .../workflows/src/types/conditions/index.ts | 2 + packages/workflows/src/types/content.test.ts | 88 + packages/workflows/src/types/content.ts | 61 + packages/workflows/src/types/document.ts | 298 ++ packages/workflows/src/types/index.test.ts | 451 +++ packages/workflows/src/types/index.ts | 7 + packages/workflows/src/types/runs.ts | 72 + packages/workflows/src/types/triggers/enum.ts | 8 + .../workflows/src/types/triggers/index.ts | 18 + .../src/types/triggers/response-completed.ts | 32 + packages/workflows/tsconfig.json | 11 + packages/workflows/vite.config.ts | 24 + pnpm-lock.yaml | 428 ++- prisma.config.mjs | 2 +- turbo.json | 16 + 387 files changed, 36183 insertions(+), 1182 deletions(-) create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/layout.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/loading.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/page.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/query-client-provider.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/loading.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/page.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/layout.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/loading.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/page.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/loading.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/page.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/layout.tsx create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/workflows/query-client-provider.tsx create mode 100644 apps/web/app/api/v3/workflows/[workflowId]/archive/route.ts create mode 100644 apps/web/app/api/v3/workflows/[workflowId]/disable/route.ts create mode 100644 apps/web/app/api/v3/workflows/[workflowId]/duplicate/route.ts create mode 100644 apps/web/app/api/v3/workflows/[workflowId]/enable/route.ts create mode 100644 apps/web/app/api/v3/workflows/[workflowId]/route.ts create mode 100644 apps/web/app/api/v3/workflows/[workflowId]/test/route.ts create mode 100644 apps/web/app/api/v3/workflows/[workflowId]/unarchive/route.ts create mode 100644 apps/web/app/api/v3/workflows/lib/context.test.ts create mode 100644 apps/web/app/api/v3/workflows/lib/context.ts create mode 100644 apps/web/app/api/v3/workflows/route.ts create mode 100644 apps/web/app/api/v3/workflows/runs/[runId]/route.ts create mode 100644 apps/web/app/api/v3/workflows/runs/route.ts create mode 100644 apps/web/lib/jobs/pool-exhaustion.test.ts create mode 100644 apps/web/lib/jobs/pool-exhaustion.ts create mode 100644 apps/web/modules/ee/workflows/components/canvas/add-button-edge.tsx create mode 100644 apps/web/modules/ee/workflows/components/canvas/canvas-controls.tsx create mode 100644 apps/web/modules/ee/workflows/components/canvas/workflow-add-trigger-picker.tsx create mode 100644 apps/web/modules/ee/workflows/components/canvas/workflow-canvas-node.tsx create mode 100644 apps/web/modules/ee/workflows/components/canvas/workflow-canvas.css create mode 100644 apps/web/modules/ee/workflows/components/canvas/workflow-canvas.tsx create mode 100644 apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx create mode 100644 apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx create mode 100644 apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx create mode 100644 apps/web/modules/ee/workflows/components/inspector/workflow-field.tsx create mode 100644 apps/web/modules/ee/workflows/components/inspector/workflow-inspector-panel.tsx create mode 100644 apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx create mode 100644 apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx create mode 100644 apps/web/modules/ee/workflows/components/runs/run-json-code.tsx create mode 100644 apps/web/modules/ee/workflows/components/runs/run-json-section.tsx create mode 100644 apps/web/modules/ee/workflows/components/runs/run-steps-body.tsx create mode 100644 apps/web/modules/ee/workflows/components/runs/run-summary-section.tsx create mode 100644 apps/web/modules/ee/workflows/components/runs/workflow-run-detail-drawer.tsx create mode 100644 apps/web/modules/ee/workflows/components/runs/workflow-run-steps.tsx create mode 100644 apps/web/modules/ee/workflows/components/runs/workflow-runs-table.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-auto-save-indicator.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-editor-provider.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-email-authoring-context.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-filter-dropdown.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-header-cta.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-list-actions.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-page-title.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-secondary-navigation.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-sort-dropdown.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-status-pill.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflow-validation-problems-dialog.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflows-empty-state.tsx create mode 100644 apps/web/modules/ee/workflows/components/workflows-upgrade-prompt.tsx create mode 100644 apps/web/modules/ee/workflows/components/workspace-workflows-header-cta.tsx create mode 100644 apps/web/modules/ee/workflows/components/workspace-workflows-secondary-navigation.tsx create mode 100644 apps/web/modules/ee/workflows/hooks/test-utils.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-archive-workflow.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-archive-workflow.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-create-workflow.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-create-workflow.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-debounced-value.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-debounced-value.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-delete-workflow.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-delete-workflow.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-builder.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-builder.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-run.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-run.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-runs.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflow-runs.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflows.test.ts create mode 100644 apps/web/modules/ee/workflows/hooks/use-workflows.ts create mode 100644 apps/web/modules/ee/workflows/lib/api-client.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/api-client.ts create mode 100644 apps/web/modules/ee/workflows/lib/api-error.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/api-error.ts create mode 100644 apps/web/modules/ee/workflows/lib/auth.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/auth.ts create mode 100644 apps/web/modules/ee/workflows/lib/bound-survey.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/bound-survey.ts create mode 100644 apps/web/modules/ee/workflows/lib/default-workflow.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/default-workflow.ts create mode 100644 apps/web/modules/ee/workflows/lib/definition-to-flow.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/definition-to-flow.ts create mode 100644 apps/web/modules/ee/workflows/lib/display.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/display.ts create mode 100644 apps/web/modules/ee/workflows/lib/email-authoring-context.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/email-authoring-context.ts create mode 100644 apps/web/modules/ee/workflows/lib/format-date.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/format-date.ts create mode 100644 apps/web/modules/ee/workflows/lib/list-filters.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/list-filters.ts create mode 100644 apps/web/modules/ee/workflows/lib/node-registry.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/node-registry.ts create mode 100644 apps/web/modules/ee/workflows/lib/query.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/query.ts create mode 100644 apps/web/modules/ee/workflows/lib/run-display.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/run-display.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/dispatch.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/dispatch.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/mark-dispatched.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/mark-dispatched.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/match-workflows.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/match-workflows.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/reconcile-constants.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.ts create mode 100644 apps/web/modules/ee/workflows/lib/trigger-ending-cards.test.ts create mode 100644 apps/web/modules/ee/workflows/lib/trigger-ending-cards.ts create mode 100644 apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.test.ts create mode 100644 apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.ts create mode 100644 apps/web/modules/ee/workflows/list/hooks/use-workflows.ts create mode 100644 apps/web/modules/ee/workflows/list/lib/query.ts create mode 100644 apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts create mode 100644 apps/web/modules/ee/workflows/loading.tsx create mode 100644 apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx create mode 100644 apps/web/modules/ee/workflows/pages/workflow-runs-page.tsx create mode 100644 apps/web/modules/ee/workflows/pages/workflows-list-page.tsx create mode 100644 apps/web/modules/ee/workflows/pages/workspace-workflow-runs-page.tsx create mode 100644 apps/web/modules/ee/workflows/state/editor.test.ts create mode 100644 apps/web/modules/ee/workflows/state/editor.ts create mode 100644 apps/web/modules/ee/workflows/types.ts create mode 100644 apps/web/modules/ee/workflows/types/email-authoring-context.ts create mode 100644 apps/web/modules/email/lib/survey-response-email.test.ts create mode 100644 apps/web/modules/email/lib/survey-response-email.ts create mode 100644 apps/web/modules/mcp/tools/guard-scopes.test.ts create mode 100644 apps/web/modules/mcp/tools/run-mcp-mutation.ts create mode 100644 apps/web/modules/mcp/tools/workflow-schemas.ts create mode 100644 apps/web/modules/mcp/tools/workflows.test.ts create mode 100644 apps/web/modules/mcp/tools/workflows.ts create mode 100644 apps/web/modules/survey/follow-ups/lib/email-send-to-options.test.ts create mode 100644 apps/web/modules/survey/follow-ups/lib/email-send-to-options.ts create mode 100644 apps/web/modules/ui/components/card-table/index.tsx create mode 100644 apps/web/modules/ui/components/textarea/index.tsx create mode 100644 docs/api-v3-reference/.redocly.lint-ignore.yaml create mode 100644 docs/api-v3-reference/src/components/parameters/WorkflowIdPath.yml create mode 100644 docs/api-v3-reference/src/components/parameters/WorkflowRunIdPath.yml create mode 100644 docs/api-v3-reference/src/components/responses/WorkflowResourceResponse.yml create mode 100644 docs/api-v3-reference/src/components/schemas/CreateWorkflowRequest.yml create mode 100644 docs/api-v3-reference/src/components/schemas/DuplicateWorkflowRequest.yml create mode 100644 docs/api-v3-reference/src/components/schemas/PatchWorkflowRequest.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowDefinition.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowEdge.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowListItem.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowNodeUi.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowResource.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowResponseCompletedTriggerNode.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunData.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunListItem.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunLog.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunLogStatus.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunResource.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunStatus.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunStepResult.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowRunSummary.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowSendEmailActionConfig.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowSendEmailActionNode.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowStatus.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowTestResult.yml create mode 100644 docs/api-v3-reference/src/components/schemas/WorkflowTriggerPayload.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_runs.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_runs_{runId}.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_{workflowId}.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_{workflowId}_archive.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_{workflowId}_disable.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_{workflowId}_duplicate.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_{workflowId}_enable.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_{workflowId}_test.yml create mode 100644 docs/api-v3-reference/src/paths/api_v3_workflows_{workflowId}_unarchive.yml create mode 100644 docs/images/platform/workflows/workflow-builder.webp create mode 100644 docs/images/platform/workflows/workflow-run-details.webp create mode 100644 docs/images/platform/workflows/workflow-runs.webp create mode 100644 docs/self-hosting/advanced/enterprise-features/workflows.mdx create mode 100644 docs/self-hosting/configuration/job-runner.mdx create mode 100644 docs/workflows/build.mdx create mode 100644 docs/workflows/manage-and-monitor.mdx create mode 100644 docs/workflows/overview.mdx create mode 100644 packages/database/migration/20260608120000_add_workflows_foundation/migration.sql create mode 100644 packages/database/migration/20260701000000_workflow_run_log_unique_step/migration.sql create mode 100644 packages/database/migration/20260709120000_drop_workflow_name_uniqueness/migration.sql create mode 100644 packages/database/migration/20260717000000_add_workflow_run_dispatched_at/migration.sql create mode 100644 packages/database/prisma.config.ts rename packages/database/{schema.prisma => schema/main.prisma} (99%) create mode 100644 packages/database/schema/workflows.prisma create mode 100644 packages/database/zod/workflows.ts create mode 100644 packages/jobs/src/processors/workflow-run-reconcile.ts create mode 100644 packages/jobs/src/processors/workflow-run.ts create mode 100644 packages/workflows/README.md create mode 100644 packages/workflows/eslint.config.mjs create mode 100644 packages/workflows/package.json create mode 100644 packages/workflows/src/contracts/common.ts create mode 100644 packages/workflows/src/contracts/index.test.ts create mode 100644 packages/workflows/src/contracts/index.ts create mode 100644 packages/workflows/src/contracts/inputs.ts create mode 100644 packages/workflows/src/contracts/operations.ts create mode 100644 packages/workflows/src/contracts/resources.ts create mode 100644 packages/workflows/src/contracts/spec-drift.test.ts create mode 100644 packages/workflows/src/errors.test.ts create mode 100644 packages/workflows/src/errors.ts create mode 100644 packages/workflows/src/execution/index.ts create mode 100644 packages/workflows/src/execution/plan.test.ts create mode 100644 packages/workflows/src/execution/plan.ts create mode 100644 packages/workflows/src/handlers/audit-redaction.test.ts create mode 100644 packages/workflows/src/handlers/audit-redaction.ts create mode 100644 packages/workflows/src/handlers/context.ts create mode 100644 packages/workflows/src/handlers/cursor.test.ts create mode 100644 packages/workflows/src/handlers/cursor.ts create mode 100644 packages/workflows/src/handlers/parse-list-query.ts create mode 100644 packages/workflows/src/handlers/runs-cursor.test.ts create mode 100644 packages/workflows/src/handlers/runs-cursor.ts create mode 100644 packages/workflows/src/handlers/serializers.test.ts create mode 100644 packages/workflows/src/handlers/serializers.ts create mode 100644 packages/workflows/src/handlers/workflows.handlers.test.ts create mode 100644 packages/workflows/src/handlers/workflows.handlers.ts create mode 100644 packages/workflows/src/http.ts create mode 100644 packages/workflows/src/index.ts create mode 100644 packages/workflows/src/recipients.test.ts create mode 100644 packages/workflows/src/recipients.ts create mode 100644 packages/workflows/src/responses.ts create mode 100644 packages/workflows/src/server/index.ts create mode 100644 packages/workflows/src/services/ports.ts create mode 100644 packages/workflows/src/services/workflows.service.test.ts create mode 100644 packages/workflows/src/services/workflows.service.ts create mode 100644 packages/workflows/src/types/README.md create mode 100644 packages/workflows/src/types/__fixtures__/workflow-definition.full.json create mode 100644 packages/workflows/src/types/__fixtures__/workflow-executable-definition.full.json create mode 100644 packages/workflows/src/types/__fixtures__/workflow-run-data.full.json create mode 100644 packages/workflows/src/types/__fixtures__/workflow-run-log.full.json create mode 100644 packages/workflows/src/types/__fixtures__/workflow-trigger-payload.full.json create mode 100644 packages/workflows/src/types/__fixtures__/workflow-version.full.json create mode 100644 packages/workflows/src/types/actions/enum.ts create mode 100644 packages/workflows/src/types/actions/index.ts create mode 100644 packages/workflows/src/types/actions/send-email.ts create mode 100644 packages/workflows/src/types/common.ts create mode 100644 packages/workflows/src/types/conditions/base.ts create mode 100644 packages/workflows/src/types/conditions/if-else.ts create mode 100644 packages/workflows/src/types/conditions/index.ts create mode 100644 packages/workflows/src/types/content.test.ts create mode 100644 packages/workflows/src/types/content.ts create mode 100644 packages/workflows/src/types/document.ts create mode 100644 packages/workflows/src/types/index.test.ts create mode 100644 packages/workflows/src/types/index.ts create mode 100644 packages/workflows/src/types/runs.ts create mode 100644 packages/workflows/src/types/triggers/enum.ts create mode 100644 packages/workflows/src/types/triggers/index.ts create mode 100644 packages/workflows/src/types/triggers/response-completed.ts create mode 100644 packages/workflows/tsconfig.json create mode 100644 packages/workflows/vite.config.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3e41b78f65dc..1597e0be0611 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -100,13 +100,9 @@ jobs: run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 shell: bash - # Build the workspace packages the tests import (@formbricks/logger, cache, types, email, …) so - # vite can resolve their package.json `exports` → dist. Deps only (`^...`), not the Next app. - - name: Build workspace package dependencies - if: steps.harness.outputs.present == 'true' - run: pnpm build --filter=@formbricks/web^... - shell: bash - + # The .env must exist before the package build: @formbricks/database's prisma.config.ts + # resolves DATABASE_URL at config load (dotenv from the repo root), so `prisma generate` + # fails without it. Same order as e2e.yml. - name: Create .env if: steps.harness.outputs.present == 'true' run: pnpm dev:setup @@ -122,6 +118,13 @@ jobs: sed -i "s|DATABASE_URL=.*|DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks_ba_test?schema=public|" .env shell: bash + # Build the workspace packages the tests import (@formbricks/logger, cache, types, email, …) so + # vite can resolve their package.json `exports` → dist. Deps only (`^...`), not the Next app. + - name: Build workspace package dependencies + if: steps.harness.outputs.present == 'true' + run: pnpm build --filter=@formbricks/web^... + shell: bash + - name: Create the test database if: steps.harness.outputs.present == 'true' run: psql "postgresql://postgres:postgres@localhost:5432/postgres" -v ON_ERROR_STOP=1 -c 'CREATE DATABASE formbricks_ba_test;' diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8fe26f170d36..9f16dde98538 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -59,8 +59,12 @@ RUN touch apps/web/.env # Install the dependencies RUN pnpm install --ignore-scripts --frozen-lockfile -# Build the database package first -RUN pnpm build --filter=@formbricks/database +# Build the database package first. `pnpm generate` loads prisma.config.ts, which +# resolves DATABASE_URL via Prisma's env() helper and throws when it is unset. Mount +# the secret and run through the secret reader so a build-time fallback is supplied +# (mirrors the web build below); prisma generate never connects to the database. +RUN --mount=type=secret,id=database_url \ + /tmp/read-secrets.sh pnpm build --filter=@formbricks/database # Build the project using our secret reader script # This mounts the secrets only during this build step without storing them in layers @@ -118,8 +122,12 @@ RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages COPY --from=installer /app/packages/database/package.json ./packages/database/package.json RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json -COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma -RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma +COPY --from=installer /app/packages/database/schema ./packages/database/schema +RUN chown -R nextjs:nextjs ./packages/database/schema && chmod -R 755 ./packages/database/schema + +# The migration runner invokes `prisma migrate deploy --config packages/database/prisma.config.ts` +COPY --from=installer /app/packages/database/prisma.config.ts ./packages/database/prisma.config.ts +RUN chown nextjs:nextjs ./packages/database/prisma.config.ts && chmod 644 ./packages/database/prisma.config.ts COPY --from=installer /app/packages/database/dist ./packages/database/dist RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx index f894cde1d592..4a9c7252928d 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx @@ -14,6 +14,7 @@ import { RocketIcon, SettingsIcon, UserIcon, + WorkflowIcon, } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; @@ -181,6 +182,20 @@ export const MainNavigation = ({ }, ], }, + { + id: "act", + name: t("common.act"), + items: [ + { + name: t("common.workflows"), + href: `/workspaces/${workspace.id}/workflows`, + icon: WorkflowIcon, + isActive: pathname?.startsWith(`/workspaces/${workspace.id}/workflows`), + isHidden: false, + disabled: isMembershipPending || isBilling, + }, + ], + }, ], [t, workspace.id, pathname, isMembershipPending, isBilling] ); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/layout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/layout.tsx new file mode 100644 index 000000000000..20a0bc2be94d --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/layout.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from "react"; +import { getTranslate } from "@/lingodotdev/server"; +import { WorkflowsUpgradePrompt } from "@/modules/ee/workflows/components/workflows-upgrade-prompt"; +import { WorkspaceWorkflowsHeaderCta } from "@/modules/ee/workflows/components/workspace-workflows-header-cta"; +import { WorkspaceWorkflowsSecondaryNavigation } from "@/modules/ee/workflows/components/workspace-workflows-secondary-navigation"; +import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; +import { WorkflowsQueryClientProvider } from "./query-client-provider"; + +const WorkspaceWorkflowsLayout = async ( + props: Readonly<{ params: Promise<{ workspaceId: string }>; children: ReactNode }> +) => { + const params = await props.params; + const { isReadOnly, isWorkflowsEnabled, organizationId } = await getWorkflowsRouteAuth(params.workspaceId); + const t = await getTranslate(); + + if (!isWorkflowsEnabled) { + // Not entitled: keep the page skeleton (title + tabs, both tabs land on this prompt) but render + // the upsell instead of the feature. No CTA and no children — the client pages fetch through + // the now-403 workflows API and must not mount. + return ( + + + + + + + ); + } + + return ( + + + }> + + + {props.children} + + + ); +}; + +export default WorkspaceWorkflowsLayout; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/loading.tsx new file mode 100644 index 000000000000..7e27b8cb81f0 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/loading.tsx @@ -0,0 +1 @@ +export { WorkflowsListBodyLoading as default } from "@/modules/ee/workflows/loading"; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/page.tsx new file mode 100644 index 000000000000..ab907c3849ea --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/page.tsx @@ -0,0 +1,25 @@ +import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth"; +import { WorkflowsListPage } from "@/modules/ee/workflows/pages/workflows-list-page"; + +const WORKFLOWS_PER_PAGE = 12; + +const WorkflowsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => { + const params = await props.params; + const { isReadOnly, isWorkflowsEnabled } = await getWorkflowsRouteAuth(params.workspaceId); + + // Pages render in parallel with the gating layout; contribute nothing when not entitled so the + // client list page (which fetches through the now-403 workflows API) never mounts. + if (!isWorkflowsEnabled) { + return null; + } + + return ( + + ); +}; + +export default WorkflowsPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/query-client-provider.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/query-client-provider.tsx new file mode 100644 index 000000000000..76f3bcc8a4a6 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/query-client-provider.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; + +interface WorkflowsQueryClientProviderProps { + children: ReactNode; +} + +export const WorkflowsQueryClientProvider = ({ children }: Readonly) => { + const [queryClient] = useState(() => new QueryClient()); + + return {children}; +}; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/loading.tsx new file mode 100644 index 000000000000..5ac400460316 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/loading.tsx @@ -0,0 +1 @@ +export { WorkspaceWorkflowRunsBodyLoading as default } from "@/modules/ee/workflows/loading"; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/page.tsx new file mode 100644 index 000000000000..988893053eca --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/page.tsx @@ -0,0 +1,8 @@ +import { WorkspaceWorkflowRunsPage } from "@/modules/ee/workflows/pages/workspace-workflow-runs-page"; + +const WorkflowRunsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => { + const { workspaceId } = await props.params; + return ; +}; + +export default WorkflowRunsPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/layout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/layout.tsx new file mode 100644 index 000000000000..3756672b6869 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/layout.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; +import { getTranslate } from "@/lingodotdev/server"; +import { WorkflowEditorProvider } from "@/modules/ee/workflows/components/workflow-editor-provider"; +import { WorkflowHeaderCta } from "@/modules/ee/workflows/components/workflow-header-cta"; +import { WorkflowPageTitle } from "@/modules/ee/workflows/components/workflow-page-title"; +import { WorkflowSecondaryNavigation } from "@/modules/ee/workflows/components/workflow-secondary-navigation"; +import { WorkflowsUpgradePrompt } from "@/modules/ee/workflows/components/workflows-upgrade-prompt"; +import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; + +const WorkflowDetailLayout = async ( + props: Readonly<{ + params: Promise<{ workspaceId: string; workflowId: string }>; + children: ReactNode; + }> +) => { + const params = await props.params; + const { isReadOnly, isWorkflowsEnabled, organizationId } = await getWorkflowsRouteAuth(params.workspaceId); + + if (!isWorkflowsEnabled) { + // Not entitled: plain title instead of WorkflowPageTitle/WorkflowHeaderCta/WorkflowEditorProvider — + // those fetch the workflow through the now-403 API and must not render broken states. The tabs are + // static links; both land on this prompt, matching the list layout. + const t = await getTranslate(); + return ( + + + + + + + ); + } + + return ( + + {/* The editor fills the shell's scroll area instead of scrolling inside it: `h-full` pins this + wrapper to that container's height and the flex column hands whatever is left below the + header to the canvas + inspector row, which scroll internally. Height is therefore derived, + never assumed — the alternative is subtracting a hardcoded guess at the chrome above. */} + + } + cta={}> + + + {props.children} + + + ); +}; + +export default WorkflowDetailLayout; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/loading.tsx new file mode 100644 index 000000000000..e7a087d571ed --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/loading.tsx @@ -0,0 +1 @@ +export { WorkflowBuilderBodyLoading as default } from "@/modules/ee/workflows/loading"; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/page.tsx new file mode 100644 index 000000000000..ebe9630c789e --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/page.tsx @@ -0,0 +1,34 @@ +import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth"; +import { getWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/lib/email-authoring-context"; +import { WorkflowBuilderPage } from "@/modules/ee/workflows/pages/workflow-builder-page"; + +const WorkflowPage = async ( + props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }> +) => { + const params = await props.params; + const { isReadOnly, isWorkflowsEnabled } = await getWorkflowsRouteAuth(params.workspaceId); + + // Pages render in parallel with the gating layout; skip the server-side context resolution and + // contribute nothing when not entitled so the builder never mounts against the now-403 API. + if (!isWorkflowsEnabled) { + return null; + } + + // Resolve the bound survey + team/sender context server-side so the send_email inspector renders + // Follow-Ups-parity controls (recall body, recipient options) from fully-formed props. + const emailAuthoringContext = await getWorkflowEmailAuthoringContext({ + workflowId: params.workflowId, + workspaceId: params.workspaceId, + }); + + return ( + + ); +}; + +export default WorkflowPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/loading.tsx new file mode 100644 index 000000000000..64d1d47aa183 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/loading.tsx @@ -0,0 +1 @@ +export { WorkflowRunsBodyLoading as default } from "@/modules/ee/workflows/loading"; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/page.tsx new file mode 100644 index 000000000000..c730bd397102 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/page.tsx @@ -0,0 +1,10 @@ +import { WorkflowRunsPage } from "@/modules/ee/workflows/pages/workflow-runs-page"; + +const WorkflowRuns = async ( + props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }> +) => { + const { workspaceId, workflowId } = await props.params; + return ; +}; + +export default WorkflowRuns; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/layout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/layout.tsx new file mode 100644 index 000000000000..f6c8fa4a6213 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/layout.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from "react"; +import { WorkflowsQueryClientProvider } from "./query-client-provider"; + +const WorkflowsLayout = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +export default WorkflowsLayout; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/query-client-provider.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/query-client-provider.tsx new file mode 100644 index 000000000000..bf6f7074c683 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/query-client-provider.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; + +export const WorkflowsQueryClientProvider = ({ children }: { children: ReactNode }) => { + const [queryClient] = useState(() => new QueryClient()); + + return {children}; +}; diff --git a/apps/web/app/.well-known/oauth-protected-resource/route.test.ts b/apps/web/app/.well-known/oauth-protected-resource/route.test.ts index 20163aaddc8c..a139b2b6aa8e 100644 --- a/apps/web/app/.well-known/oauth-protected-resource/route.test.ts +++ b/apps/web/app/.well-known/oauth-protected-resource/route.test.ts @@ -46,6 +46,8 @@ describe("OAuth protected resource metadata", () => { scopes_supported: [ "surveys:read", "surveys:write", + "workflows:read", + "workflows:write", "feedbackRecords:read", "feedbackRecords:write", "offline_access", diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts index d0e5c5582c89..52d8cbbee8d5 100644 --- a/apps/web/app/api/mcp/route.test.ts +++ b/apps/web/app/api/mcp/route.test.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { ApiKeyPermission } from "@formbricks/database/prisma"; +import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; import { createdResponse, problemBadRequest, @@ -45,7 +46,14 @@ vi.mock("@formbricks/database", () => ({ vi.mock("@/modules/auth/lib/oauth-urls", () => ({ // Must mirror the real MCP_RESOURCE_SCOPES: the route's minimum-scope gate and its WWW-Authenticate // challenge are both derived from this list, so a short mock would test a world production doesn't have. - MCP_RESOURCE_SCOPES: ["surveys:read", "surveys:write", "feedbackRecords:read", "feedbackRecords:write"], + MCP_RESOURCE_SCOPES: [ + "surveys:read", + "surveys:write", + "workflows:read", + "workflows:write", + "feedbackRecords:read", + "feedbackRecords:write", + ], getAuthIssuerUrl: () => "http://localhost/api/auth", getMcpOrigin: () => "http://localhost", getMcpProtectedResourceMetadataUrl: () => "http://localhost/.well-known/oauth-protected-resource/api/mcp", @@ -163,7 +171,7 @@ describe("POST /api/mcp", () => { expect(response.status).toBe(401); expect(response.headers.get("Content-Type")).toBe("application/problem+json"); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' ); expect(applyIPRateLimit).toHaveBeenCalled(); }); @@ -214,6 +222,19 @@ describe("POST /api/mcp", () => { "validate_survey", "patch_survey", "delete_survey", + "list_workflows", + "get_workflow", + "list_workflow_runs", + "get_workflow_run", + "test_workflow", + "create_workflow", + "patch_workflow", + "duplicate_workflow", + "delete_workflow", + "enable_workflow", + "disable_workflow", + "archive_workflow", + "unarchive_workflow", "list_workspaces", "list_feedback_datasets", "list_feedback_records", @@ -424,7 +445,7 @@ describe("POST /api/mcp", () => { expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); expect(applyIPRateLimit).toHaveBeenCalled(); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' ); }); @@ -469,6 +490,53 @@ describe("POST /api/mcp", () => { }); }); + test("blocks workflow write tools for tokens without workflows:write", async () => { + // A write-capable user whose OAuth token was only granted read scopes (surveys:read + workflows:read) + // must not be able to reach a workflow mutation — the ENG-1967 token-scope boundary. + verifyAccessTokenMock.mockResolvedValueOnce({ + sub: "user_1", + email: "person@example.com", + scope: "openid profile email surveys:read workflows:read", + exp: Math.floor(Date.now() / 1000) + 900, + azp: "client_wf_read_only", + }); + + const response = await POST( + createMcpRequest( + { + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { + name: "delete_workflow", + arguments: { + workflowId: "wf1234567890123456789012ab", + }, + }, + }, + { + authorization: "Bearer eyJhbGciOiJFZERTQSJ9.wfreadonly.signature", + "x-api-key": "", + "x-request-id": "req_wf_read_only", + } + ) + ); + + expect(response.status).toBe(200); + const message = await readMcpResponse(response); + expect(message.result.isError).toBe(true); + expect(message.result.structuredContent.error).toMatchObject({ + status: 403, + code: "forbidden", + detail: "OAuth token does not include the required MCP scope", + requestId: "req_wf_read_only", + }); + // The scope gate must fire BEFORE any mutation side effect: no audit log is built or queued for a + // request that never reaches the workflow handler. + expect(buildV3AuditLog).not.toHaveBeenCalled(); + expect(queueV3AuditLog).not.toHaveBeenCalled(); + }); + test("calls create_survey through the MCP route", async () => { vi.mocked(createV3SurveyResponseFromRawInput).mockResolvedValue( createdResponse( diff --git a/apps/web/app/api/v3/workflows/[workflowId]/archive/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/archive/route.ts new file mode 100644 index 000000000000..ea8cf0d138a6 --- /dev/null +++ b/apps/web/app/api/v3/workflows/[workflowId]/archive/route.ts @@ -0,0 +1,19 @@ +/** + * POST /api/v3/workflows/{workflowId}/archive — soft-delete (status → archived). + * Thin adapter delegating to the framework-agnostic handler in `@formbricks/workflows/server`. + */ +import { ZWorkflowIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context"; + +export const POST = withV3ApiWrapper({ + auth: "both", + action: "updated", + targetType: "workflow", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) => + workflowsHandlers.archive({ + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/[workflowId]/disable/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/disable/route.ts new file mode 100644 index 000000000000..2ed24d0c7062 --- /dev/null +++ b/apps/web/app/api/v3/workflows/[workflowId]/disable/route.ts @@ -0,0 +1,19 @@ +/** + * POST /api/v3/workflows/{workflowId}/disable — move an enabled workflow to disabled. + * Thin adapter delegating to the framework-agnostic handler in @formbricks/workflows/server. + */ +import { ZWorkflowIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context"; + +export const POST = withV3ApiWrapper({ + auth: "both", + action: "updated", + targetType: "workflow", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) => + workflowsHandlers.disable({ + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/[workflowId]/duplicate/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/duplicate/route.ts new file mode 100644 index 000000000000..4e0331bddeac --- /dev/null +++ b/apps/web/app/api/v3/workflows/[workflowId]/duplicate/route.ts @@ -0,0 +1,20 @@ +/** + * POST /api/v3/workflows/{workflowId}/duplicate — clone a workflow into a new draft. + * Thin adapter delegating to the framework-agnostic handler in `@formbricks/workflows/server`. + */ +import { ZWorkflowIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context"; + +export const POST = withV3ApiWrapper({ + auth: "both", + action: "created", + targetType: "workflow", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ req, parsedInput, authentication, auditLog, requestId, instance }) => + workflowsHandlers.duplicate({ + req, + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/[workflowId]/enable/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/enable/route.ts new file mode 100644 index 000000000000..22dd8da8688f --- /dev/null +++ b/apps/web/app/api/v3/workflows/[workflowId]/enable/route.ts @@ -0,0 +1,19 @@ +/** + * POST /api/v3/workflows/{workflowId}/enable — validate executability, snapshot an immutable + * version, and move the workflow to enabled. Thin adapter delegating to @formbricks/workflows/server. + */ +import { ZWorkflowIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context"; + +export const POST = withV3ApiWrapper({ + auth: "both", + action: "updated", + targetType: "workflow", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) => + workflowsHandlers.enable({ + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/[workflowId]/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/route.ts new file mode 100644 index 000000000000..8e88b0117d71 --- /dev/null +++ b/apps/web/app/api/v3/workflows/[workflowId]/route.ts @@ -0,0 +1,45 @@ +/** + * /api/v3/workflows/{workflowId} — retrieve, update, and delete a single workflow. + * Unknown / cross-workspace ids return 403 (not 404) to avoid leaking existence. + * + * Thin adapters: the wrapper validates the path param with the contract schema, then delegates to + * the framework-agnostic handlers in `@formbricks/workflows/server`. + */ +import { ZWorkflowIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../lib/context"; + +export const GET = withV3ApiWrapper({ + auth: "both", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ parsedInput, authentication, requestId, instance }) => + workflowsHandlers.get({ + ctx: buildWorkflowApiContext(authentication, requestId, instance), + params: parsedInput.params, + }), +}); + +export const PATCH = withV3ApiWrapper({ + auth: "both", + action: "updated", + targetType: "workflow", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ req, parsedInput, authentication, auditLog, requestId, instance }) => + workflowsHandlers.patch({ + req, + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + params: parsedInput.params, + }), +}); + +export const DELETE = withV3ApiWrapper({ + auth: "both", + action: "deleted", + targetType: "workflow", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) => + workflowsHandlers.delete({ + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/[workflowId]/test/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/test/route.ts new file mode 100644 index 000000000000..d51850de5b66 --- /dev/null +++ b/apps/web/app/api/v3/workflows/[workflowId]/test/route.ts @@ -0,0 +1,19 @@ +/** + * POST /api/v3/workflows/{workflowId}/test — dry-run (test) a workflow: validate that its live + * definition would execute and that the trigger's referenced survey + ending cards resolve. + * No run is created and no side effects occur; the response reports `{ ok, problems }`. Thin + * adapter delegating to @formbricks/workflows/server. + */ +import { ZWorkflowIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context"; + +export const POST = withV3ApiWrapper({ + auth: "both", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ parsedInput, authentication, requestId, instance }) => + workflowsHandlers.testWorkflow({ + ctx: buildWorkflowApiContext(authentication, requestId, instance), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/[workflowId]/unarchive/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/unarchive/route.ts new file mode 100644 index 000000000000..fa0f521aa521 --- /dev/null +++ b/apps/web/app/api/v3/workflows/[workflowId]/unarchive/route.ts @@ -0,0 +1,19 @@ +/** + * POST /api/v3/workflows/{workflowId}/unarchive — restore an archived workflow to draft. + * Thin adapter delegating to the framework-agnostic handler in `@formbricks/workflows/server`. + */ +import { ZWorkflowIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context"; + +export const POST = withV3ApiWrapper({ + auth: "both", + action: "updated", + targetType: "workflow", + schemas: { params: ZWorkflowIdInput }, + handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) => + workflowsHandlers.unarchive({ + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/lib/context.test.ts b/apps/web/app/api/v3/workflows/lib/context.test.ts new file mode 100644 index 000000000000..fbea286126f9 --- /dev/null +++ b/apps/web/app/api/v3/workflows/lib/context.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; +import { getOrganizationMemberEmails } from "@/lib/organization/service"; +import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; +import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { buildWorkflowApiContext } from "./context"; + +const { surveyFindUnique } = vi.hoisted(() => ({ surveyFindUnique: vi.fn() })); +vi.mock("@formbricks/database", () => ({ + prisma: { workflow: {}, survey: { findUnique: surveyFindUnique } }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: vi.fn(() => ({ warn: vi.fn(), error: vi.fn() })) }, +})); +vi.mock("@/app/api/v3/lib/auth", () => ({ requireV3WorkspaceAccess: vi.fn() })); +vi.mock("@/lib/utils/helper", () => ({ getOrganizationIdFromWorkspaceId: vi.fn() })); +vi.mock("@/lib/organization/service", () => ({ getOrganizationMemberEmails: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsWorkflowsEnabled: vi.fn() })); + +const baseAuditLog = (): TV3AuditLog => ({ + action: "updated", + targetType: "workflow", + userId: "unknown", + targetId: "unknown", + organizationId: "unknown", + status: "failure", + oldObject: undefined, + newObject: undefined, + userType: "api", + apiUrl: "https://app.formbricks.com/api/v3/workflows/wf_1", +}); + +const sessionAuth = { + user: { id: "cm9zr52kh000508l8e3q7bw9j" }, + expires: "2026-12-01", +} as unknown as TV3Authentication; +const apiKeyAuth = { + type: "apiKey", + apiKeyId: "key_1", + organizationId: "org_1", + organizationAccess: { accessControl: { read: true, write: true } }, + workspacePermissions: [], +} as unknown as TAuthenticationApiKey; + +beforeEach(() => { + vi.clearAllMocks(); + // Entitled by default so authorization-focused tests exercise the workspace-access behavior. + vi.mocked(getIsWorkflowsEnabled).mockResolvedValue(true); +}); + +describe("buildWorkflowApiContext", () => { + test("derives userId from a session", () => { + const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "https://app.formbricks.com"); + expect(ctx.userId).toBe("cm9zr52kh000508l8e3q7bw9j"); + }); + + test("leaves userId null for API-key authentication", () => { + expect(buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").userId).toBeNull(); + }); + + test("leaves userId null for unauthenticated requests", () => { + expect(buildWorkflowApiContext(null, "req_1", "inst").userId).toBeNull(); + }); + + test("authorize delegates to requireV3WorkspaceAccess and returns its result when entitled", async () => { + const resolved = { workspaceId: "ws_1", organizationId: "org_1" }; + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(resolved); + + const ctx = buildWorkflowApiContext(apiKeyAuth, "req_1", "https://app.formbricks.com"); + const result = await ctx.authorize("ws_1", "readWrite"); + + expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( + apiKeyAuth, + "ws_1", + "readWrite", + "req_1", + "https://app.formbricks.com" + ); + // The entitlement is checked against the organization resolved by workspace access. + expect(getIsWorkflowsEnabled).toHaveBeenCalledWith("org_1"); + expect(result).toEqual(resolved); + }); + + test("authorize returns a 403 problem when the organization lacks the workflows entitlement", async () => { + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ workspaceId: "ws_1", organizationId: "org_1" }); + vi.mocked(getIsWorkflowsEnabled).mockResolvedValue(false); + + const ctx = buildWorkflowApiContext(apiKeyAuth, "req_1", "https://app.formbricks.com"); + const result = await ctx.authorize("ws_1", "read"); + + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(403); + const body = await response.json(); + expect(body).toMatchObject({ + status: 403, + detail: "Workflows are not enabled for this organization", + }); + }); + + test("authorize short-circuits on a workspace-access failure without checking the entitlement", async () => { + const denied = new Response(null, { status: 403 }); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied); + + const ctx = buildWorkflowApiContext(apiKeyAuth, "req_1", "inst"); + const result = await ctx.authorize("ws_1", "read"); + + expect(result).toBe(denied); + expect(getIsWorkflowsEnabled).not.toHaveBeenCalled(); + }); +}); + +describe("verifyTriggerSurvey (validates a workflow trigger's referenced survey)", () => { + const verify = (input: { workspaceId: string; surveyId: string; endingCardIds: string[] }) => + buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyTriggerSurvey(input); + + // The adapter parses `survey.endings` with `ZSurveyEndings`, so mocked endings must be valid + // ending cards (cuid2 id + type), matching how the survey is stored. + const endingId1 = "cm9zr4q7i000108l84goze001"; + const endingId2 = "cm9zr4q7i000108l84goze002"; + const endScreen = (id: string) => ({ id, type: "endScreen" as const }); + + test("rejects a workflow trigger whose survey no longer exists in the workspace", async () => { + surveyFindUnique.mockResolvedValue(null); + + const result = await verify({ workspaceId: "ws_1", surveyId: "s_1", endingCardIds: [endingId1] }); + + expect(result).toEqual({ surveyExists: false, missingEndingCardIds: [] }); + expect(surveyFindUnique).toHaveBeenCalledWith({ + where: { id_workspaceId: { id: "s_1", workspaceId: "ws_1" } }, + select: { endings: true }, + }); + }); + + test("flags the trigger's ending-card ids that are missing from the survey", async () => { + surveyFindUnique.mockResolvedValue({ endings: [endScreen(endingId1), endScreen(endingId2)] }); + + const result = await verify({ + workspaceId: "ws_1", + surveyId: "s_1", + endingCardIds: [endingId1, "ending_missing"], + }); + + expect(result).toEqual({ surveyExists: true, missingEndingCardIds: ["ending_missing"] }); + }); + + test("accepts a workflow trigger whose survey and ending cards all exist", async () => { + surveyFindUnique.mockResolvedValue({ endings: [endScreen(endingId1)] }); + + const result = await verify({ workspaceId: "ws_1", surveyId: "s_1", endingCardIds: [endingId1] }); + + expect(result).toEqual({ surveyExists: true, missingEndingCardIds: [] }); + }); +}); + +describe("verifyRecipientsAllowed (recipient allowlist for send_email, ENG-2029)", () => { + test("returns the literal recipients that are not organization members (case-insensitive)", async () => { + vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1"); + vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["member@corp.example"])); + + const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({ + workspaceId: "ws_1", + emails: ["Member@corp.example", "attacker@external-evil.example"], + }); + + expect(getOrganizationMemberEmails).toHaveBeenCalledWith("org_1"); + expect(result).toEqual({ disallowedEmails: ["attacker@external-evil.example"] }); + }); + + test("allows all recipients when each is an organization member", async () => { + vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1"); + vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["a@corp.example", "b@corp.example"])); + + const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({ + workspaceId: "ws_1", + emails: ["a@corp.example", "b@corp.example"], + }); + + expect(result).toEqual({ disallowedEmails: [] }); + }); +}); + +describe("recordAudit (binds the audit sink to the request's audit log)", () => { + test("is not exposed when no audit log is threaded in (read-only routes)", () => { + const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst"); + expect(ctx.recordAudit).toBeUndefined(); + }); + + test("writes targetId + before/after snapshots onto the audit log", async () => { + vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_resolved"); + const auditLog = baseAuditLog(); + const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog); + + await ctx.recordAudit?.({ + targetId: "wf_1", + workspaceId: "ws_1", + oldObject: { status: "draft" }, + newObject: { status: "enabled" }, + }); + + expect(auditLog.targetId).toBe("wf_1"); + expect(auditLog.oldObject).toEqual({ status: "draft" }); + expect(auditLog.newObject).toEqual({ status: "enabled" }); + }); + + test("resolves the workflow's organization from detail.workspaceId for session auth", async () => { + vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_resolved"); + const auditLog = baseAuditLog(); + const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog); + + await ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", newObject: { status: "draft" } }); + + expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_1"); + expect(auditLog.organizationId).toBe("org_resolved"); + }); + + test("resolves org from detail.workspaceId on the delete path (oldObject only, no newObject)", async () => { + vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_resolved"); + const auditLog = baseAuditLog(); + const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog); + + // Delete-style: a pre-mutation snapshot only, no newObject — org must still resolve from the + // explicit workspaceId (never inferred from a snapshot that the delete path may not carry). + await ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", oldObject: { status: "draft" } }); + + expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_1"); + expect(auditLog.organizationId).toBe("org_resolved"); + expect(auditLog.oldObject).toEqual({ status: "draft" }); + expect(auditLog.newObject).toBeUndefined(); + }); + + test("keeps the API-key path's organization and does not re-resolve from the workspace", async () => { + const auditLog = { ...baseAuditLog(), organizationId: "org_from_key" }; + const ctx = buildWorkflowApiContext(apiKeyAuth as TV3Authentication, "req_1", "inst", auditLog); + + await ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", newObject: { status: "draft" } }); + + expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled(); + expect(auditLog.organizationId).toBe("org_from_key"); + }); + + test("never throws when organization resolution fails; snapshots are still recorded", async () => { + vi.mocked(getOrganizationIdFromWorkspaceId).mockRejectedValue(new Error("workspace lookup failed")); + const auditLog = baseAuditLog(); + const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog); + + await expect( + ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", newObject: { status: "draft" } }) + ).resolves.toBeUndefined(); + + expect(auditLog.targetId).toBe("wf_1"); + // Resolution failed, so the session org stays at its default rather than corrupting the event. + expect(auditLog.organizationId).toBe("unknown"); + }); +}); diff --git a/apps/web/app/api/v3/workflows/lib/context.ts b/apps/web/app/api/v3/workflows/lib/context.ts new file mode 100644 index 000000000000..9c280243a658 --- /dev/null +++ b/apps/web/app/api/v3/workflows/lib/context.ts @@ -0,0 +1,148 @@ +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { ZSurveyEndings } from "@formbricks/types/surveys/types"; +import { + type WorkflowApiContext, + type WorkflowAuditDetail, + createWorkflowsHandlers, + createWorkflowsService, +} from "@formbricks/workflows/server"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { problemForbidden } from "@/app/api/v3/lib/response"; +import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; +import { ENCRYPTION_KEY } from "@/lib/constants"; +import { getOrganizationMemberEmails } from "@/lib/organization/service"; +import { normalizeEmailForComparison } from "@/lib/utils/email"; +import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; +import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils"; + +/** + * Adapter glue between the Next.js v3 routes and the framework-agnostic `@formbricks/workflows` + * handlers. The package owns business logic, validation, serialization, and error mapping; this + * file injects the app's concrete `prisma`/`logger` and binds an `authorize` capability to the + * authenticated request. The real Prisma client structurally satisfies the package's narrow + * `WorkflowsDb` port, so no cast is needed; the package never imports `@formbricks/database`. + */ +const service = createWorkflowsService({ prisma }); + +/** Singleton handlers; they are stateless and only close over the injected service. */ +export const workflowsHandlers = createWorkflowsHandlers(service); + +const getUserId = (authentication: TV3Authentication): string | null => + authentication && "user" in authentication && authentication.user?.id ? authentication.user.id : null; + +/** + * Confirm a workflow trigger's referenced survey + ending cards exist in the workspace. Injected so + * `@formbricks/workflows` stays survey-agnostic. Scoped by the survey's `(id, workspaceId)` composite + * key; ending ids come from the survey's `endings`, parsed through `ZSurveyEndings` so the JSON + * column is validated (not accessed untyped) before reading ids. + */ +const verifyTriggerSurvey: WorkflowApiContext["verifyTriggerSurvey"] = async ({ + workspaceId, + surveyId, + endingCardIds, +}) => { + const survey = await prisma.survey.findUnique({ + where: { id_workspaceId: { id: surveyId, workspaceId } }, + select: { endings: true }, + }); + + if (!survey) { + return { surveyExists: false, missingEndingCardIds: [] }; + } + + const endingIds = new Set(ZSurveyEndings.parse(survey.endings).map((ending) => ending.id)); + return { + surveyExists: true, + missingEndingCardIds: endingCardIds.filter((endingCardId) => !endingIds.has(endingCardId)), + }; +}; + +/** + * Bind the framework-agnostic audit sink to this request's audit log. The handlers call it once, + * post-mutation, with the affected workflow id + workspace id + before/after snapshots; we copy + * those onto `auditLog` (target id, old/new object) so the v3 wrapper queues a complete Enterprise + * event. + * + * Organization resolution: the API-key path already set `auditLog.organizationId` from the key's + * org (see `buildV3AuditLog`); the session path leaves it as `UNKNOWN_DATA`, so we resolve the + * workflow's real org from `detail.workspaceId` (a first-class field — never inferred from the + * snapshots, so snapshot reshaping or PII redaction can't silently regress it). Any failure is + * swallowed and logged — an audit problem must never break or alter an already-successful mutation. + */ +const buildRecordAudit = + ( + auditLog: TV3AuditLog, + authentication: TV3Authentication, + requestId: string + ): NonNullable => + async (detail: WorkflowAuditDetail) => { + try { + auditLog.targetId = detail.targetId; + auditLog.oldObject = detail.oldObject; + auditLog.newObject = detail.newObject; + + // API-key auth already carries the org; only the session path needs resolution. + const isApiKey = !!authentication && "apiKeyId" in authentication; + if (!isApiKey) { + auditLog.organizationId = await getOrganizationIdFromWorkspaceId(detail.workspaceId); + } + } catch (error) { + logger.withContext({ requestId }).error({ error }, "Failed to record workflow audit detail"); + } + }; + +/** + * Recipient allowlist for `send_email` actions. Injected so `@formbricks/workflows` stays + * organization-agnostic: given literal recipient emails, returns the subset that does NOT belong to + * the workspace's organization. Enable/test use it to block a workflow from silently forwarding + * response data to an arbitrary external inbox (ENG-2029). Emails are compared case-insensitively. + */ +const verifyRecipientsAllowed: WorkflowApiContext["verifyRecipientsAllowed"] = async ({ + workspaceId, + emails, +}) => { + const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); + const memberEmails = await getOrganizationMemberEmails(organizationId); + const disallowedEmails = emails.filter((email) => !memberEmails.has(normalizeEmailForComparison(email))); + return { disallowedEmails }; +}; + +export const buildWorkflowApiContext = ( + authentication: TV3Authentication, + requestId: string, + instance: string, + auditLog?: TV3AuditLog +): WorkflowApiContext => ({ + userId: getUserId(authentication), + requestId, + instance, + logger: logger.withContext({ requestId }), + // HMAC key for redacting PII markers in audit snapshots; reuses the app's audit/encryption secret + // so markers aren't offline-guessable. Injected as data to keep `@formbricks/workflows` agnostic. + auditRedactionKey: ENCRYPTION_KEY, + // Workspace access first, then the workflows entitlement (Cloud plan / self-hosted EE license) + // for the resolved organization. Every v3 route handler and MCP tool authorizes through this + // capability, so this is the single enforcement point for both surfaces; the returned problem + // Response short-circuits through the package's error mapping like any authorization failure. + authorize: async (workspaceId, access) => { + const authorized = await requireV3WorkspaceAccess( + authentication, + workspaceId, + access, + requestId, + instance + ); + if (authorized instanceof Response) { + return authorized; + } + const isWorkflowsEnabled = await getIsWorkflowsEnabled(authorized.organizationId); + if (!isWorkflowsEnabled) { + return problemForbidden(requestId, "Workflows are not enabled for this organization", instance); + } + return authorized; + }, + verifyTriggerSurvey, + verifyRecipientsAllowed, + ...(auditLog ? { recordAudit: buildRecordAudit(auditLog, authentication, requestId) } : {}), +}); diff --git a/apps/web/app/api/v3/workflows/route.ts b/apps/web/app/api/v3/workflows/route.ts new file mode 100644 index 000000000000..e68960c62059 --- /dev/null +++ b/apps/web/app/api/v3/workflows/route.ts @@ -0,0 +1,26 @@ +/** + * /api/v3/workflows — list and create workflow management resources. + * Session cookie or x-api-key; scope by workspaceId only. + * + * Thin adapter: authenticate via the shared wrapper, build the workflow API context, and delegate + * to the framework-agnostic handlers in `@formbricks/workflows/server`. + */ +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "./lib/context"; + +export const GET = withV3ApiWrapper({ + auth: "both", + handler: async ({ req, authentication, requestId, instance }) => + workflowsHandlers.list({ req, ctx: buildWorkflowApiContext(authentication, requestId, instance) }), +}); + +export const POST = withV3ApiWrapper({ + auth: "both", + action: "created", + targetType: "workflow", + handler: async ({ req, authentication, auditLog, requestId, instance }) => + workflowsHandlers.create({ + req, + ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog), + }), +}); diff --git a/apps/web/app/api/v3/workflows/runs/[runId]/route.ts b/apps/web/app/api/v3/workflows/runs/[runId]/route.ts new file mode 100644 index 000000000000..8d55ab1e5e03 --- /dev/null +++ b/apps/web/app/api/v3/workflows/runs/[runId]/route.ts @@ -0,0 +1,20 @@ +/** + * /api/v3/workflows/runs/{runId} — retrieve a single workflow run with its ordered step logs. + * Unknown / cross-workspace run ids return 403 (not 404) to avoid leaking existence. + * + * Thin adapter: the wrapper validates the path param with the contract schema, then delegates to + * the framework-agnostic handler in `@formbricks/workflows/server`. + */ +import { ZWorkflowRunIdInput } from "@formbricks/workflows"; +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context"; + +export const GET = withV3ApiWrapper({ + auth: "both", + schemas: { params: ZWorkflowRunIdInput }, + handler: async ({ parsedInput, authentication, requestId, instance }) => + workflowsHandlers.getRun({ + ctx: buildWorkflowApiContext(authentication, requestId, instance), + params: parsedInput.params, + }), +}); diff --git a/apps/web/app/api/v3/workflows/runs/route.ts b/apps/web/app/api/v3/workflows/runs/route.ts new file mode 100644 index 000000000000..c1624700673d --- /dev/null +++ b/apps/web/app/api/v3/workflows/runs/route.ts @@ -0,0 +1,17 @@ +/** + * /api/v3/workflows/runs — list workflow runs for a workspace (newest first). + * Session cookie or x-api-key; scoped by the required `workspaceId` query param, with optional + * `workflowId` / `responseId` / `filter[status][in]` / `filter[isDryRun][eq]` filters. A static + * `runs` segment, so it never collides with `/api/v3/workflows/{workflowId}`. + * + * Thin adapter: authenticate via the shared wrapper, build the workflow API context, and delegate + * to the framework-agnostic handlers in `@formbricks/workflows/server`. + */ +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { buildWorkflowApiContext, workflowsHandlers } from "../lib/context"; + +export const GET = withV3ApiWrapper({ + auth: "both", + handler: async ({ req, authentication, requestId, instance }) => + workflowsHandlers.listRuns({ req, ctx: buildWorkflowApiContext(authentication, requestId, instance) }), +}); diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 64b9e4bf47f4..95b71e403060 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -84,6 +84,8 @@ checksums: auth/oauth/scopes/profile: ea073dc28851ee1a00365047be103d50 auth/oauth/scopes/surveys_read: 8280264457fe7c3b759c82422052aed2 auth/oauth/scopes/surveys_write: fb128cebe62dfd5685d086d706c432c2 + auth/oauth/scopes/workflows_read: b3b216cd6e77b26500068bdf7afad004 + auth/oauth/scopes/workflows_write: c9ce0488b4188718bf4c235855ca5c5a auth/oauth/unknown_client: 561bc5afdb90606fbc1ed97f20698ead auth/password_compromised: d4ab0f66aaed039f956c2515328ed1c9 auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc @@ -122,6 +124,7 @@ checksums: common/accepted: ea2ed23f35f8b090b5a994ac64ec588a common/account: 01215c12fb1cdb93bd0c84c1382bef56 common/account_settings: df8e9882a1f5c75951f3a05ddfed72ba + common/act: b6e0bff3738c7597f2500bc124648103 common/action: c92af0bdf1698b0d10cf5b28d2ad4945 common/actions: c46571856723b03262fd33f511116298 common/actions_description: 8e35b1538d1006fa8470183310ad21ef @@ -156,6 +159,7 @@ checksums: common/archive: fa813ab3074103e5daad07462af25789 common/archived: cf5127ecfd7e43a35466a1ba5fe16450 common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652 + common/attempt: fbe2d20432424e164245e13ff9195310 common/attributes: 86d0ae6fea0fbb119722ed3841f8385a common/authorized_apps: ffed21922e6e9c0975ddcde28e0a266b common/back: f541015a827e37cb3b1234e56bc2aa3c @@ -164,6 +168,7 @@ checksums: common/bottom_left: af9c28e07d6a12af1f18bce2f580d93d common/bottom_right: aaef9a70ef795affc806c6d1853d8373 common/cancel: 2e2a849c2223911717de8caa2c71bade + common/canceled: 342a409089d71ce45725add1c62b0ade common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507 common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813 common/charts: 1da4564d89264c89de4ed28d7451b43e @@ -236,9 +241,11 @@ checksums: common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e common/edit: eee7f39ff90b18852afc1671f21fbaa9 + common/editor: 15d1c1521efc08cd482f7ac9e1df0acf common/elements: 8cb054d952b341e5965284860d532bc7 common/email: e7f34943a0c2fb849db1839ff6ef5cb5 common/enable: 463972a7a95f50f3105d09b92508f2cd + common/enabled: 20236664b7e62df0e767921b4450205f common/ending_card: 16d30d3a36472159da8c2dbd374dfe22 common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97 common/enterprise_license: e81bf506f47968870c7bd07245648a0d @@ -249,6 +256,7 @@ checksums: common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51 common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0 common/expand_rows: b6e06327cb8718dfd6651720843e4dad + common/failed: 99f87615af1fffa2b8802866b096705a common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784 common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4 @@ -259,6 +267,7 @@ checksums: common/file_upload_service_unavailable: 93a6a904cef89cc18d2c4a65e2d581cc common/filter: 626325a05e4c8800f7ede7012b0cadaf common/finish: ffa7a10f71182b48fefed7135bee24fa + common/finished_at: 05d2fa31cf3b2e2255729ec7898240c2 common/first_name: cf040a5d6a9fd696be400380cc99f54b common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb common/full_name: f45991923345e8322c9ff8cd6b7e2b16 @@ -281,6 +290,7 @@ checksums: common/imprint: c4e5f2a1994d3cc5896b200709cc499c common/in_progress: 3de9afebcb9d4ce8ac42e14995f79ffd common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76 + common/input: c281c28cbb062bc3538cbd4a42d79cf6 common/integration: 40d02f65c4356003e0e90ffb944907d2 common/integrations: 0ccce343287704cd90150c32e2fcad36 common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf @@ -321,6 +331,7 @@ checksums: common/move_up: 69f25b205c677abdb26cbb69d97cd10b common/name: 9368b5a047572b6051f334af5aa76819 common/new_version_available: 399ddfc4232712e18ddab2587356b3dc + common/new_workflow: 556be5b31c361973a19d3e2f7375d4f3 common/next: 89ddbcf710eba274963494f312bdc8a9 common/no: 8c708225830b06df2d1141c536f2a0d6 common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4 @@ -359,10 +370,12 @@ checksums: common/other: 79acaa6cd481262bea4e743a422529d2 common/other_filters: 20b09213c131db47eb8b23e72d0c4bea common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081 + common/output: c1e7f08b62c52a91234e00b88f52acc2 common/overlay_color: 4b72073285d13fff93d094aabffe05ac common/overview: 30c54e4dc4ce599b87d94be34a8617f5 common/password: 223a61cf906ab9c40d22612c588dff48 common/paused: edb1f7b7219e1c9b7aa67159090d6991 + common/pending: 030a6f3395d5d4efddd3cc67d6009039 common/pending_downgrade: d6796fc1d4df21591c69fbbd39ba53ff common/people_manager: c1a2f206157ec618f9fe74bf99a06b85 common/person: b6e3064ca6b67285dc1ebb2590d6094f @@ -383,6 +396,7 @@ checksums: common/question: 2a47e06b62410b16003c4979dee0099f common/question_id: d0c3672976c281411bdccf749faf5ffd common/questions: 38d08215fd7a8026077c7b64eea6bb59 + common/queued: e19b621b39112ea373d249523599ac98 common/quota: edd33b180b463ee7a70a64a5c4ad7f02 common/quotas: e6afead11b5b8ae627885ce2b84a548f common/quotas_description: a2caa44fa74664b3b6007e813f31a754 @@ -395,15 +409,20 @@ checksums: common/replace: 98b2268975b1a737b2e4ad837df96703 common/report_survey: 147dd05db52e35f5d1f837460fb720f5 common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd + common/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5 common/reset_to_default: 68ee98b46677392f44b505b268053b26 common/resize: 20887e5af5294f08bc72cdedeee6e7a8 common/response: c7a9d88269d8ff117abcbc0d97f88b2c + common/response_completed: d4c3d86374bb9ff3fef904ccf5c3a3af common/response_id: 73375099cc976dc7203b8e27f5f709e0 common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28 common/restart: bab6232e89f24e3129f8e48268739d5b common/retry: 6e44d18639560596569a1278f9c83676 common/role: 53743bbb6ca938f5b893552e839d067f common/row_n: f90f7018a69f2d7025ad99a90bd23dc9 + common/run_data: 5bad3e03035c08da471b768ba73e61cf + common/running: 010d4795c3d5df31edde92a3441d7017 + common/runs: 8d49d75b6db650c168fb8465270b90fd common/saas: f01686245bcfb35a3590ab56db677bdb common/sales: 38758eb50094cd8190a71fe67be4d647 common/save: f7a2929f33bc420195e59ac5a8bcd454 @@ -439,12 +458,16 @@ checksums: common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836 common/sort_by: 8adf3dbc5668379558957662f0c43563 + common/sort_by_value: 3a1cd12ad5811afe085f5751cae9ec6c + common/started_at: 16d19d3011b3045aadc90809d09eb820 common/status: 4e1fcce15854d824919b4a582c697c90 + common/steps: 12d58a289584cb648103d66b327ae833 common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6 common/string: 4ddccc1974775ed7357f9beaf9361cec common/styling: 240fc91eb03c52d46b137f82e7aec2a1 common/subheader: 73a37d57cb9807e574a42bd0c7e334ed common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e + common/succeeded: 5a6a378853fa4ad6790e5fcaf8695b7c common/summary: 13eb7b8a239fb4702dfdaee69100a220 common/survey: b659d270a53dada994d926e0cc6e9a54 common/survey_completed: 5d1974ef76d4436daee96b2b76eddd20 @@ -477,8 +500,11 @@ checksums: common/trial_expired: ca9f0532ac40ca427ca1ba4c86454e07 common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5 common/trial_plan_badge: b7928bd1938c56199e7d8aada43e587e + common/trigger: 25f7594d1ac2f32a3d2774dcd11dddfe + common/trigger_payload: 50e9c46ecee798b95d23abf3896b86e7 common/try_again: 33dd8820e743e35a66e6977f69e9d3b5 common/type: f04471a7ddac844b9ad145eb9911ef75 + common/unarchive: 671fc7e9d7c8cb4d182a25a46551c168 common/undo: 6fa10b811e2894dcdd73718f66c1b481 common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0 common/update: 079fc039262fd31b10532929685c2d1b @@ -498,6 +524,7 @@ checksums: common/verified_email: d4a9e5e47d622c6ef2fede44233076c7 common/video: 8050c90e4289b105a0780f0fdda6ff66 common/view: 36a9b5e3dc153c036d320460d72a03c3 + common/view_workflow: c3d3675fc33f5793cfe5fa86155224bb common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9 common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c @@ -507,6 +534,9 @@ checksums: common/weeks: 545de30df4f44d3f6d1d344af6a10815 common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7 common/whats_new: e426b9bfcc875da20c66192faa148206 + common/workflow_name: faf88fb043779199c6bdcd78e45ff7c4 + common/workflow_runs: 80c2d42cb9f4550f0f56ebe72c42a03d + common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72 common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1 common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf @@ -570,7 +600,6 @@ checksums: emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9 emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9 - emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5 emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06 emails/select_a_date: 521e4a705800da06d091fde3e801ce02 emails/survey_response_finished_email_congrats: 4cc39698d6a16f68cf7d9db902c5978e @@ -2358,6 +2387,7 @@ checksums: workspace/settings/billing/comparison_row_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db workspace/settings/billing/comparison_row_unify_feedback: a5f13efd8f6df6163a2b4372b50d232e workspace/settings/billing/comparison_row_unlimited_seats: 478092018a27a4e588cb5c70968ef11a + workspace/settings/billing/comparison_row_workflows: b0c9c8615a9ba7d9cb73e767290a7f72 workspace/settings/billing/comparison_row_workspaces: 8ba082a84aa35cf851af1cf874b853e2 workspace/settings/billing/comparison_section_all_plans: 2a4543269f18647b8b2bd5f5cb399c24 workspace/settings/billing/comparison_section_basic_usage: 16bd8c505c287b38de311ad6a2eaa3e9 @@ -2444,6 +2474,7 @@ checksums: workspace/settings/billing/plan_scale_feature_responses: f2be033ebf6c86a664b812b4a918647f workspace/settings/billing/plan_scale_feature_security: 6671961cf8d8413d1740b13901bcc033 workspace/settings/billing/plan_scale_feature_semantic_analysis: 1441e34cacd26f0aa27af4ebad6e5c54 + workspace/settings/billing/plan_scale_feature_workflows: b0c9c8615a9ba7d9cb73e767290a7f72 workspace/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef workspace/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599 workspace/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d @@ -3048,7 +3079,10 @@ checksums: workspace/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683 workspace/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87 workspace/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88 + workspace/surveys/edit/follow_ups_modal_action_email_already_added: 14fdaf283e2f26c7fca18c803a1afd58 workspace/surveys/edit/follow_ups_modal_action_email_content: 9825583500908e6b16f7ffffb5a3aef4 + workspace/surveys/edit/follow_ups_modal_action_email_input_placeholder: ea0d5679e1d1c3219ca3c61fd9afdabe + workspace/surveys/edit/follow_ups_modal_action_email_invalid: 8de4bc8832b11b380bc4cbcedc16e48b workspace/surveys/edit/follow_ups_modal_action_email_settings: 18728b7e2096854c12f442e323dc10c0 workspace/surveys/edit/follow_ups_modal_action_from_description: 6bc80080a4a3513e0e7c7e7194b2d4ad workspace/surveys/edit/follow_ups_modal_action_from_label: 3d84daca8c92c8609deeab4b294b4afb @@ -3076,6 +3110,7 @@ checksums: workspace/surveys/edit/follow_ups_modal_trigger_type_response: 8b0e49e76ba09241f512201871bef0f2 workspace/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a workspace/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548 + workspace/surveys/edit/follow_ups_workflows_alert_title: eeeab44d06dae5afe46c6b990154a1c6 workspace/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e workspace/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf workspace/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1 @@ -3973,6 +4008,117 @@ checksums: workspace/unify/value_id: 5e0222661639b5ef6aab7ccff11363a1 workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675 workspace/unify/value_text: e097a597cc507c716401ad18255de578 + workspace/workflows/add_action: 66fefc4dd6a7b939c2224272cf0d2669 + workspace/workflows/add_trigger: c905d42a09d57b725c9dbad1837ad981 + workspace/workflows/add_trigger_description: 4a99aa50c2d2ed4c1a5f8379d9277766 + workspace/workflows/all_changes_saved: 1aeb862fc41b69307e3acc682b41818b + workspace/workflows/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee + workspace/workflows/archive_confirm_body: faab86aaacb71d5b5dd7b63c00f534c3 + workspace/workflows/archive_confirm_title: 2ddbf492c023f8fef2df49a008afbcd4 + workspace/workflows/archive_failed: 111d35d82bc91d3e279f588a7d849d38 + workspace/workflows/archive_success: e0e280e53988b78d0f8caf26c8ea006b + workspace/workflows/archive_workflow: 728063f8bfe0b9eb1c64bee9772db71d + workspace/workflows/archive_workflow_confirmation: 83f351c186e67a3c5308af82bf971d65 + workspace/workflows/archive_workflow_description: 06e437998a762e504d7d0101b0c53565 + workspace/workflows/auto_layout: 553d4f054655684130a8b5bc8f800bf0 + workspace/workflows/autosave_failed: 5d9b22b31b83828d5eeaeae87d2b6d14 + workspace/workflows/autosave_failed_tooltip: f4cb6255fd863b741275a7ff6d2701c6 + workspace/workflows/autosave_failed_tooltip_rejected: 5f90aa698c936629a7b959520dc9358e + workspace/workflows/collapse_inspector: d3bd60a39c7d42e98c78df07c756ac22 + workspace/workflows/create_failed: b67532a43f2b040b3738ffba1d3c0ce7 + workspace/workflows/delete_failed: df67d2e7a4952b19c8371e592ea03c65 + workspace/workflows/delete_success: 4b203fb057b84c888518b5ee885d7533 + workspace/workflows/delete_workflow_confirmation: c282ef533424f60f04f8ff2da3354eb7 + workspace/workflows/disable_failed: 02640d94b113075d6e03d50dac8be8a3 + workspace/workflows/disable_success: d107eb1a5c608c93695eecbc31bf26df + workspace/workflows/duplicate_failed: 56b15c01ba3e3fafbfd226520023204f + workspace/workflows/duplicate_success: b94c796f27301d2957a710dc06e73679 + workspace/workflows/edit_blocked_active: 11f02e65cffdf7a8533906eff1de4ffb + workspace/workflows/email_attach_response_data_description: 7d312092941888d63361690ee8706102 + workspace/workflows/email_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683 + workspace/workflows/email_body_label: e88eb1ea71f5ef886aa43ea6ba292d87 + workspace/workflows/email_body_placeholder: 5d75f0b732254eff49d0e8c5c40de7cd + workspace/workflows/email_body_required: ec8193d07de034ad764978b538d49d06 + workspace/workflows/email_from_label: 3d84daca8c92c8609deeab4b294b4afb + workspace/workflows/email_include_hidden_fields_label: 8b72f10c43491126650c723819faa454 + workspace/workflows/email_include_variables_label: 9710532ac5935c4540bf98bc292c1166 + workspace/workflows/email_needs_survey: 8d8843fe947bac3ef178d13ef96940fe + workspace/workflows/email_reply_to_label: e7f83bcc57c1e4248e6835477cd8e28a + workspace/workflows/email_set_up_trigger: fbfe074697929bec889438a92497c8cb + workspace/workflows/email_subject_label: de5b885eb327b2f233f3b67aab4c4c0a + workspace/workflows/email_subject_placeholder: 9cd2b8d4de6fb9daade571497bc39864 + workspace/workflows/email_subject_required: e6eb6e2f6f952f58bcbc6931ca303079 + workspace/workflows/email_to_label: fc84f35b3c44796dfcdd4b096c9b8d3d + workspace/workflows/email_to_placeholder: 1323bfdf1926a863c93bc0b37ae61218 + workspace/workflows/email_to_required: d29dd2c5bdebdde1047f4baae12269a4 + workspace/workflows/enable_blocked_unsaved_changes: 07fc359db9158b09d612f4f0e0e2df5e + workspace/workflows/enable_failed: 997437c65cc018c4d3cf7622e043d48d + workspace/workflows/enable_success: 384d5e80e013c74f8e092f41ed5bae5c + workspace/workflows/expand_inspector: 8d05989d5b8c5b6012b5c3e2651c0a92 + workspace/workflows/if_else: a9f31cee9ec7c33fa80d6f8dedf9060a + workspace/workflows/if_else_summary: f166c79dcfd8a9595ac364c96caaf7e1 + workspace/workflows/inspector_unsupported_node: 478f0de84863ccf95702428b1cc95be0 + workspace/workflows/load_failed: 4e79a59e05cfc390294673e4aa111f02 + workspace/workflows/name_required: a2866fa94293bc08be10ef65e0939423 + workspace/workflows/no_results_description: b358733531cc8290f8b311a8cc3bf05d + workspace/workflows/no_results_title: e3f2c57c4024d33a20f192d45284d709 + workspace/workflows/no_workflows_description: 8a8152d86459e52a07e1ab28bd347121 + workspace/workflows/no_workflows_title: f1a5ada694cc4d773d2b56b105306a09 + workspace/workflows/node_actions: 60413ce1864388744a65164a552d5079 + workspace/workflows/node_needs_email_content: 46bee688ab2d335136684536d8fdf69f + workspace/workflows/node_needs_survey: 5e51ca0392e98011a8a72e75a223e5dd + workspace/workflows/pan_mode: 7d97982816ad18072903021c89153bce + workspace/workflows/pointer_mode: 7af7ee96c6d8e9a15f0569c1b2376def + workspace/workflows/read_only: 9109fd6e72125af271d41181f1f35dd2 + workspace/workflows/relative_date: b9249095eeb4436a1d0d5ca5f9ecd346 + workspace/workflows/relative_days_ago: d9da4648c4fa3cb86584d3f9f48df6a2 + workspace/workflows/relative_today: 86945b299a1fb38b11917873dd0bea40 + workspace/workflows/relative_yesterday: dfc275052c70eb9e21522e13a7e77e1e + workspace/workflows/response_completed: d4c3d86374bb9ff3fef904ccf5c3a3af + workspace/workflows/response_completed_description: 2b7facad97819a57c830e16bcf8dd582 + workspace/workflows/save_failed: 959fe0d656afa7990c9fc476f2a4c7bf + workspace/workflows/save_success: 95ac20f8626471d0372614fb0831e016 + workspace/workflows/saving_changes: ee98ff32f9a585a9108c8cd961d6077e + workspace/workflows/search_by_workflow_name: 9ce39ab494b1fa76252aa9bee55d22f8 + workspace/workflows/send_email: 0ef83c0bb40de25921a9ee7fa05babec + workspace/workflows/send_email_description: a05ecd60166cdb3e3667e674afae556b + workspace/workflows/send_email_summary: 23c31e7b1539ee670994c0e70cf7fdfe + workspace/workflows/send_email_unconfigured: 306f5a6768ec08c316c3234ce11f8b32 + workspace/workflows/trigger_ending_cards_label: 376d9519a9eb7c4dff7a6c1ab4d7d3ae + workspace/workflows/trigger_ending_cards_none: baf1774aad23796cc5325989015e0487 + workspace/workflows/trigger_ending_cards_pick_survey: 8238b366d1bf21f1192110e6963939d7 + workspace/workflows/trigger_ending_cards_scope_all: 7f134a310395059accd3fd141af9b345 + workspace/workflows/trigger_ending_cards_scope_specific: 5db478fd10c210d5a3def79b670f0d43 + workspace/workflows/trigger_ending_cards_select_at_least_one: 3d7d6a487662d8c5ad84703fb8f5ad7e + workspace/workflows/trigger_summary_all_endings: 00342d8a7bd08cefd89dae7d6f88cf5b + workspace/workflows/trigger_summary_ending_cards: efbd2748e2d99cc1c624f54b9a7779b0 + workspace/workflows/trigger_survey_description: 29db20a64eeb3fc7b690f72883f7f538 + workspace/workflows/trigger_survey_empty: 922f064c2082c3e71fc17001ef61cc55 + workspace/workflows/trigger_survey_label: b659d270a53dada994d926e0cc6e9a54 + workspace/workflows/trigger_survey_placeholder: 1f49086dfb874307aae1136e88c3d514 + workspace/workflows/triggers: 66488f38662a4199fb8a18967239c992 + workspace/workflows/unarchive: 671fc7e9d7c8cb4d182a25a46551c168 + workspace/workflows/unarchive_failed: 16568ba93941e8e71f9b4cc997d5eaf2 + workspace/workflows/unarchive_success: b1ae787ba6e1d4e9e8631ad35d73e5ee + workspace/workflows/upgrade_prompt_description: 526faca22b7a0b613fdce0ace49c0aa7 + workspace/workflows/upgrade_prompt_title: 5546aa81302c33f2b53857b9c62c16e2 + workspace/workflows/validation_failed: fbba0e27eca222e81a86d3036919d11a + workspace/workflows/validation_problem_fix_label: fe748ab52e0ad49e8a96247ec5814bb2 + workspace/workflows/validation_problem_flow_invalid: e2aaaf90f60d72553f93c3153202c542 + workspace/workflows/validation_problem_generic: a58601a8dd434a0ee3f8cdd4aca86213 + workspace/workflows/validation_problem_name_missing: b116eed6f771dff219090d142c1f6bd1 + workspace/workflows/validation_problem_step_incomplete: 7736f8e9de3307aed23ec50790122aa0 + workspace/workflows/validation_problem_step_not_executable: 144beddd4ac41cececf07882089f08d8 + workspace/workflows/validation_problem_trigger_ending_not_found: 2a288324d1a1c0c96dfb24ec7df1111b + workspace/workflows/validation_problem_trigger_missing: 7a9243dac736efe6023063de872b716f + workspace/workflows/validation_problem_trigger_not_connected: 69a851839a4179bb9c2e995140b2ba93 + workspace/workflows/validation_problem_trigger_survey_unbound: ad18f1043275f229a8d4d2ff5ffc4e45 + workspace/workflows/validation_problems_count: 1855046d8540d697167fa8d2be11beba + workspace/workflows/validation_problems_description: 3d86d4b78cd9444a4d0161e13c29523f + workspace/workflows/validation_problems_title: dca6132844fcd065bc5ab7fd2dd3ca40 + workspace/workflows/validation_status_valid: 4a3ddc0ebe876ff26001201203348d2c + workspace/workflows/zoom_in: 20244d74cc44e8606cbf4bb8b4e16e4b + workspace/workflows/zoom_out: b723e467be28d8659338502c470821fd workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9 workspace/xm-templates/ces_description: d90ab573eed017c45e45527a325c9bda workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337 diff --git a/apps/web/instrumentation-jobs.test.ts b/apps/web/instrumentation-jobs.test.ts index 1bfb1a11cb36..ae6998069df7 100644 --- a/apps/web/instrumentation-jobs.test.ts +++ b/apps/web/instrumentation-jobs.test.ts @@ -13,6 +13,10 @@ const mockGetJobsWorkerBootstrapConfig = vi.fn(); const mockProcessResponsePipelineJob = vi.fn(); const mockProcessSurveySchedulingJob = vi.fn(); const mockProcessSurveyArchivePurgeJob = vi.fn(); +const mockProcessWorkflowRunJob = vi.fn(); +const mockRemoveRecurringWorkflowRunReconcileJobSchedule = vi.fn(); +const mockUpsertRecurringWorkflowRunReconcileJobSchedule = vi.fn(); +const mockProcessWorkflowRunReconcileJob = vi.fn(); const TEST_TIMEOUT_MS = 15_000; const slowTest = (name: string, fn: () => Promise): void => { @@ -22,9 +26,11 @@ const slowTest = (name: string, fn: () => Promise): void => { vi.mock("@formbricks/jobs", () => ({ removeRecurringSurveySchedulingJobSchedule: mockRemoveRecurringSurveySchedulingJobSchedule, removeRecurringSurveyArchivePurgeJobSchedule: mockRemoveRecurringSurveyArchivePurgeJobSchedule, + removeRecurringWorkflowRunReconcileJobSchedule: mockRemoveRecurringWorkflowRunReconcileJobSchedule, startJobsRuntime: mockStartJobsRuntime, upsertRecurringSurveySchedulingJobSchedule: mockUpsertRecurringSurveySchedulingJobSchedule, upsertRecurringSurveyArchivePurgeJobSchedule: mockUpsertRecurringSurveyArchivePurgeJobSchedule, + upsertRecurringWorkflowRunReconcileJobSchedule: mockUpsertRecurringWorkflowRunReconcileJobSchedule, })); vi.mock("@/lib/jobs/config", () => ({ @@ -53,6 +59,14 @@ vi.mock("@/modules/survey/archive/lib/process-survey-archive-purge-job", () => ( processSurveyArchivePurgeJob: mockProcessSurveyArchivePurgeJob, })); +vi.mock("@/modules/ee/workflows/lib/runner/process-workflow-run-job", () => ({ + processWorkflowRunJob: mockProcessWorkflowRunJob, +})); + +vi.mock("@/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job", () => ({ + processWorkflowRunReconcileJob: mockProcessWorkflowRunReconcileJob, +})); + describe("instrumentation-jobs", () => { beforeEach(() => { vi.resetModules(); @@ -65,6 +79,7 @@ describe("instrumentation-jobs", () => { name: "survey-archive-purge.process", queueName: "background-jobs", }); + mockRemoveRecurringWorkflowRunReconcileJobSchedule.mockResolvedValue(true); mockGetJobsQueueingConfig.mockReturnValue({ enabled: false, redisUrl: null, @@ -124,7 +139,9 @@ describe("instrumentation-jobs", () => { "response-pipeline.process": expect.any(Function), "survey-scheduling.reconcile": expect.any(Function), "survey-archive-purge.process": expect.any(Function), + "workflow-run.process": expect.any(Function), "test-log.process": mockExistingOverride, + "workflow-run.reconcile": expect.any(Function), }, redisUrl: "redis://localhost:6379", workerCount: 2, @@ -132,6 +149,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?.( { @@ -160,6 +178,20 @@ describe("instrumentation-jobs", () => { queueName: "background-jobs", } ); + await workflowRunOverride?.( + { + workflowRunId: "run_123", + workflowId: "wf_123", + workspaceId: "ws_123", + }, + { + attempt: 1, + jobId: "job_789", + jobName: "workflow-run.process", + maxAttempts: 3, + queueName: "background-jobs", + } + ); expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith( { @@ -188,6 +220,42 @@ describe("instrumentation-jobs", () => { queueName: "background-jobs", } ); + + const workflowRunReconcileOverride = overrides?.["workflow-run.reconcile"]; + await workflowRunReconcileOverride?.( + { scope: "global" }, + { + attempt: 1, + jobId: "job_789", + jobName: "workflow-run.reconcile", + maxAttempts: 3, + queueName: "background-jobs", + } + ); + expect(mockProcessWorkflowRunJob).toHaveBeenCalledWith( + { + workflowRunId: "run_123", + workflowId: "wf_123", + workspaceId: "ws_123", + }, + { + attempt: 1, + jobId: "job_789", + jobName: "workflow-run.process", + maxAttempts: 3, + queueName: "background-jobs", + } + ); + expect(mockProcessWorkflowRunReconcileJob).toHaveBeenCalledWith( + { scope: "global" }, + { + attempt: 1, + jobId: "job_789", + jobName: "workflow-run.reconcile", + maxAttempts: 3, + queueName: "background-jobs", + } + ); }); slowTest("reuses the in-flight startup promise", async () => { @@ -288,12 +356,19 @@ describe("instrumentation-jobs", () => { name: "survey-scheduling.reconcile", queueName: "background-jobs", }); + mockUpsertRecurringWorkflowRunReconcileJobSchedule.mockResolvedValue({ + id: "schedule-job-2", + name: "workflow-run.reconcile", + queueName: "background-jobs", + }); const { registerRecurringJobs } = await import("./instrumentation-jobs"); const { SURVEY_SCHEDULING_DAILY_CRON_PATTERN, SURVEY_SCHEDULING_TIME_ZONE } = await import("@/modules/survey/scheduling/lib/constants"); const { SURVEY_ARCHIVE_PURGE_DAILY_CRON_PATTERN, SURVEY_ARCHIVE_PURGE_TIME_ZONE } = await import("@/modules/survey/archive/lib/constants"); + const { WORKFLOW_RUN_RECONCILE_INTERVAL_MS } = + await import("@/modules/ee/workflows/lib/runner/reconcile-constants"); await registerRecurringJobs(); await registerRecurringJobs(); @@ -339,6 +414,25 @@ describe("instrumentation-jobs", () => { scope: "global", } ); + expect(mockRemoveRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledTimes(1); + expect(mockRemoveRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledWith({ + scheduleId: "workflow-run-reconcile", + scope: "global", + }); + expect(mockUpsertRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledTimes(1); + expect(mockUpsertRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledWith( + { + scheduleId: "workflow-run-reconcile", + scope: "global", + }, + { + everyMs: WORKFLOW_RUN_RECONCILE_INTERVAL_MS, + kind: "every", + }, + { + scope: "global", + } + ); } ); diff --git a/apps/web/instrumentation-jobs.ts b/apps/web/instrumentation-jobs.ts index 82a0b77a1584..e4033b1fdcd2 100644 --- a/apps/web/instrumentation-jobs.ts +++ b/apps/web/instrumentation-jobs.ts @@ -4,14 +4,25 @@ import { type TResponsePipelineJobData, type TSurveyArchivePurgeJobData, type TSurveySchedulingJobData, + type TWorkflowRunJobData, + type TWorkflowRunReconcileJobData, removeRecurringSurveyArchivePurgeJobSchedule, removeRecurringSurveySchedulingJobSchedule, + removeRecurringWorkflowRunReconcileJobSchedule, startJobsRuntime, upsertRecurringSurveyArchivePurgeJobSchedule, upsertRecurringSurveySchedulingJobSchedule, + upsertRecurringWorkflowRunReconcileJobSchedule, } from "@formbricks/jobs"; import { logger } from "@formbricks/logger"; import { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } from "@/lib/jobs/config"; +import { processWorkflowRunJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-job"; +import { processWorkflowRunReconcileJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job"; +import { + WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE, + WORKFLOW_RUN_RECONCILE_INTERVAL_MS, + WORKFLOW_RUN_RECONCILE_SCHEDULE_ID, +} from "@/modules/ee/workflows/lib/runner/reconcile-constants"; import { processResponsePipelineJob } from "@/modules/response-pipeline/lib/process-response-pipeline-job"; import { SURVEY_ARCHIVE_PURGE_DAILY_CRON_PATTERN, @@ -43,6 +54,8 @@ const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal; const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process"; const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile"; const SURVEY_ARCHIVE_PURGE_JOB_NAME = "survey-archive-purge.process"; +const WORKFLOW_RUN_JOB_NAME = "workflow-run.process"; +const WORKFLOW_RUN_RECONCILE_JOB_NAME = "workflow-run.reconcile"; const responsePipelineJobHandler: NonNullable = async (data, context) => { await processResponsePipelineJob(data as TResponsePipelineJobData, context); @@ -53,6 +66,12 @@ const surveySchedulingJobHandler: NonNullable = asy const surveyArchivePurgeJobHandler: NonNullable = async (data, context) => { await processSurveyArchivePurgeJob(data as TSurveyArchivePurgeJobData, context); }; +const workflowRunJobHandler: NonNullable = async (data, context) => { + await processWorkflowRunJob(data as TWorkflowRunJobData, context); +}; +const workflowRunReconcileJobHandler: NonNullable = async (data, context) => { + await processWorkflowRunReconcileJob(data as TWorkflowRunReconcileJobData, context); +}; const registerSurveySchedulingSchedule = async (): Promise => { await removeRecurringSurveySchedulingJobSchedule({ @@ -98,6 +117,27 @@ const registerSurveyArchivePurgeSchedule = async (): Promise => { ); }; +const registerWorkflowRunReconcileSchedule = async (): Promise => { + await removeRecurringWorkflowRunReconcileJobSchedule({ + scheduleId: WORKFLOW_RUN_RECONCILE_SCHEDULE_ID, + scope: WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE, + }); + + await upsertRecurringWorkflowRunReconcileJobSchedule( + { + scheduleId: WORKFLOW_RUN_RECONCILE_SCHEDULE_ID, + scope: WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE, + }, + { + everyMs: WORKFLOW_RUN_RECONCILE_INTERVAL_MS, + kind: "every", + }, + { + scope: WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE, + } + ); +}; + const clearRecurringJobsRetryTimeout = (): void => { if (globalForJobsRuntime.formbricksJobsRecurringRetryTimeout) { clearTimeout(globalForJobsRuntime.formbricksJobsRecurringRetryTimeout); @@ -169,6 +209,7 @@ export const registerRecurringJobs = async (): Promise => { globalForJobsRuntime.formbricksJobsRecurringRegistration = (async () => { await registerSurveySchedulingSchedule(); await registerSurveyArchivePurgeSchedule(); + await registerWorkflowRunReconcileSchedule(); clearRecurringJobsRetryTimeout(); globalForJobsRuntime.formbricksJobsRecurringRegistered = true; globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined; @@ -208,11 +249,15 @@ export const registerJobsWorker = async (): Promise => [RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler, [SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler, [SURVEY_ARCHIVE_PURGE_JOB_NAME]: surveyArchivePurgeJobHandler, + [WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler, + [WORKFLOW_RUN_RECONCILE_JOB_NAME]: workflowRunReconcileJobHandler, } : { [RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler, [SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler, [SURVEY_ARCHIVE_PURGE_JOB_NAME]: surveyArchivePurgeJobHandler, + [WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler, + [WORKFLOW_RUN_RECONCILE_JOB_NAME]: workflowRunReconcileJobHandler, }; globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => { diff --git a/apps/web/integration/gen-boolean-client.mjs b/apps/web/integration/gen-boolean-client.mjs index 99f77d63d714..7da92e82cc44 100644 --- a/apps/web/integration/gen-boolean-client.mjs +++ b/apps/web/integration/gen-boolean-client.mjs @@ -1,50 +1,70 @@ // Generates a parallel Prisma client whose `emailVerified` is Boolean and `Account.type` is optional // — i.e. the POST-CUTOVER shape Better Auth reads/writes (ENG-1054). The integration harness aliases // @formbricks/database to a shim backed by this client so BA's real user/account creation works -// against a real Postgres before the live schema is flipped. Derived from schema.prisma so it never -// drifts. Output (generated/prisma-test) is gitignored. Run via `pnpm test:integration`. +// against a real Postgres before the live schema is flipped. Derived from the multi-file schema in +// packages/database/schema/ so it never drifts. Output (generated/prisma-test) is gitignored. Run via +// `pnpm test:integration`. import { execFileSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { delimiter, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const dbDir = resolve(here, "../../../packages/database"); -const srcSchema = resolve(dbDir, "schema.prisma"); -const testSchema = resolve(dbDir, "schema.test-boolean.prisma"); +const srcSchemaDir = resolve(dbDir, "schema"); +const testSchemaDir = resolve(dbDir, "schema-test-boolean"); -let schema = readFileSync(srcSchema, "utf8"); +rmSync(testSchemaDir, { recursive: true, force: true }); +mkdirSync(testSchemaDir, { recursive: true }); -const before = schema; -// 1. separate output dir so the real client is never clobbered -schema = schema.replace('"./generated/prisma"', '"./generated/prisma-test"'); -// 2. emailVerified Date → Boolean (what BA writes) -schema = schema.replace( - 'emailVerified DateTime? @map(name: "email_verified")', - 'emailVerified Boolean @default(false) @map(name: "email_verified")' -); -// 3. Account.type optional (BA creates accounts without a `type`), scoped to the Account model. -// Locate the block by index (no schema-spanning regex) and patch only its `type` field. The -// intra-line whitespace classes are `[ \t]` (never `\n`), so the match can't run across lines — -// that's what keeps it linear; a `[\s\S]*?`/`\s*` pattern backtracks super-linearly because those -// classes also match newlines. No-ops cleanly if `type` is already `String?`. -const accountStart = schema.indexOf("model Account {"); -const accountEnd = accountStart === -1 ? -1 : schema.indexOf("}", accountStart); -if (accountStart === -1 || accountEnd === -1) { - throw new Error("gen-boolean-client: Account model block not found — schema.prisma shape changed; update this script."); +let patchedAny = false; +let accountFound = false; + +for (const file of readdirSync(srcSchemaDir)) { + if (!file.endsWith(".prisma")) continue; + let schema = readFileSync(resolve(srcSchemaDir, file), "utf8"); + const before = schema; + + // 1. separate output dir so the real client is never clobbered (path is relative to the schema dir) + schema = schema.replace('"../generated/prisma"', '"../generated/prisma-test"'); + // 2. emailVerified Date → Boolean (what BA writes); no-ops once the live schema is flipped + schema = schema.replace( + 'emailVerified DateTime? @map(name: "email_verified")', + 'emailVerified Boolean @default(false) @map(name: "email_verified")' + ); + // 3. Account.type optional (BA creates accounts without a `type`), scoped to the Account model. + // Locate the block by index (no schema-spanning regex) and patch only its `type` field. The + // intra-line whitespace classes are `[ \t]` (never `\n`), so the match can't run across lines — + // that's what keeps it linear; a `[\s\S]*?`/`\s*` pattern backtracks super-linearly because those + // classes also match newlines. No-ops cleanly if `type` is already `String?`. + const accountStart = schema.indexOf("model Account {"); + if (accountStart !== -1) { + accountFound = true; + const accountEnd = schema.indexOf("}", accountStart); + if (accountEnd === -1) { + throw new Error( + "gen-boolean-client: Account model block not terminated — schema shape changed; update this script." + ); + } + schema = + schema.slice(0, accountStart) + + schema.slice(accountStart, accountEnd).replace(/\n([ \t]*)type([ \t]+)String(\s)/, "\n$1type$2String?$3") + + schema.slice(accountEnd); + } + + if (schema !== before) patchedAny = true; + writeFileSync(resolve(testSchemaDir, file), schema); } -schema = - schema.slice(0, accountStart) + - schema.slice(accountStart, accountEnd).replace(/\n([ \t]*)type([ \t]+)String(\s)/, "\n$1type$2String?$3") + - schema.slice(accountEnd); -if (schema === before) { - throw new Error("gen-boolean-client: no replacements applied — schema.prisma shape changed; update this script."); +if (!accountFound) { + throw new Error("gen-boolean-client: Account model block not found — schema shape changed; update this script."); +} +if (!patchedAny) { + throw new Error("gen-boolean-client: no replacements applied — schema shape changed; update this script."); } -writeFileSync(testSchema, schema); -console.log("[gen-boolean-client] derived", testSchema); +console.log("[gen-boolean-client] derived", testSchemaDir); // Invoke the Prisma CLI directly through Node by ABSOLUTE path, rather than `pnpm exec prisma`: // - process.execPath is a fixed, unwriteable path to the running Node binary, and the CLI path is @@ -62,7 +82,7 @@ const prismaBin = typeof prismaPkg.bin === "string" ? prismaPkg.bin : prismaPkg. const prismaDir = dirname(prismaPkgJson); const prismaCli = resolve(prismaDir, prismaBin); const nodeModulesBin = resolve(prismaDir, "../.bin"); -execFileSync(process.execPath, [prismaCli, "generate", "--schema", testSchema], { +execFileSync(process.execPath, [prismaCli, "generate", "--schema", testSchemaDir], { cwd: dbDir, stdio: "inherit", env: { ...process.env, PATH: `${nodeModulesBin}${delimiter}${process.env.PATH ?? ""}` }, diff --git a/apps/web/lib/jobs/pool-exhaustion.test.ts b/apps/web/lib/jobs/pool-exhaustion.test.ts new file mode 100644 index 000000000000..f0d772199ea8 --- /dev/null +++ b/apps/web/lib/jobs/pool-exhaustion.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { Prisma } from "@formbricks/database/prisma"; +import { DatabaseError } from "@formbricks/types/errors"; +import { isDatabasePoolExhaustionError } from "./pool-exhaustion"; + +describe("isDatabasePoolExhaustionError", () => { + test("is true for the Prisma P2024 pool-timeout code", () => { + const error = new Prisma.PrismaClientKnownRequestError("pool timeout", { + code: "P2024", + clientVersion: "test", + }); + expect(isDatabasePoolExhaustionError(error)).toBe(true); + }); + + test("is true for a connection-pool timeout message (plain Error or DatabaseError)", () => { + expect( + isDatabasePoolExhaustionError(new Error("Timed out fetching a new connection from the connection pool")) + ).toBe(true); + expect(isDatabasePoolExhaustionError(new DatabaseError("connection pool timeout while querying"))).toBe( + true + ); + }); + + test("is false for other Prisma codes, unrelated messages, and non-errors", () => { + const notFound = new Prisma.PrismaClientKnownRequestError("not found", { + code: "P2025", + clientVersion: "test", + }); + expect(isDatabasePoolExhaustionError(notFound)).toBe(false); + expect(isDatabasePoolExhaustionError(new Error("something unrelated"))).toBe(false); + expect(isDatabasePoolExhaustionError("nope")).toBe(false); + expect(isDatabasePoolExhaustionError(null)).toBe(false); + expect(isDatabasePoolExhaustionError(undefined)).toBe(false); + }); +}); diff --git a/apps/web/lib/jobs/pool-exhaustion.ts b/apps/web/lib/jobs/pool-exhaustion.ts new file mode 100644 index 000000000000..c2ddde69b9f0 --- /dev/null +++ b/apps/web/lib/jobs/pool-exhaustion.ts @@ -0,0 +1,24 @@ +import { Prisma } from "@formbricks/database/prisma"; +import { DatabaseError } from "@formbricks/types/errors"; + +/** + * True when an error is a transient database connection-pool exhaustion (Prisma `P2024`, or a + * connection-pool timeout surfaced as a message). These are retryable: a background job that hits + * one should propagate the error so it is retried, rather than swallow it and silently drop work. + * + * Shared by the response-pipeline job and the workflow runner enqueue so both classify retryable + * DB exhaustion the same way (and so the runner can rethrow it without importing the pipeline). + */ +export const isDatabasePoolExhaustionError = (error: unknown): boolean => { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2024") { + return true; + } + + if (error instanceof DatabaseError || error instanceof Error) { + return /Timed out fetching a new connection from the connection pool|connection pool timeout/i.test( + error.message + ); + } + + return false; +}; diff --git a/apps/web/lib/localStorage.ts b/apps/web/lib/localStorage.ts index 454cdf76f1a3..2581c6e38b12 100644 --- a/apps/web/lib/localStorage.ts +++ b/apps/web/lib/localStorage.ts @@ -1,4 +1,5 @@ export const FORMBRICKS_SURVEYS_FILTERS_KEY_LS = "formbricks-surveys-filters"; +export const FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS = "formbricks-workflows-filters"; export const FORMBRICKS_ENVIRONMENT_ID_LS = "formbricks-environment-id"; export const FORMBRICKS_WORKSPACE_ID_LS = "formbricks-workspace-id"; export const FORMBRICKS_LOGGED_IN_WITH_LS = "formbricks-logged-in-with"; diff --git a/apps/web/lib/organization/service.test.ts b/apps/web/lib/organization/service.test.ts index d92494a4e9bf..2f74dadf9dd4 100644 --- a/apps/web/lib/organization/service.test.ts +++ b/apps/web/lib/organization/service.test.ts @@ -12,6 +12,7 @@ import { createOrganization, deleteOrganization, getOrganization, + getOrganizationMemberEmails, getOrganizationsByUserId, select as organizationSelect, subscribeOrganizationMembersToSurveyResponses, @@ -34,6 +35,9 @@ vi.mock("@formbricks/database", () => ({ user: { findUnique: vi.fn(), }, + membership: { + findMany: vi.fn(), + }, }, })); @@ -201,6 +205,7 @@ describe("Organization Service", () => { workspaces: IS_FORMBRICKS_CLOUD ? 1 : 3, monthly: { responses: IS_FORMBRICKS_CLOUD ? 250 : 1500, + workflowRuns: null, }, }, stripeCustomerId: null, @@ -409,4 +414,51 @@ describe("Organization Service", () => { expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2"); }); }); + + describe("getOrganizationMemberEmails (send_email recipient allowlist, ENG-2029)", () => { + test("queries only active members of the organization", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([]); + + await getOrganizationMemberEmails("org_1"); + + expect(prisma.membership.findMany).toHaveBeenCalledWith({ + where: { organizationId: "org_1", user: { isActive: true } }, + select: { user: { select: { email: true } } }, + }); + }); + + test("returns a lowercased, whitespace-trimmed set for case-insensitive matching", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { user: { email: " Member@Corp.Example " } }, + { user: { email: "second@corp.example" } }, + ] as never); + + const result = await getOrganizationMemberEmails("org_1"); + + expect(result).toEqual(new Set(["member@corp.example", "second@corp.example"])); + }); + + test("drops memberships with a missing user or empty email", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { user: { email: "kept@corp.example" } }, + { user: null }, + { user: { email: null } }, + { user: { email: "" } }, + ] as never); + + const result = await getOrganizationMemberEmails("org_1"); + + expect(result).toEqual(new Set(["kept@corp.example"])); + }); + + test("wraps a known Prisma error in DatabaseError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("db down", { + code: "P2002", + clientVersion: "1.0.0", + }); + vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError); + + await expect(getOrganizationMemberEmails("org_1")).rejects.toThrow(DatabaseError); + }); + }); }); diff --git a/apps/web/lib/organization/service.ts b/apps/web/lib/organization/service.ts index e4a41adb5c67..f50a20f2d3fb 100644 --- a/apps/web/lib/organization/service.ts +++ b/apps/web/lib/organization/service.ts @@ -17,6 +17,7 @@ import { TUserNotificationSettings } from "@formbricks/types/user"; import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants"; import { updateUser } from "@/lib/user/service"; import { getBillingUsageCycleWindow } from "@/lib/utils/billing"; +import { normalizeEmailForComparison } from "@/lib/utils/email"; import { getWorkspaces } from "@/lib/workspace/service"; import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing"; import { deleteHubTenantData } from "@/modules/hub/service"; @@ -46,6 +47,8 @@ const getDefaultOrganizationBilling = (): TOrganizationBilling => ({ workspaces: IS_FORMBRICKS_CLOUD ? 1 : 3, monthly: { responses: IS_FORMBRICKS_CLOUD ? 250 : 1500, + // No included workflow runs by default (ENG-1936); the Scale entitlement grants the volume. + workflowRuns: null, }, }, stripeCustomerId: null, @@ -139,6 +142,42 @@ export const getOrganizationByWorkspaceId = reactCache( } ); +/** + * Lowercased set of the email addresses of every active member of an organization. Used as the + * recipient allowlist for workflow `send_email` actions (ENG-2029): a literal recipient address is + * only permitted when it belongs to an active organization member, so a workflow cannot silently + * forward response data to an arbitrary external inbox. Emails are lowercased for case-insensitive + * matching. + */ +export const getOrganizationMemberEmails = reactCache( + async (organizationId: string): Promise> => { + validateInputs([organizationId, ZString]); + + try { + // Only active users: a deactivated (soft-deleted) member has had access revoked and must not + // remain on the send_email recipient allowlist (ENG-2029). + const memberships = await prisma.membership.findMany({ + where: { organizationId, user: { isActive: true } }, + select: { user: { select: { email: true } } }, + }); + + return new Set( + memberships + .map((membership) => + membership.user?.email ? normalizeEmailForComparison(membership.user.email) : undefined + ) + .filter((email): email is string => Boolean(email)) + ); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + export const getOrganization = reactCache(async (organizationId: string): Promise => { validateInputs([organizationId, ZString]); diff --git a/apps/web/lib/utils/email.ts b/apps/web/lib/utils/email.ts index 0efb5a72f4ae..f4370deae11b 100644 --- a/apps/web/lib/utils/email.ts +++ b/apps/web/lib/utils/email.ts @@ -3,3 +3,11 @@ export const isValidEmail = (email: string): boolean => { const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i; return regex.test(email); }; + +/** + * Canonicalizes an email address for case- and whitespace-insensitive comparison. The workflow + * `send_email` recipient allowlist (ENG-2029) is built and queried through this single rule so the + * member-email set and every lookup against it cannot drift apart and silently weaken the + * fail-closed guarantee. + */ +export const normalizeEmailForComparison = (email: string): string => email.trim().toLowerCase(); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 0fe85e639453..6ab4c7779370 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Akzeptiert", "account": "Konto", "account_settings": "Kontoeinstellungen", + "act": "Handeln", "action": "Aktion", "actions": "Aktionen", "actions_description": "Code- und No-Code-Aktionen werden verwendet, um Intercept-Umfragen in Apps und auf Websites auszulösen.", @@ -185,6 +188,7 @@ "archive": "Archivieren", "archived": "Archiviert", "are_you_sure": "Bist du sicher?", + "attempt": "Versuch", "attributes": "Attribute", "authorized_apps": "Authorized Apps", "back": "Zurück", @@ -193,6 +197,7 @@ "bottom_left": "Unten links", "bottom_right": "Unten rechts", "cancel": "Abbrechen", + "canceled": "Abgebrochen", "centered_modal": "Zentriertes Modal", "chart": "Diagramm", "charts": "Diagramme", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(Kopie {copyNumber})", "e_commerce": "E-Commerce", "edit": "Bearbeiten", + "editor": "Editor", "elements": "Elemente", "email": "E-Mail", "enable": "Aktivieren", + "enabled": "Aktiviert", "ending_card": "Abschlusskarte", "enter_url": "URL eingeben", "enterprise_license": "Enterprise-Lizenz", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte versuche es später erneut.", "error_rate_limit_title": "Rate-Limit überschritten", "expand_rows": "Zeilen erweitern", + "failed": "Fehlgeschlagen", "failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage", "failed_to_load_organizations": "Fehler beim Laden der Organisationen", "failed_to_load_workspaces": "Workspaces konnten nicht geladen werden", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filter", "finish": "Fertig", + "finished_at": "Beendet um", "first_name": "Vorname", "formbricks_version": "Formbricks-Version", "full_name": "Vollständiger Name", @@ -310,6 +319,7 @@ "imprint": "Impressum", "in_progress": "In Bearbeitung", "inactive_surveys": "Inaktive Umfragen", + "input": "Eingabe", "integration": "Integration", "integrations": "Integrationen", "invalid_date_with_value": "Ungültiges Datum: {value}", @@ -350,6 +360,7 @@ "move_up": "Nach oben bewegen", "name": "Name", "new_version_available": "Formbricks {version} ist da. Jetzt upgraden!", + "new_workflow": "Neuer Workflow", "next": "Weiter", "no": "Nein", "no_actions_found": "Keine Aktionen gefunden", @@ -388,10 +399,12 @@ "other": "Sonstiges", "other_filters": "Weitere Filter", "other_placeholder": "Sonstiger Platzhalter", + "output": "Ausgabe", "overlay_color": "Overlay-Farbe", "overview": "Übersicht", "password": "Passwort", "paused": "Pausiert", + "pending": "Ausstehend", "pending_downgrade": "Ausstehende Herabstufung", "people_manager": "Mitarbeitererlebnis", "person": "Person", @@ -412,6 +425,7 @@ "question": "Frage", "question_id": "Fragen-ID", "questions": "Fragen", + "queued": "In Warteschlange", "quota": "Kontingent", "quotas": "Kontingente", "quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmenden erhältst, die bestimmte Kriterien erfüllen.", @@ -424,15 +438,20 @@ "replace": "Ersetzen", "report_survey": "Umfrage melden", "request_trial_license": "Testlizenz anfordern", + "required": "Erforderlich", "reset_to_default": "Auf Standard zurücksetzen", "resize": "Größe ändern", "response": "Antwort", + "response_completed": "Antwort abgeschlossen", "response_id": "Antwort-ID", "responses": "Antworten", "restart": "Neu starten", "retry": "Erneut versuchen", "role": "Rolle", "row_n": "Zeile {n}", + "run_data": "Ausführungsdaten", + "running": "Läuft", + "runs": "Ausführungen", "saas": "SaaS", "sales": "Vertrieb", "save": "Speichern", @@ -468,12 +487,16 @@ "something_went_wrong": "Etwas ist schiefgelaufen", "something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "sort_by": "Sortieren nach", + "sort_by_value": "Sortieren nach: {label}", + "started_at": "Gestartet um", "status": "Status", + "steps": "Schritte", "storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen", "string": "Text", "styling": "Styling", "subheader": "Unterüberschrift", "submit": "Abschicken", + "succeeded": "Erfolgreich", "summary": "Zusammenfassung", "survey": "Umfrage", "survey_completed": "Umfrage abgeschlossen.", @@ -506,8 +529,11 @@ "trial_expired": "Deine Testphase ist abgelaufen", "trial_one_day_remaining": "1 Tag verbleibend in deiner Testphase", "trial_plan_badge": "{plan}-Testversion", + "trigger": "Auslöser", + "trigger_payload": "Trigger-Payload", "try_again": "Erneut versuchen", "type": "Typ", + "unarchive": "Wiederherstellen", "undo": "Rückgängig", "unlock_more_workspaces_with_a_higher_plan": "Schalte mehr Workspaces mit einem höheren Plan frei.", "update": "Aktualisieren", @@ -527,6 +553,7 @@ "verified_email": "Verifizierte E-Mail", "video": "Video", "view": "Ansehen", + "view_workflow": "Workflow ansehen", "warning": "Warnung", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Wir konnten deine Lizenz nicht verifizieren, da der Lizenzserver nicht erreichbar ist.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "Wochen", "welcome_card": "Willkommenskarte", "whats_new": "Was ist neu", + "workflow_name": "Workflow-Name", + "workflow_runs": "Workflow-Ausführungen", + "workflows": "Workflows", "workspace": "Arbeitsbereich", "workspace_created_successfully": "Workspace erfolgreich erstellt", "workspace_creation_description": "Organisiere Umfragen in Workspaces für eine bessere Zugriffskontrolle.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten", "response_data": "Antwortdaten", "response_finished_email_subject": "Eine Antwort für {surveyName} wurde ausgefüllt ✅", - "response_finished_email_subject_with_email": "{personEmail} hat gerade deine {surveyName}-Umfrage ausgefüllt ✅", "schedule_your_meeting": "Plane dein Meeting", "select_a_date": "Wähle ein Datum", "survey_response_finished_email_congrats": "Glückwunsch, du hast eine neue Antwort auf deine Umfrage erhalten! Jemand hat gerade deine Umfrage ausgefüllt: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Zwei-Faktor-Authentifizierung", "comparison_row_unify_feedback": "Feedback aus allen Quellen vereinen", "comparison_row_unlimited_seats": "Unbegrenzte Plätze", + "comparison_row_workflows": "Workflows", "comparison_row_workspaces": "Arbeitsbereiche", "comparison_section_all_plans": "Alle Pläne", "comparison_section_basic_usage": "Kernnutzung", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5.000 Antworten / Monat mit dynamischer Preisgestaltung", "plan_scale_feature_security": "2FA & Spam-Schutz", "plan_scale_feature_semantic_analysis": "Semantische Analyse (KI)", + "plan_scale_feature_workflows": "Workflows", "plan_scale_feature_workspaces": "5 Workspaces", "plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle deinen Plan direkt in Formbricks.", "plan_selection_title": "Wähle deinen Plan", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen", "follow_ups_modal_action_body_label": "Text", "follow_ups_modal_action_body_placeholder": "Text der E-Mail", + "follow_ups_modal_action_email_already_added": "Diese E-Mail wurde bereits hinzugefügt", "follow_ups_modal_action_email_content": "E-Mail-Inhalt", + "follow_ups_modal_action_email_input_placeholder": "Schreibe eine E-Mail & drücke die Leertaste", + "follow_ups_modal_action_email_invalid": "Bitte gib eine gültige E-Mail-Adresse ein", "follow_ups_modal_action_email_settings": "E-Mail-Einstellungen", "follow_ups_modal_action_from_description": "E-Mail-Adresse, von der die E-Mail gesendet wird", "follow_ups_modal_action_from_label": "Von", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab", "follow_ups_modal_updated_successfull_toast": "Follow-up aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.", "follow_ups_new": "Neues Follow-up", + "follow_ups_workflows_alert_title": "Brauchst du mehr Flexibilität? Automatisiere Follow-ups und vieles mehr mit Workflows.", "formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden", "four_points": "4 Punkte", "heading": "Überschrift", @@ -4134,6 +4169,119 @@ "value_number": "Wert (Anzahl)", "value_text": "Wert (Text)" }, + "workflows": { + "add_action": "Aktion hinzufügen", + "add_trigger": "Trigger hinzufügen", + "add_trigger_description": "Wähle aus, was diesen Workflow startet.", + "all_changes_saved": "Alle Änderungen gespeichert", + "alphabetical": "Alphabetisch", + "archive_confirm_body": "Durch das Archivieren wird dieser Workflow deaktiviert und die Ausführung gestoppt. Du kannst ihn später wieder aus dem Archiv holen.", + "archive_confirm_title": "Workflow archivieren?", + "archive_failed": "Workflow konnte nicht archiviert werden. Bitte versuche es erneut.", + "archive_success": "Workflow archiviert.", + "archive_workflow": "Workflow archivieren", + "archive_workflow_confirmation": "Bist du sicher, dass du \"{name}\" archivieren möchtest? Du kannst ihn später wiederherstellen.", + "archive_workflow_description": "Beim Archivieren wird der Workflow aus der Liste ausgeblendet. Du kannst ihn später wiederherstellen.", + "auto_layout": "Auto-Layout", + "autosave_failed": "Speichern fehlgeschlagen", + "autosave_failed_tooltip": "Deine neuesten Änderungen konnten nicht gespeichert werden. Überprüfe deine Verbindung und versuche es erneut.", + "autosave_failed_tooltip_rejected": "Deine letzten Änderungen konnten nicht gespeichert werden: {detail}", + "collapse_inspector": "Inspector einklappen", + "create_failed": "Workflow konnte nicht erstellt werden. Bitte versuche es erneut.", + "delete_failed": "Workflow konnte nicht gelöscht werden. Bitte versuche es erneut.", + "delete_success": "Workflow gelöscht.", + "delete_workflow_confirmation": "Hiermit wird \"{name}\" und sein Ausführungsverlauf dauerhaft gelöscht.", + "disable_failed": "Workflow konnte nicht deaktiviert werden.", + "disable_success": "Workflow deaktiviert.", + "duplicate_failed": "Workflow konnte nicht dupliziert werden. Bitte versuche es erneut.", + "duplicate_success": "Workflow dupliziert.", + "edit_blocked_active": "Deaktiviere den Workflow, um hier Änderungen vorzunehmen.", + "email_attach_response_data_description": "Füge die auslösende Umfrageantwort zur E-Mail-Payload hinzu.", + "email_attach_response_data_label": "Antwortdaten anhängen", + "email_body_label": "Nachricht", + "email_body_placeholder": "Schreibe die Nachricht, die du senden möchtest…", + "email_body_required": "Füge die zu sendende Nachricht hinzu.", + "email_from_label": "Von", + "email_include_hidden_fields_label": "Versteckte Felder einbeziehen", + "email_include_variables_label": "Variablen einbeziehen", + "email_needs_survey": "Verknüpfe zuerst eine Umfrage im Trigger-Schritt. Die Optionen für Empfänger und Nachricht stammen aus den Antworten der Umfrage.", + "email_reply_to_label": "Antworten an", + "email_set_up_trigger": "Trigger einrichten", + "email_subject_label": "Betreff", + "email_subject_placeholder": "Danke für die Teilnahme an der Umfrage", + "email_subject_required": "Füge eine Betreffzeile hinzu.", + "email_to_label": "Senden an", + "email_to_placeholder": "team@beispiel.de", + "email_to_required": "Wähle aus, wer diese E-Mail erhalten soll.", + "enable_blocked_unsaved_changes": "Deine letzten Änderungen konnten nicht gespeichert werden, daher wurde der Workflow nicht aktiviert.", + "enable_failed": "Der Workflow konnte nicht aktiviert werden.", + "enable_success": "Workflow aktiviert.", + "expand_inspector": "Inspector ausklappen", + "if_else": "Wenn / Sonst", + "if_else_summary": "Verzweige den Workflow basierend auf einer Bedingung.", + "inspector_unsupported_node": "Dieser Knotentyp hat noch kein Konfigurationsformular.", + "load_failed": "Workflow konnte nicht geladen werden.", + "name_required": "Bitte einen Namen eingeben.", + "no_results_description": "Versuche, deine Suche oder Filter anzupassen.", + "no_results_title": "Keine Workflows gefunden", + "no_workflows_description": "Erstelle deinen ersten Workflow, um Aktionen zu automatisieren, wenn Antworten eingehen.", + "no_workflows_title": "Noch keine Workflows", + "node_actions": "Knotenaktionen", + "node_needs_email_content": "Empfänger & Inhalte festlegen", + "node_needs_survey": "Wähle eine Umfrage aus, um loszulegen", + "pan_mode": "Verschiebemodus", + "pointer_mode": "Zeigermodus", + "read_only": "Schreibgeschützt", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {vor # Tag} other {vor # Tagen}}, {time}", + "relative_today": "Heute, {time}", + "relative_yesterday": "Gestern, {time}", + "response_completed": "Antwort abgeschlossen", + "response_completed_description": "Wird ausgeführt, wenn jemand eine Umfrageantwort abschließt.", + "save_failed": "Workflow konnte nicht gespeichert werden.", + "save_success": "Workflow gespeichert.", + "saving_changes": "Wird gespeichert…", + "search_by_workflow_name": "Nach Workflow-Namen suchen", + "send_email": "E-Mail senden", + "send_email_description": "Versende eine E-Mail, wenn dieser Workflow ausgeführt wird.", + "send_email_summary": "Sende eine E-Mail an {to}.", + "send_email_unconfigured": "Konfiguriere den E-Mail-Empfänger.", + "trigger_ending_cards_label": "Abschlusskarten", + "trigger_ending_cards_none": "Diese Umfrage hat keine konfigurierten Abschlüsse.", + "trigger_ending_cards_pick_survey": "Wähle eine Umfrage aus, um ihre Abschlüsse zu sehen.", + "trigger_ending_cards_scope_all": "Alle Abschlüsse", + "trigger_ending_cards_scope_specific": "Bestimmte Abschlüsse", + "trigger_ending_cards_select_at_least_one": "Wähle mindestens einen Abschluss aus. Ohne Auswahl löst jeder Abschluss diesen Workflow aus.", + "trigger_summary_all_endings": "Bei jeder Umfrageantwort auslösen.", + "trigger_summary_ending_cards": "Bei {count, plural, one {# Endkarte} other {# Endkarten}} auslösen.", + "trigger_survey_description": "Wähle die Umfrage aus, deren abgeschlossene Antworten diesen Workflow auslösen.", + "trigger_survey_empty": "In diesem Workspace gibt es noch keine Umfragen.", + "trigger_survey_label": "Umfrage", + "trigger_survey_placeholder": "Wähle eine Umfrage aus", + "triggers": "Auslöser", + "unarchive": "Aus Archiv wiederherstellen", + "unarchive_failed": "Workflow konnte nicht wiederhergestellt werden. Bitte versuche es erneut.", + "unarchive_success": "Workflow wiederhergestellt.", + "upgrade_prompt_description": "Automatisiere antwortgesteuerte Aufgaben mit Triggern, Filtern und Aktionen.", + "upgrade_prompt_title": "Upgrade durchführen, um Workflows freizuschalten", + "validation_failed": "Workflow-Validierung fehlgeschlagen.", + "validation_problem_fix_label": "Beheben: {problem}", + "validation_problem_flow_invalid": "Die Workflow-Schritte sind nicht zu einem ausführbaren Ablauf verbunden.", + "validation_problem_generic": "Dieser Teil des Workflows hat ein Konfigurationsproblem.", + "validation_problem_name_missing": "Gib dem Workflow einen Namen.", + "validation_problem_step_incomplete": "Füll den Empfänger, Betreff und Text des E-Mail-Schritts aus.", + "validation_problem_step_not_executable": "Dieser Schritttyp kann noch nicht ausgeführt werden. Entferne ihn, bevor Du den Workflow aktivierst.", + "validation_problem_trigger_ending_not_found": "Ein ausgewähltes Ende existiert nicht mehr in der verbundenen Umfrage.", + "validation_problem_trigger_missing": "Füge einen Trigger hinzu, um den Workflow zu starten.", + "validation_problem_trigger_not_connected": "Verbinde einen Schritt nach dem Trigger.", + "validation_problem_trigger_survey_unbound": "Verbinde den Trigger mit einer Umfrage in diesem Workspace.", + "validation_problems_count": "{count, plural, one {# Problem} other {# Probleme}}", + "validation_problems_description": "Behebe diese Probleme, bevor der Workflow ausgeführt werden kann:", + "validation_problems_title": "Validierungsprobleme", + "validation_status_valid": "Gültig", + "zoom_in": "Vergrößern", + "zoom_out": "Verkleinern" + }, "xm-templates": { "ces": "CES", "ces_description": "Customer Effort Score", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 3b161e266931..65e4b90bab69 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Accepted", "account": "Account", "account_settings": "Account settings", + "act": "Act", "action": "Action", "actions": "Actions", "actions_description": "Code and No-Code Actions are used to trigger intercept surveys within apps & on websites.", @@ -185,6 +188,7 @@ "archive": "Archive", "archived": "Archived", "are_you_sure": "Are you sure?", + "attempt": "Attempt", "attributes": "Attributes", "authorized_apps": "Authorized Apps", "back": "Back", @@ -193,6 +197,7 @@ "bottom_left": "Bottom Left", "bottom_right": "Bottom Right", "cancel": "Cancel", + "canceled": "Canceled", "centered_modal": "Centered Modal", "chart": "Chart", "charts": "Charts", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(copy {copyNumber})", "e_commerce": "E-Commerce", "edit": "Edit", + "editor": "Editor", "elements": "Elements", "email": "Email", "enable": "Enable", + "enabled": "Enabled", "ending_card": "Ending card", "enter_url": "Enter URL", "enterprise_license": "Enterprise License", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Maximum number of requests reached. Please try again later.", "error_rate_limit_title": "Rate Limit Exceeded", "expand_rows": "Expand rows", + "failed": "Failed", "failed_to_copy_to_clipboard": "Failed to copy to clipboard", "failed_to_load_organizations": "Failed to load organizations", "failed_to_load_workspaces": "Failed to load workspaces", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filter", "finish": "Finish", + "finished_at": "Finished At", "first_name": "First Name", "formbricks_version": "Formbricks Version", "full_name": "Full name", @@ -310,6 +319,7 @@ "imprint": "Imprint", "in_progress": "In Progress", "inactive_surveys": "Inactive surveys", + "input": "Input", "integration": "integration", "integrations": "Integrations", "invalid_date_with_value": "Invalid date: {value}", @@ -350,6 +360,7 @@ "move_up": "Move up", "name": "Name", "new_version_available": "Formbricks {version} is here. Upgrade now!", + "new_workflow": "New workflow", "next": "Next", "no": "No", "no_actions_found": "No actions found", @@ -388,10 +399,12 @@ "other": "Other", "other_filters": "Other Filters", "other_placeholder": "Other Placeholder", + "output": "Output", "overlay_color": "Overlay color", "overview": "Overview", "password": "Password", "paused": "Paused", + "pending": "Pending", "pending_downgrade": "Pending Downgrade", "people_manager": "Employee Experience", "person": "Person", @@ -412,6 +425,7 @@ "question": "question", "question_id": "Question ID", "questions": "Questions", + "queued": "Queued", "quota": "Quota", "quotas": "Quotas", "quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.", @@ -424,15 +438,20 @@ "replace": "Replace", "report_survey": "Report Survey", "request_trial_license": "Request trial license", + "required": "Required", "reset_to_default": "Reset to default", "resize": "Resize", "response": "Response", + "response_completed": "Response completed", "response_id": "Response ID", "responses": "Responses", "restart": "Restart", "retry": "Retry", "role": "Role", "row_n": "Row {n}", + "run_data": "Run data", + "running": "Running", + "runs": "Runs", "saas": "SaaS", "sales": "Sales", "save": "Save", @@ -468,12 +487,16 @@ "something_went_wrong": "Something went wrong", "something_went_wrong_please_try_again": "Something went wrong. Please try again.", "sort_by": "Sort by", + "sort_by_value": "Sort by: {label}", + "started_at": "Started At", "status": "Status", + "steps": "Steps", "storage_not_configured": "File storage not set up, uploads will likely fail", "string": "Text", "styling": "Styling", "subheader": "Subheader", "submit": "Submit", + "succeeded": "Succeeded", "summary": "Summary", "survey": "Survey", "survey_completed": "Survey completed.", @@ -506,8 +529,11 @@ "trial_expired": "Your trial has expired", "trial_one_day_remaining": "1 day left in your trial", "trial_plan_badge": "{plan} Trial", + "trigger": "Trigger", + "trigger_payload": "Trigger payload", "try_again": "Try again", "type": "Type", + "unarchive": "Unarchive", "undo": "Undo", "unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.", "update": "Update", @@ -527,6 +553,7 @@ "verified_email": "Verified Email", "video": "Video", "view": "View", + "view_workflow": "View workflow", "warning": "Warning", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "weeks", "welcome_card": "Welcome card", "whats_new": "What's New", + "workflow_name": "Workflow Name", + "workflow_runs": "Workflow Runs", + "workflows": "Workflows", "workspace": "Workspace", "workspace_created_successfully": "Workspace created successfully", "workspace_creation_description": "Organize surveys in workspaces for better access control.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons", "response_data": "Response data", "response_finished_email_subject": "A response for {surveyName} was completed ✅", - "response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅", "schedule_your_meeting": "Schedule your meeting", "select_a_date": "Select a date", "survey_response_finished_email_congrats": "Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Two-factor authentication", "comparison_row_unify_feedback": "Unify feedback from all sources", "comparison_row_unlimited_seats": "Unlimited seats", + "comparison_row_workflows": "Workflows", "comparison_row_workspaces": "Workspaces", "comparison_section_all_plans": "All plans", "comparison_section_basic_usage": "Core usage", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5,000 responses / month with dynamic pricing", "plan_scale_feature_security": "2FA & spam protection", "plan_scale_feature_semantic_analysis": "Semantic Analysis (AI)", + "plan_scale_feature_workflows": "Workflows", "plan_scale_feature_workspaces": "5 workspaces", "plan_selection_description": "Compare Hobby, Pro, and Scale, then switch plans directly from Formbricks.", "plan_selection_title": "Choose your plan", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Attach response data", "follow_ups_modal_action_body_label": "Body", "follow_ups_modal_action_body_placeholder": "Body of the email", + "follow_ups_modal_action_email_already_added": "This email has already been added", "follow_ups_modal_action_email_content": "Email content", + "follow_ups_modal_action_email_input_placeholder": "Write an email & press space bar", + "follow_ups_modal_action_email_invalid": "Please enter a valid email address", "follow_ups_modal_action_email_settings": "Email settings", "follow_ups_modal_action_from_description": "Email address to send the email from", "follow_ups_modal_action_from_label": "From", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Respondent completes survey", "follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.", "follow_ups_new": "New follow-up", + "follow_ups_workflows_alert_title": "Need more flexibility? Automate follow-ups and much more with Workflows.", "formbricks_sdk_is_not_connected": "Formbricks SDK is not connected", "four_points": "4 points", "heading": "Heading", @@ -4134,6 +4169,119 @@ "value_number": "Value (Number)", "value_text": "Value (Text)" }, + "workflows": { + "add_action": "Add action", + "add_trigger": "Add trigger", + "add_trigger_description": "Choose what starts this workflow.", + "all_changes_saved": "All changes saved", + "alphabetical": "Alphabetical", + "archive_confirm_body": "Archiving disables this workflow and stops it from running. You can unarchive it again later.", + "archive_confirm_title": "Archive workflow?", + "archive_failed": "Failed to archive the workflow. Please try again.", + "archive_success": "Workflow archived.", + "archive_workflow": "Archive workflow", + "archive_workflow_confirmation": "Are you sure you want to archive \"{name}\"? You can restore it later.", + "archive_workflow_description": "Archiving hides the workflow from the list. You can restore it later.", + "auto_layout": "Auto layout", + "autosave_failed": "Save failed", + "autosave_failed_tooltip": "Your latest changes couldn't be saved. Check your connection and try again.", + "autosave_failed_tooltip_rejected": "Your latest changes couldn't be saved: {detail}", + "collapse_inspector": "Collapse inspector", + "create_failed": "Failed to create the workflow. Please try again.", + "delete_failed": "Failed to delete the workflow. Please try again.", + "delete_success": "Workflow deleted.", + "delete_workflow_confirmation": "This permanently deletes \"{name}\" and its run history.", + "disable_failed": "Could not disable the workflow.", + "disable_success": "Workflow disabled.", + "duplicate_failed": "Failed to duplicate the workflow. Please try again.", + "duplicate_success": "Workflow duplicated.", + "edit_blocked_active": "Disable the workflow to make changes here.", + "email_attach_response_data_description": "Include the triggering survey response with the email payload.", + "email_attach_response_data_label": "Attach response data", + "email_body_label": "Body", + "email_body_placeholder": "Write the message you want to send…", + "email_body_required": "Add the message to send.", + "email_from_label": "From", + "email_include_hidden_fields_label": "Include hidden fields", + "email_include_variables_label": "Include variables", + "email_needs_survey": "Connect a survey in the trigger step first. The recipient and message options come from the survey's answers.", + "email_reply_to_label": "Reply to", + "email_set_up_trigger": "Set up trigger", + "email_subject_label": "Subject", + "email_subject_placeholder": "Thanks for completing the survey", + "email_subject_required": "Add a subject line.", + "email_to_label": "Send to", + "email_to_placeholder": "team@example.com", + "email_to_required": "Pick who should receive this email.", + "enable_blocked_unsaved_changes": "Your latest changes couldn't be saved, so the workflow wasn't enabled.", + "enable_failed": "Could not enable the workflow.", + "enable_success": "Workflow enabled.", + "expand_inspector": "Expand inspector", + "if_else": "If / Else", + "if_else_summary": "Branch the workflow based on a condition.", + "inspector_unsupported_node": "This node type doesn't have a configuration form yet.", + "load_failed": "Could not load the workflow.", + "name_required": "Please enter a name.", + "no_results_description": "Try adjusting your search or filters.", + "no_results_title": "No workflows found", + "no_workflows_description": "Create your first workflow to automate actions when responses come in.", + "no_workflows_title": "No workflows yet", + "node_actions": "Node actions", + "node_needs_email_content": "Set recipient & contents", + "node_needs_survey": "Pick a survey to get started", + "pan_mode": "Pan mode", + "pointer_mode": "Pointer mode", + "read_only": "Read-only", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {# day ago} other {# days ago}}, {time}", + "relative_today": "Today, {time}", + "relative_yesterday": "Yesterday, {time}", + "response_completed": "Response completed", + "response_completed_description": "Runs when someone completes a survey response.", + "save_failed": "Could not save the workflow.", + "save_success": "Workflow saved.", + "saving_changes": "Saving…", + "search_by_workflow_name": "Search by workflow name", + "send_email": "Send email", + "send_email_description": "Send an email when this workflow runs.", + "send_email_summary": "Send an email to {to}.", + "send_email_unconfigured": "Configure the email recipient.", + "trigger_ending_cards_label": "Ending cards", + "trigger_ending_cards_none": "This survey has no endings configured.", + "trigger_ending_cards_pick_survey": "Pick a survey to see its endings.", + "trigger_ending_cards_scope_all": "All endings", + "trigger_ending_cards_scope_specific": "Specific endings", + "trigger_ending_cards_select_at_least_one": "Select at least one ending. With none selected, every ending fires this workflow.", + "trigger_summary_all_endings": "Trigger on any survey response.", + "trigger_summary_ending_cards": "Trigger on {count, plural, one {# ending card} other {# ending cards}}.", + "trigger_survey_description": "Pick the survey whose completed responses fire this workflow.", + "trigger_survey_empty": "No surveys in this workspace yet.", + "trigger_survey_label": "Survey", + "trigger_survey_placeholder": "Select a survey", + "triggers": "Triggers", + "unarchive": "Unarchive", + "unarchive_failed": "Failed to unarchive the workflow. Please try again.", + "unarchive_success": "Workflow unarchived.", + "upgrade_prompt_description": "Automate response-driven tasks with triggers, filters, and actions.", + "upgrade_prompt_title": "Upgrade to unlock Workflows", + "validation_failed": "Workflow validation failed.", + "validation_problem_fix_label": "Fix: {problem}", + "validation_problem_flow_invalid": "The workflow steps aren't connected into a single runnable flow.", + "validation_problem_generic": "This part of the workflow has a configuration problem.", + "validation_problem_name_missing": "Give the workflow a name.", + "validation_problem_step_incomplete": "Fill in the email step's recipient, subject, and body.", + "validation_problem_step_not_executable": "This step type can't run yet. Remove it before enabling the workflow.", + "validation_problem_trigger_ending_not_found": "A selected ending no longer exists on the connected survey.", + "validation_problem_trigger_missing": "Add a trigger to start the workflow.", + "validation_problem_trigger_not_connected": "Connect a step after the trigger.", + "validation_problem_trigger_survey_unbound": "Connect the trigger to a survey in this workspace.", + "validation_problems_count": "{count, plural, one {# problem} other {# problems}}", + "validation_problems_description": "Fix these problems before the workflow can run:", + "validation_problems_title": "Validation problems", + "validation_status_valid": "Valid", + "zoom_in": "Zoom in", + "zoom_out": "Zoom out" + }, "xm-templates": { "ces": "CES", "ces_description": "Customer Effort Score", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 5c67956fac7d..cb207ac6c6ba 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Aceptado", "account": "Cuenta", "account_settings": "Ajustes de cuenta", + "act": "Actuar", "action": "Acción", "actions": "Acciones", "actions_description": "Las acciones de código y sin código se utilizan para activar encuestas de intercepción en aplicaciones y sitios web.", @@ -185,6 +188,7 @@ "archive": "Archivar", "archived": "Archivado", "are_you_sure": "¿Estás seguro?", + "attempt": "Intento", "attributes": "Atributos", "authorized_apps": "Authorized Apps", "back": "Atrás", @@ -193,6 +197,7 @@ "bottom_left": "Inferior izquierda", "bottom_right": "Inferior derecha", "cancel": "Cancelar", + "canceled": "Cancelado", "centered_modal": "Modal centrado", "chart": "Gráfico", "charts": "Gráficos", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(copia {copyNumber})", "e_commerce": "Comercio electrónico", "edit": "Editar", + "editor": "Editor", "elements": "Elementos", "email": "Email", "enable": "Activar", + "enabled": "Activado", "ending_card": "Tarjeta final", "enter_url": "Introducir URL", "enterprise_license": "Licencia empresarial", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Número máximo de solicitudes alcanzado. Por favor, inténtalo de nuevo más tarde.", "error_rate_limit_title": "Límite de frecuencia excedido", "expand_rows": "Expandir filas", + "failed": "Fallido", "failed_to_copy_to_clipboard": "Error al copiar al portapapeles", "failed_to_load_organizations": "Error al cargar organizaciones", "failed_to_load_workspaces": "Error al cargar los espacios de trabajo", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filtro", "finish": "Finalizar", + "finished_at": "Finalizado el", "first_name": "Nombre", "formbricks_version": "Versión de Formbricks", "full_name": "Nombre completo", @@ -310,6 +319,7 @@ "imprint": "Aviso legal", "in_progress": "En progreso", "inactive_surveys": "Encuestas inactivas", + "input": "Entrada", "integration": "integración", "integrations": "Integraciones", "invalid_date_with_value": "Fecha no válida: {value}", @@ -350,6 +360,7 @@ "move_up": "Mover hacia arriba", "name": "Nombre", "new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!", + "new_workflow": "Nuevo flujo de trabajo", "next": "Siguiente", "no": "No", "no_actions_found": "No se encontraron acciones", @@ -388,10 +399,12 @@ "other": "Otro", "other_filters": "Otros Filtros", "other_placeholder": "Otro marcador de posición", + "output": "Salida", "overlay_color": "Color de superposición", "overview": "Resumen", "password": "Contraseña", "paused": "Pausado", + "pending": "Pendiente", "pending_downgrade": "Degradación pendiente", "people_manager": "Experiencia del empleado", "person": "Persona", @@ -412,6 +425,7 @@ "question": "pregunta", "question_id": "ID de pregunta", "questions": "Preguntas", + "queued": "En cola", "quota": "Cuota", "quotas": "Cuotas", "quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.", @@ -424,15 +438,20 @@ "replace": "Reemplazar", "report_survey": "Reportar encuesta", "request_trial_license": "Solicitar licencia de prueba", + "required": "Obligatorio", "reset_to_default": "Restablecer a valores predeterminados", "resize": "Cambiar tamaño", "response": "Respuesta", + "response_completed": "Respuesta completada", "response_id": "ID de respuesta", "responses": "Respuestas", "restart": "Reiniciar", "retry": "Reintentar", "role": "Rol", "row_n": "Fila {n}", + "run_data": "Datos de ejecución", + "running": "En ejecución", + "runs": "Ejecuciones", "saas": "SaaS", "sales": "Ventas", "save": "Guardar", @@ -468,12 +487,16 @@ "something_went_wrong": "Algo ha salido mal", "something_went_wrong_please_try_again": "Algo ha salido mal. Por favor, inténtalo de nuevo.", "sort_by": "Ordenar por", + "sort_by_value": "Ordenar por: {label}", + "started_at": "Iniciado el", "status": "Estado", + "steps": "Pasos", "storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas", "string": "Texto", "styling": "Estilo", "subheader": "Subtítulo", "submit": "Enviar", + "succeeded": "Exitoso", "summary": "Resumen", "survey": "Encuesta", "survey_completed": "Encuesta completada.", @@ -506,8 +529,11 @@ "trial_expired": "Tu prueba ha expirado", "trial_one_day_remaining": "1 día restante en tu prueba", "trial_plan_badge": "Prueba de {plan}", + "trigger": "Activador", + "trigger_payload": "Carga útil del disparador", "try_again": "Intentar de nuevo", "type": "Tipo", + "unarchive": "Desarchivar", "undo": "Deshacer", "unlock_more_workspaces_with_a_higher_plan": "Desbloquea más espacios de trabajo con un plan superior.", "update": "Actualizar", @@ -527,6 +553,7 @@ "verified_email": "Correo electrónico verificado", "video": "Vídeo", "view": "Ver", + "view_workflow": "Ver flujo de trabajo", "warning": "Advertencia", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "No pudimos verificar tu licencia porque el servidor de licencias no está accesible.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "semanas", "welcome_card": "Tarjeta de bienvenida", "whats_new": "Novedades", + "workflow_name": "Nombre del flujo de trabajo", + "workflow_runs": "Ejecuciones de workflows", + "workflows": "Flujos de trabajo", "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.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "El enlace al archivo subido no está incluido por razones de privacidad de datos", "response_data": "Datos de respuesta", "response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅", - "response_finished_email_subject_with_email": "{personEmail} acaba de completar tu encuesta {surveyName} ✅", "schedule_your_meeting": "Programa tu reunión", "select_a_date": "Selecciona una fecha", "survey_response_finished_email_congrats": "Enhorabuena, has recibido una nueva respuesta a tu encuesta. Alguien acaba de completar tu encuesta: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Autenticación de dos factores", "comparison_row_unify_feedback": "Unifica comentarios de todas las fuentes", "comparison_row_unlimited_seats": "Asientos ilimitados", + "comparison_row_workflows": "Workflows", "comparison_row_workspaces": "Espacios de trabajo", "comparison_section_all_plans": "Todos los planes", "comparison_section_basic_usage": "Uso básico", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5.000 respuestas al mes con precios dinámicos", "plan_scale_feature_security": "2FA y protección antispam", "plan_scale_feature_semantic_analysis": "Análisis semántico (IA)", + "plan_scale_feature_workflows": "Workflows", "plan_scale_feature_workspaces": "5 espacios de trabajo", "plan_selection_description": "Compara Hobby, Pro y Scale, y cambia de plan directamente desde Formbricks.", "plan_selection_title": "Elige tu plan", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Adjuntar datos de respuesta", "follow_ups_modal_action_body_label": "Cuerpo", "follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico", + "follow_ups_modal_action_email_already_added": "Este correo electrónico ya ha sido añadido", "follow_ups_modal_action_email_content": "Contenido del correo electrónico", + "follow_ups_modal_action_email_input_placeholder": "Escribe un correo y pulsa la barra espaciadora", + "follow_ups_modal_action_email_invalid": "Por favor, introduce una dirección de correo electrónico válida", "follow_ups_modal_action_email_settings": "Configuración del correo electrónico", "follow_ups_modal_action_from_description": "Dirección de correo electrónico desde la que enviar el correo", "follow_ups_modal_action_from_label": "De", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "El encuestado completa la encuesta", "follow_ups_modal_updated_successfull_toast": "Seguimiento actualizado y se guardará cuando guardes la encuesta.", "follow_ups_new": "Nuevo seguimiento", + "follow_ups_workflows_alert_title": "¿Necesitas más flexibilidad? Automatiza seguimientos y mucho más con Workflows.", "formbricks_sdk_is_not_connected": "El SDK de Formbricks no está conectado", "four_points": "4 puntos", "heading": "Encabezado", @@ -4134,6 +4169,119 @@ "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, + "workflows": { + "add_action": "Añadir acción", + "add_trigger": "Añadir activador", + "add_trigger_description": "Elige qué inicia este flujo de trabajo.", + "all_changes_saved": "Todos los cambios guardados", + "alphabetical": "Alfabético", + "archive_confirm_body": "Archivar desactiva este flujo de trabajo y evita que se ejecute. Puedes desarchivarlo más adelante.", + "archive_confirm_title": "¿Archivar flujo de trabajo?", + "archive_failed": "No se pudo archivar el flujo de trabajo. Por favor, inténtalo de nuevo.", + "archive_success": "Flujo de trabajo archivado.", + "archive_workflow": "Archivar flujo de trabajo", + "archive_workflow_confirmation": "¿Estás seguro de que quieres archivar \"{name}\"? Puedes restaurarlo más tarde.", + "archive_workflow_description": "Archivar oculta el flujo de trabajo de la lista. Puedes restaurarlo más tarde.", + "auto_layout": "Diseño automático", + "autosave_failed": "Error al guardar", + "autosave_failed_tooltip": "No se pudieron guardar tus últimos cambios. Comprueba tu conexión e inténtalo de nuevo.", + "autosave_failed_tooltip_rejected": "No se pudieron guardar tus últimos cambios: {detail}", + "collapse_inspector": "Contraer inspector", + "create_failed": "No se pudo crear el flujo de trabajo. Por favor, inténtalo de nuevo.", + "delete_failed": "No se pudo eliminar el flujo de trabajo. Por favor, inténtalo de nuevo.", + "delete_success": "Flujo de trabajo eliminado.", + "delete_workflow_confirmation": "Esto eliminará permanentemente \"{name}\" y su historial de ejecuciones.", + "disable_failed": "No se pudo desactivar el flujo de trabajo.", + "disable_success": "Flujo de trabajo desactivado.", + "duplicate_failed": "No se pudo duplicar el flujo de trabajo. Por favor, inténtalo de nuevo.", + "duplicate_success": "Flujo de trabajo duplicado.", + "edit_blocked_active": "Desactiva el flujo de trabajo para hacer cambios aquí.", + "email_attach_response_data_description": "Incluye la respuesta de la encuesta que activó el flujo con la carga del correo electrónico.", + "email_attach_response_data_label": "Adjuntar datos de respuesta", + "email_body_label": "Cuerpo", + "email_body_placeholder": "Escribe el mensaje que quieres enviar…", + "email_body_required": "Añade el mensaje que quieres enviar.", + "email_from_label": "De", + "email_include_hidden_fields_label": "Incluir campos ocultos", + "email_include_variables_label": "Incluir variables", + "email_needs_survey": "Primero conecta una encuesta en el paso del activador. Las opciones de destinatario y mensaje provienen de las respuestas de la encuesta.", + "email_reply_to_label": "Responder a", + "email_set_up_trigger": "Configurar activador", + "email_subject_label": "Asunto", + "email_subject_placeholder": "Gracias por completar la encuesta", + "email_subject_required": "Añade un asunto.", + "email_to_label": "Enviar a", + "email_to_placeholder": "equipo@ejemplo.com", + "email_to_required": "Elige quién debe recibir este correo.", + "enable_blocked_unsaved_changes": "No se pudieron guardar tus últimos cambios, así que el flujo de trabajo no se habilitó.", + "enable_failed": "No se pudo activar el flujo de trabajo.", + "enable_success": "Flujo de trabajo activado.", + "expand_inspector": "Expandir inspector", + "if_else": "Si / Sino", + "if_else_summary": "Ramifica el flujo de trabajo según una condición.", + "inspector_unsupported_node": "Este tipo de nodo aún no tiene un formulario de configuración.", + "load_failed": "No se pudo cargar el flujo de trabajo.", + "name_required": "Introduce un nombre.", + "no_results_description": "Prueba a ajustar tu búsqueda o filtros.", + "no_results_title": "No se encontraron flujos de trabajo", + "no_workflows_description": "Crea tu primer flujo de trabajo para automatizar acciones cuando lleguen respuestas.", + "no_workflows_title": "Aún no hay flujos de trabajo", + "node_actions": "Acciones del nodo", + "node_needs_email_content": "Configurar destinatario y contenido", + "node_needs_survey": "Elige una encuesta para empezar", + "pan_mode": "Modo de desplazamiento", + "pointer_mode": "Modo puntero", + "read_only": "Solo lectura", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {hace # día} other {hace # días}}, {time}", + "relative_today": "Hoy, {time}", + "relative_yesterday": "Ayer, {time}", + "response_completed": "Respuesta completada", + "response_completed_description": "Se ejecuta cuando alguien completa una respuesta de encuesta.", + "save_failed": "No se pudo guardar el flujo de trabajo.", + "save_success": "Flujo de trabajo guardado.", + "saving_changes": "Guardando…", + "search_by_workflow_name": "Buscar por nombre de flujo de trabajo", + "send_email": "Enviar correo", + "send_email_description": "Envía un correo electrónico cuando se ejecute este flujo de trabajo.", + "send_email_summary": "Enviar un correo a {to}.", + "send_email_unconfigured": "Configura el destinatario del correo.", + "trigger_ending_cards_label": "Pantallas de finalización", + "trigger_ending_cards_none": "Esta encuesta no tiene finales configurados.", + "trigger_ending_cards_pick_survey": "Elige una encuesta para ver sus finales.", + "trigger_ending_cards_scope_all": "Todos los finales", + "trigger_ending_cards_scope_specific": "Finales específicos", + "trigger_ending_cards_select_at_least_one": "Selecciona al menos un final. Si no seleccionas ninguno, cada final activará este flujo de trabajo.", + "trigger_summary_all_endings": "Activar con cualquier respuesta de encuesta.", + "trigger_summary_ending_cards": "Activar con {count, plural, one {# tarjeta de finalización} other {# tarjetas de finalización}}.", + "trigger_survey_description": "Elige la encuesta cuyas respuestas completadas activarán este flujo de trabajo.", + "trigger_survey_empty": "Aún no hay encuestas en este espacio de trabajo.", + "trigger_survey_label": "Encuesta", + "trigger_survey_placeholder": "Selecciona una encuesta", + "triggers": "Activadores", + "unarchive": "Desarchivar", + "unarchive_failed": "No se pudo desarchivar el flujo de trabajo. Por favor, inténtalo de nuevo.", + "unarchive_success": "Flujo de trabajo desarchivado.", + "upgrade_prompt_description": "Automatiza tareas impulsadas por respuestas con activadores, filtros y acciones.", + "upgrade_prompt_title": "Mejora tu plan para desbloquear Workflows", + "validation_failed": "Falló la validación del flujo de trabajo.", + "validation_problem_fix_label": "Solución: {problem}", + "validation_problem_flow_invalid": "Los pasos del flujo de trabajo no están conectados en un flujo ejecutable único.", + "validation_problem_generic": "Esta parte del flujo de trabajo tiene un problema de configuración.", + "validation_problem_name_missing": "Dale un nombre al flujo de trabajo.", + "validation_problem_step_incomplete": "Rellena el destinatario, asunto y cuerpo del paso de correo electrónico.", + "validation_problem_step_not_executable": "Este tipo de paso aún no puede ejecutarse. Elimínalo antes de activar el flujo de trabajo.", + "validation_problem_trigger_ending_not_found": "Un final seleccionado ya no existe en la encuesta conectada.", + "validation_problem_trigger_missing": "Añade un desencadenador para iniciar el flujo de trabajo.", + "validation_problem_trigger_not_connected": "Conecta un paso después del desencadenador.", + "validation_problem_trigger_survey_unbound": "Conecta el desencadenador a una encuesta en este espacio de trabajo.", + "validation_problems_count": "{count, plural, one {# problema} other {# problemas}}", + "validation_problems_description": "Soluciona estos problemas antes de que el flujo de trabajo pueda ejecutarse:", + "validation_problems_title": "Problemas de validación", + "validation_status_valid": "Válido", + "zoom_in": "Acercar", + "zoom_out": "Alejar" + }, "xm-templates": { "ces": "CES", "ces_description": "Puntuación de Esfuerzo del Cliente", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 0336cdaa0c1e..6d5650ff76be 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Accepté", "account": "Compte", "account_settings": "Paramètres du compte", + "act": "Agir", "action": "Action", "actions": "Actions", "actions_description": "Les actions avec et sans code permettent de déclencher des enquêtes dans des applications et sur des sites Web.", @@ -185,6 +188,7 @@ "archive": "Archiver", "archived": "Archivé", "are_you_sure": "Es-tu sûr ?", + "attempt": "Tentative", "attributes": "Attributs", "authorized_apps": "Authorized Apps", "back": "Retour", @@ -193,6 +197,7 @@ "bottom_left": "En bas à gauche", "bottom_right": "En bas à droite", "cancel": "Annuler", + "canceled": "Annulé", "centered_modal": "Au centre", "chart": "Graphique", "charts": "Graphiques", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(copie {copyNumber})", "e_commerce": "E-commerce", "edit": "Modifier", + "editor": "Éditeur", "elements": "Éléments", "email": "Email", "enable": "Activer", + "enabled": "Activé", "ending_card": "Carte de fin", "enter_url": "Saisir l'URL", "enterprise_license": "Licence d'entreprise", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.", "error_rate_limit_title": "Limite de Taux Dépassée", "expand_rows": "Développer les lignes", + "failed": "Échoué", "failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers", "failed_to_load_organizations": "Échec du chargement des organisations", "failed_to_load_workspaces": "Échec du chargement des espaces de travail", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filtre", "finish": "Terminer", + "finished_at": "Terminé le", "first_name": "Prénom", "formbricks_version": "Version de Formbricks", "full_name": "Nom complet", @@ -310,6 +319,7 @@ "imprint": "Empreinte", "in_progress": "En cours", "inactive_surveys": "Sondages inactifs", + "input": "Entrée", "integration": "intégration", "integrations": "Intégrations", "invalid_date_with_value": "Date invalide: {value}", @@ -350,6 +360,7 @@ "move_up": "Déplacer vers le haut", "name": "Nom", "new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !", + "new_workflow": "Nouveau workflow", "next": "Suivant", "no": "Non", "no_actions_found": "Aucune action trouvée", @@ -388,10 +399,12 @@ "other": "Autre", "other_filters": "Autres filtres", "other_placeholder": "Autre espace réservé", + "output": "Sortie", "overlay_color": "Couleur de superposition", "overview": "Aperçu", "password": "Mot de passe", "paused": "En pause", + "pending": "En attente", "pending_downgrade": "Downgrade en attente", "people_manager": "Expérience employé", "person": "Personne", @@ -412,6 +425,7 @@ "question": "question", "question_id": "ID de la question", "questions": "Questions", + "queued": "En file d'attente", "quota": "Quota", "quotas": "Quotas", "quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.", @@ -424,15 +438,20 @@ "replace": "Remplacer", "report_survey": "Rapport d'enquête", "request_trial_license": "Demander une licence d'essai", + "required": "Obligatoire", "reset_to_default": "Réinitialiser par défaut", "resize": "Redimensionner", "response": "Réponse", + "response_completed": "Réponse terminée", "response_id": "ID de réponse", "responses": "Réponses", "restart": "Recommencer", "retry": "Réessayer", "role": "Rôle", "row_n": "Ligne {n}", + "run_data": "Données d'exécution", + "running": "En cours d'exécution", + "runs": "Exécutions", "saas": "SaaS", "sales": "Ventes", "save": "Enregistrer", @@ -468,12 +487,16 @@ "something_went_wrong": "Quelque chose s'est mal passé.", "something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.", "sort_by": "Trier par", + "sort_by_value": "Trier par: {label}", + "started_at": "Commencé le", "status": "Statut", + "steps": "Étapes", "storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer", "string": "Texte", "styling": "Style", "subheader": "Sous-titre", "submit": "Soumettre", + "succeeded": "Réussi", "summary": "Résumé", "survey": "Enquête", "survey_completed": "Enquête terminée.", @@ -506,8 +529,11 @@ "trial_expired": "Votre période d'essai a expiré", "trial_one_day_remaining": "1 jour restant dans votre période d'essai", "trial_plan_badge": "Essai {plan}", + "trigger": "Déclencheur", + "trigger_payload": "Charge utile de déclenchement", "try_again": "Réessayer", "type": "Type", + "unarchive": "Désarchiver", "undo": "Annuler", "unlock_more_workspaces_with_a_higher_plan": "Débloque plus d'espaces de travail avec un forfait supérieur.", "update": "Mise à jour", @@ -527,6 +553,7 @@ "verified_email": "Email vérifié", "video": "Vidéo", "view": "Afficher", + "view_workflow": "Voir le workflow", "warning": "Avertissement", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "semaines", "welcome_card": "Carte de bienvenue", "whats_new": "Quoi de neuf", + "workflow_name": "Nom du flux de travail", + "workflow_runs": "Exécutions de workflows", + "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.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données", "response_data": "Données de réponse", "response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅", - "response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅", "schedule_your_meeting": "Planifier votre rendez-vous", "select_a_date": "Sélectionner une date", "survey_response_finished_email_congrats": "Félicitations, vous avez reçu une nouvelle réponse à votre enquête ! Quelqu'un vient de compléter votre enquête : {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Authentification à deux facteurs", "comparison_row_unify_feedback": "Unifier les retours de toutes les sources", "comparison_row_unlimited_seats": "Sièges illimités", + "comparison_row_workflows": "Workflows", "comparison_row_workspaces": "Espaces de travail", "comparison_section_all_plans": "Tous les forfaits", "comparison_section_basic_usage": "Utilisation principale", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5 000 réponses / mois avec tarification dynamique", "plan_scale_feature_security": "2FA et protection anti-spam", "plan_scale_feature_semantic_analysis": "Analyse sémantique (IA)", + "plan_scale_feature_workflows": "Workflows", "plan_scale_feature_workspaces": "5 espaces de travail", "plan_selection_description": "Compare les formules Hobby, Pro et Scale, puis change de formule directement depuis Formbricks.", "plan_selection_title": "Choisis ta formule", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse", "follow_ups_modal_action_body_label": "Corps", "follow_ups_modal_action_body_placeholder": "Corps de l'email", + "follow_ups_modal_action_email_already_added": "Cet email a déjà été ajouté", "follow_ups_modal_action_email_content": "Contenu de l'email", + "follow_ups_modal_action_email_input_placeholder": "Écris un email et appuie sur espace", + "follow_ups_modal_action_email_invalid": "Merci de saisir une adresse email valide", "follow_ups_modal_action_email_settings": "Paramètres de messagerie", "follow_ups_modal_action_from_description": "Adresse e-mail à partir de laquelle envoyer l'e-mail", "follow_ups_modal_action_from_label": "De", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête", "follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"", "follow_ups_new": "Nouveau suivi", + "follow_ups_workflows_alert_title": "Besoin de plus de flexibilité ? Automatise les relances et bien plus encore avec Workflows.", "formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté", "four_points": "4 points", "heading": "En-tête", @@ -4134,6 +4169,119 @@ "value_number": "Valeur (Nombre)", "value_text": "Valeur (texte)" }, + "workflows": { + "add_action": "Ajouter une action", + "add_trigger": "Ajouter un déclencheur", + "add_trigger_description": "Choisis ce qui démarre ce workflow.", + "all_changes_saved": "Toutes les modifications enregistrées", + "alphabetical": "Alphabétique", + "archive_confirm_body": "L'archivage désactive ce workflow et l'empêche de s'exécuter. Tu pourras le restaurer plus tard.", + "archive_confirm_title": "Archiver le workflow ?", + "archive_failed": "Échec de l'archivage du workflow. Réessaye.", + "archive_success": "Workflow archivé.", + "archive_workflow": "Archiver le workflow", + "archive_workflow_confirmation": "Es-tu sûr de vouloir archiver « {name} » ? Tu pourras le restaurer plus tard.", + "archive_workflow_description": "L'archivage masque le workflow de la liste. Tu pourras le restaurer plus tard.", + "auto_layout": "Disposition automatique", + "autosave_failed": "Échec de l'enregistrement", + "autosave_failed_tooltip": "Tes dernières modifications n'ont pas pu être enregistrées. Vérifie ta connexion et réessaie.", + "autosave_failed_tooltip_rejected": "Tes dernières modifications n'ont pas pu être enregistrées : {detail}", + "collapse_inspector": "Réduire l'inspecteur", + "create_failed": "Échec de la création du workflow. Réessaye.", + "delete_failed": "Échec de la suppression du workflow. Réessaye.", + "delete_success": "Workflow supprimé.", + "delete_workflow_confirmation": "Cette action supprime définitivement « {name} » et son historique d'exécution.", + "disable_failed": "Impossible de désactiver le workflow.", + "disable_success": "Workflow désactivé.", + "duplicate_failed": "Échec de la duplication du workflow. Réessaye.", + "duplicate_success": "Workflow dupliqué.", + "edit_blocked_active": "Désactive le workflow pour apporter des modifications ici.", + "email_attach_response_data_description": "Inclure la réponse au sondage déclencheur avec la charge utile de l'e-mail.", + "email_attach_response_data_label": "Joindre les données de réponse", + "email_body_label": "Corps", + "email_body_placeholder": "Écris le message que tu souhaites envoyer…", + "email_body_required": "Ajoute le message à envoyer.", + "email_from_label": "De", + "email_include_hidden_fields_label": "Inclure les champs masqués", + "email_include_variables_label": "Inclure les variables", + "email_needs_survey": "Connecte d'abord un questionnaire dans l'étape de déclenchement. Les options de destinataire et de message proviennent des réponses du questionnaire.", + "email_reply_to_label": "Répondre à", + "email_set_up_trigger": "Configurer le déclencheur", + "email_subject_label": "Objet", + "email_subject_placeholder": "Merci d'avoir répondu au sondage", + "email_subject_required": "Ajoute une ligne d'objet.", + "email_to_label": "Envoyer à", + "email_to_placeholder": "equipe@exemple.com", + "email_to_required": "Choisis qui doit recevoir cet e-mail.", + "enable_blocked_unsaved_changes": "Tes dernières modifications n'ont pas pu être enregistrées, donc le workflow n'a pas été activé.", + "enable_failed": "Impossible d'activer le workflow.", + "enable_success": "Workflow activé.", + "expand_inspector": "Développer l'inspecteur", + "if_else": "Si / Sinon", + "if_else_summary": "Brancher le workflow selon une condition.", + "inspector_unsupported_node": "Ce type de nœud n'a pas encore de formulaire de configuration.", + "load_failed": "Impossible de charger le workflow.", + "name_required": "Veuillez saisir un nom.", + "no_results_description": "Essaie d'ajuster ta recherche ou tes filtres.", + "no_results_title": "Aucun workflow trouvé", + "no_workflows_description": "Crée ton premier workflow pour automatiser des actions lorsque des réponses arrivent.", + "no_workflows_title": "Aucun workflow pour le moment", + "node_actions": "Actions du nœud", + "node_needs_email_content": "Définir le destinataire et le contenu", + "node_needs_survey": "Choisis un questionnaire pour commencer", + "pan_mode": "Mode panoramique", + "pointer_mode": "Mode pointeur", + "read_only": "Lecture seule", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {il y a # jour} other {il y a # jours}}, {time}", + "relative_today": "Aujourd'hui, {time}", + "relative_yesterday": "Hier, {time}", + "response_completed": "Réponse terminée", + "response_completed_description": "S'exécute quand quelqu'un complète une réponse au questionnaire.", + "save_failed": "Impossible d'enregistrer le workflow.", + "save_success": "Workflow enregistré.", + "saving_changes": "Enregistrement…", + "search_by_workflow_name": "Rechercher par nom de workflow", + "send_email": "Envoyer un e-mail", + "send_email_description": "Envoie un email quand ce workflow s'exécute.", + "send_email_summary": "Envoyer un e-mail à {to}.", + "send_email_unconfigured": "Configure le destinataire de l'e-mail.", + "trigger_ending_cards_label": "Écrans de fin", + "trigger_ending_cards_none": "Ce questionnaire n'a aucune fin configurée.", + "trigger_ending_cards_pick_survey": "Choisis un questionnaire pour voir ses fins.", + "trigger_ending_cards_scope_all": "Toutes les fins", + "trigger_ending_cards_scope_specific": "Fins spécifiques", + "trigger_ending_cards_select_at_least_one": "Sélectionne au moins une fin. Sans sélection, chaque fin déclenche ce workflow.", + "trigger_summary_all_endings": "Se déclenche à chaque réponse à l'enquête.", + "trigger_summary_ending_cards": "Se déclenche sur {count, plural, one {# carte de fin} other {# cartes de fin}}.", + "trigger_survey_description": "Choisis le questionnaire dont les réponses complétées déclenchent ce workflow.", + "trigger_survey_empty": "Aucun questionnaire dans cet espace de travail pour le moment.", + "trigger_survey_label": "Questionnaire", + "trigger_survey_placeholder": "Sélectionne un questionnaire", + "triggers": "Déclencheurs", + "unarchive": "Désarchiver", + "unarchive_failed": "Échec de la désarchivage du workflow. Réessaye.", + "unarchive_success": "Workflow désarchivé.", + "upgrade_prompt_description": "Automatise les tâches pilotées par les réponses avec des déclencheurs, des filtres et des actions.", + "upgrade_prompt_title": "Passe à la version supérieure pour débloquer les Workflows", + "validation_failed": "La validation du workflow a échoué.", + "validation_problem_fix_label": "Corriger : {problem}", + "validation_problem_flow_invalid": "Les étapes du workflow ne sont pas connectées en un flux exécutable unique.", + "validation_problem_generic": "Cette partie du workflow a un problème de configuration.", + "validation_problem_name_missing": "Donne un nom au workflow.", + "validation_problem_step_incomplete": "Remplis le destinataire, l'objet et le corps de l'étape d'e-mail.", + "validation_problem_step_not_executable": "Ce type d'étape ne peut pas encore s'exécuter. Supprime-le avant d'activer le workflow.", + "validation_problem_trigger_ending_not_found": "Une fin sélectionnée n'existe plus dans le questionnaire connecté.", + "validation_problem_trigger_missing": "Ajoute un déclencheur pour lancer le workflow.", + "validation_problem_trigger_not_connected": "Connecte une étape après le déclencheur.", + "validation_problem_trigger_survey_unbound": "Connecte le déclencheur à une enquête dans cet espace de travail.", + "validation_problems_count": "{count, plural, one {# problème} other {# problèmes}}", + "validation_problems_description": "Corrige ces problèmes avant que le workflow puisse s'exécuter :", + "validation_problems_title": "Problèmes de validation", + "validation_status_valid": "Valide", + "zoom_in": "Zoomer", + "zoom_out": "Dézoomer" + }, "xm-templates": { "ces": "CES", "ces_description": "Score d'Effort Client", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index ca2c36a2ecbf..93d924e0a33d 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Elfogadva", "account": "Fiók", "account_settings": "Fiókbeállítások", + "act": "Felvonás", "action": "Művelet", "actions": "Műveletek", "actions_description": "A kód vagy kód nélküli műveleteket arra használják, hogy aktiválják a kérdőívek alkalmazásokon és webhelyeken belüli elfogását.", @@ -185,6 +188,7 @@ "archive": "Archiválás", "archived": "Archiválva", "are_you_sure": "Biztos benne?", + "attempt": "Kísérlet", "attributes": "Attribútumok", "authorized_apps": "Authorized Apps", "back": "Vissza", @@ -193,6 +197,7 @@ "bottom_left": "Balra lent", "bottom_right": "Jobbra lent", "cancel": "Mégse", + "canceled": "Megszakítva", "centered_modal": "Középre helyezett kizárólagos", "chart": "Diagram", "charts": "Diagramok", @@ -265,9 +270,11 @@ "duplicate_copy_number": "({copyNumber}. másolat)", "e_commerce": "E-kereskedelem", "edit": "Szerkesztés", + "editor": "Szerkesztő", "elements": "Elemek", "email": "E-mail", "enable": "Engedélyezés", + "enabled": "Engedélyezve", "ending_card": "Befejező kártya", "enter_url": "URL megadása", "enterprise_license": "Vállalati licenc", @@ -278,6 +285,7 @@ "error_rate_limit_description": "A kérések legnagyobb száma elérve. Próbálja meg később újra.", "error_rate_limit_title": "A sebességkorlát elérve", "expand_rows": "Sorok kinyitása", + "failed": "Sikertelen", "failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra", "failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket", "failed_to_load_workspaces": "Nem sikerült betölteni a munkaterületeket", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "A fájlfeltöltési szolgáltatás nem érhető el", "filter": "Szűrő", "finish": "Befejezés", + "finished_at": "Befejezve", "first_name": "Keresztnév", "formbricks_version": "Formbricks verziója", "full_name": "Teljes név", @@ -310,6 +319,7 @@ "imprint": "Impresszum", "in_progress": "Folyamatban", "inactive_surveys": "Inaktív kérdőívek", + "input": "Bemenet", "integration": "integráció", "integrations": "Integrációk", "invalid_date_with_value": "Érvénytelen dátum: {value}", @@ -350,6 +360,7 @@ "move_up": "Mozgatás fel", "name": "Név", "new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!", + "new_workflow": "Új munkafolyamat", "next": "Következő", "no": "Nem", "no_actions_found": "Nem találhatók műveletek", @@ -388,10 +399,12 @@ "other": "Egyéb", "other_filters": "Egyéb szűrők", "other_placeholder": "Egyéb helykitöltő", + "output": "Kimenet", "overlay_color": "Rávetítés színe", "overview": "Áttekintés", "password": "Jelszó", "paused": "Szüneteltetve", + "pending": "Folyamatban", "pending_downgrade": "Régebbi verzió telepítésére várakozik", "people_manager": "Munkavállalói Élmény", "person": "Személy", @@ -412,6 +425,7 @@ "question": "kérdés", "question_id": "Kérdésazonosító", "questions": "Kérdések", + "queued": "Várakozik", "quota": "Kvóta", "quotas": "Kvóták", "quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.", @@ -424,15 +438,20 @@ "replace": "Csere", "report_survey": "Kérdőív jelentése", "request_trial_license": "Próbaidőszaki licenc kérése", + "required": "Kötelező", "reset_to_default": "Visszaállítás az alapértelmezettre", "resize": "Átméretezés", "response": "Válasz", + "response_completed": "Válasz befejezve", "response_id": "Válaszazonosító", "responses": "Válaszok", "restart": "Újraindítás", "retry": "Újrapróbálás", "role": "Szerep", "row_n": "{n}. sor", + "run_data": "Futtatási adatok", + "running": "Fut", + "runs": "Futtatások", "saas": "SaaS", "sales": "Értékesítés", "save": "Mentés", @@ -468,12 +487,16 @@ "something_went_wrong": "Valami probléma történt", "something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.", "sort_by": "Rendezési sorrend", + "sort_by_value": "Rendezési sorrend: {label}", + "started_at": "Elindítva", "status": "Állapot", + "steps": "Lépések", "storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek", "string": "Szöveg", "styling": "Stíluskészítés", "subheader": "Alcím", "submit": "Elküldés", + "succeeded": "Sikeres", "summary": "Összegzés", "survey": "Kérdőív", "survey_completed": "A kérdőív kitöltve.", @@ -506,8 +529,11 @@ "trial_expired": "A próbaidőszaka lejárt", "trial_one_day_remaining": "1 nap van hátra a próbaidőszakából", "trial_plan_badge": "{plan} próbaidőszaka", + "trigger": "Eseményindító", + "trigger_payload": "Trigger adatcsomag", "try_again": "Próbálja újra", "type": "Típus", + "unarchive": "Archiválás visszavonása", "undo": "Visszavonás", "unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.", "update": "Frissítés", @@ -527,6 +553,7 @@ "verified_email": "Ellenőrzött e-mail-cím", "video": "Videó", "view": "Megtekintés", + "view_workflow": "Munkafolyamat megtekintése", "warning": "Figyelmeztetés", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nem tudtuk ellenőrizni a licencét, mert a licenckiszolgáló nem érhető el.", "webhook": "Webhorog", @@ -536,6 +563,9 @@ "weeks": "hét", "welcome_card": "Üdvözlő kártya", "whats_new": "Újdonságok", + "workflow_name": "Munkafolyamat neve", + "workflow_runs": "Munkafolyamat-futtatások", + "workflows": "Munkafolyamatok", "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.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Adatvédelmi okokból a feltöltött fájlra mutató hivatkozást nem tartalmazza", "response_data": "Válasz adatai", "response_finished_email_subject": "A(z) {surveyName} kérdőívre adott válasz befejeződött ✅", - "response_finished_email_subject_with_email": "{personEmail} épp most töltötte ki a(z) {surveyName} kérdőívet ✅", "schedule_your_meeting": "Megbeszélés ütemezése", "select_a_date": "Dátum kiválasztása", "survey_response_finished_email_congrats": "Gratulálunk, új válasz érkezett a kérdőívére! Valaki épp most töltötte ki ezt a kérdőívet: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Kétfaktoros hitelesítés", "comparison_row_unify_feedback": "Visszajelzések egyesítése minden forrásból", "comparison_row_unlimited_seats": "Korlátlan számú felhasználó", + "comparison_row_workflows": "Munkafolyamatok", "comparison_row_workspaces": "Munkaterületek", "comparison_section_all_plans": "Minden csomag", "comparison_section_basic_usage": "Alapvető használat", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5000 válasz / hónap dinamikus árképzéssel", "plan_scale_feature_security": "2FA és spam védelem", "plan_scale_feature_semantic_analysis": "Szemantikai elemzés (AI)", + "plan_scale_feature_workflows": "Munkafolyamatok", "plan_scale_feature_workspaces": "5 munkaterület", "plan_selection_description": "Hobby, Pro és Scale csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.", "plan_selection_title": "Csomag kiválasztása", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Válasz adatainak csatolása", "follow_ups_modal_action_body_label": "Törzs", "follow_ups_modal_action_body_placeholder": "Az e-mail törzse", + "follow_ups_modal_action_email_already_added": "Ez az e-mail cím már hozzá lett adva", "follow_ups_modal_action_email_content": "E-mail tartalma", + "follow_ups_modal_action_email_input_placeholder": "Írjon be egy e-mail címet és nyomja meg a szóköz billentyűt", + "follow_ups_modal_action_email_invalid": "Kérem, adjon meg egy érvényes e-mail címet", "follow_ups_modal_action_email_settings": "E-mail beállításai", "follow_ups_modal_action_from_description": "Az az e-mail-cím, ahonnan az e-mail elküldésre kerül", "follow_ups_modal_action_from_label": "Feladó", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet", "follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.", "follow_ups_new": "Új utókövetés", + "follow_ups_workflows_alert_title": "Nagyobb rugalmasságra van szüksége? Automatizálja az utókövetéseket és még sok mást a Workflows segítségével.", "formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva", "four_points": "4 pont", "heading": "Címsor", @@ -4134,6 +4169,119 @@ "value_number": "Érték (szám)", "value_text": "Érték (szöveg)" }, + "workflows": { + "add_action": "Művelet hozzáadása", + "add_trigger": "Trigger hozzáadása", + "add_trigger_description": "Válassza ki, mi indítsa el ezt a munkafolyamatot.", + "all_changes_saved": "Minden módosítás mentésre került", + "alphabetical": "Ábécé szerinti", + "archive_confirm_body": "Az archiválás letiltja ezt a munkafolyamatot és leállítja annak futtatását. Később bármikor visszaállíthatja az archívumból.", + "archive_confirm_title": "Archiválja a munkafolyamatot?", + "archive_failed": "A munkafolyamat archiválása sikertelen volt. Kérjük, próbálja meg újra.", + "archive_success": "A munkafolyamat archiválva lett.", + "archive_workflow": "Munkafolyamat archiválása", + "archive_workflow_confirmation": "Biztos benne, hogy archiválni kívánja a következőt: \"{name}\"? Később visszaállíthatja.", + "archive_workflow_description": "Az archiválás elrejti a munkafolyamatot a listából. Később visszaállíthatja.", + "auto_layout": "Automatikus elrendezés", + "autosave_failed": "A mentés sikertelen volt", + "autosave_failed_tooltip": "A legutóbbi módosításait nem sikerült elmenteni. Kérem, ellenőrizze a kapcsolatot, és próbálja újra.", + "autosave_failed_tooltip_rejected": "A legutóbbi módosításokat nem sikerült menteni: {detail}", + "collapse_inspector": "Ellenőrző összecsukása", + "create_failed": "A munkafolyamat létrehozása sikertelen volt. Kérjük, próbálja meg újra.", + "delete_failed": "A munkafolyamat törlése sikertelen volt. Kérjük, próbálja meg újra.", + "delete_success": "A munkafolyamat törölve lett.", + "delete_workflow_confirmation": "Ez véglegesen törli a következőt: \"{name}\" és annak futtatási előzményeit.", + "disable_failed": "A munkafolyamat letiltása sikertelen volt.", + "disable_success": "Munkafolyamat letiltva.", + "duplicate_failed": "A munkafolyamat másolása sikertelen volt. Kérjük, próbálja meg újra.", + "duplicate_success": "A munkafolyamat lemásolva.", + "edit_blocked_active": "A módosítások elvégzéséhez kérem, tiltsa le a munkafolyamatot.", + "email_attach_response_data_description": "A kiváltó felmérési válasz csatolása az e-mail adatcsomaghoz.", + "email_attach_response_data_label": "Válaszadatok csatolása", + "email_body_label": "Törzs", + "email_body_placeholder": "Írja meg az elküldeni kívánt üzenetet…", + "email_body_required": "Adja meg az elküldendő üzenetet.", + "email_from_label": "Feladó", + "email_include_hidden_fields_label": "Rejtett mezők hozzáadása", + "email_include_variables_label": "Változók hozzáadása", + "email_needs_survey": "Először kapcsoljon össze egy felmérést a trigger lépésben. A címzett és az üzenet opciók a felmérés válaszaiból származnak.", + "email_reply_to_label": "Válaszcím", + "email_set_up_trigger": "Trigger beállítása", + "email_subject_label": "Tárgy", + "email_subject_placeholder": "Köszönjük, hogy kitöltötted a felmérést", + "email_subject_required": "Adjon meg egy tárgyat.", + "email_to_label": "Címzett", + "email_to_placeholder": "csapat@pelda.hu", + "email_to_required": "Válassza ki, hogy ki kapja meg ezt az e-mailt.", + "enable_blocked_unsaved_changes": "A legutóbbi módosításokat nem sikerült menteni, ezért a munkafolyamat nem lett engedélyezve.", + "enable_failed": "A munkafolyamat nem engedélyezhető.", + "enable_success": "Munkafolyamat engedélyezve.", + "expand_inspector": "Ellenőrző kibontása", + "if_else": "Ha / Egyébként", + "if_else_summary": "A munkafolyamat elágaztatása egy feltétel alapján.", + "inspector_unsupported_node": "Ez a csomóponttípus még nem rendelkezik konfigurációs űrlappal.", + "load_failed": "A munkafolyamat betöltése sikertelen volt.", + "name_required": "Adj meg egy nevet.", + "no_results_description": "Kérem, módosítsa a keresési feltételeket vagy a szűrőket.", + "no_results_title": "Nem találhatók munkafolyamatok", + "no_workflows_description": "Hozza létre első munkafolyamatát, hogy automatizálja a műveleteket, amikor válaszok érkeznek.", + "no_workflows_title": "Még nincsenek munkafolyamatok", + "node_actions": "Csomópont műveletek", + "node_needs_email_content": "Állítsa be a címzettet és a tartalmat", + "node_needs_survey": "Válasszon egy felmérést a kezdéshez", + "pan_mode": "Eltolás mód", + "pointer_mode": "Mutató mód", + "read_only": "Csak olvasható", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {# napja} other {# napja}}, {time}", + "relative_today": "Ma, {time}", + "relative_yesterday": "Tegnap, {time}", + "response_completed": "Válasz befejezve", + "response_completed_description": "Fut, amikor valaki befejez egy felmérési választ.", + "save_failed": "A munkafolyamat mentése sikertelen volt.", + "save_success": "Munkafolyamat elmentve.", + "saving_changes": "Mentés folyamatban…", + "search_by_workflow_name": "Keresés munkafolyamat neve alapján", + "send_email": "E-mail küldése", + "send_email_description": "E-mail küldése, amikor ez a munkafolyamat lefut.", + "send_email_summary": "E-mail küldése a következő címre: {to}.", + "send_email_unconfigured": "Konfigurálja az e-mail címzettet.", + "trigger_ending_cards_label": "Záró kártyák", + "trigger_ending_cards_none": "Ehhez a felméréshez nincsenek beállított lezárások.", + "trigger_ending_cards_pick_survey": "Válasszon ki egy felmérést a lezárásainak megtekintéséhez.", + "trigger_ending_cards_scope_all": "Összes lezárás", + "trigger_ending_cards_scope_specific": "Meghatározott lezárások", + "trigger_ending_cards_select_at_least_one": "Válasszon ki legalább egy lezárást. Ha egyik sincs kiválasztva, minden lezárás elindítja ezt a munkafolyamatot.", + "trigger_summary_all_endings": "Aktiválás bármilyen felmérési válasz esetén.", + "trigger_summary_ending_cards": "Aktiválás {count, plural, one {# befejező kártya} other {# befejező kártya}} esetén.", + "trigger_survey_description": "Válassza ki azt a felmérést, amelynek befejezett válaszai elindítják ezt a munkafolyamatot.", + "trigger_survey_empty": "Még nincsenek felmérések ebben a munkaterületen.", + "trigger_survey_label": "Felmérés", + "trigger_survey_placeholder": "Válasszon ki egy felmérést", + "triggers": "Indítók", + "unarchive": "Archiválás visszavonása", + "unarchive_failed": "A munkafolyamat archiválásának visszavonása sikertelen volt. Kérjük, próbálja meg újra.", + "unarchive_success": "A munkafolyamat archiválása visszavonva.", + "upgrade_prompt_description": "Automatizáljon válaszalapú feladatokat triggerekkel, szűrőkkel és műveletekkel.", + "upgrade_prompt_title": "Frissítsen a Munkafolyamatok feloldásához", + "validation_failed": "A munkafolyamat érvényesítése sikertelen volt.", + "validation_problem_fix_label": "Javítás: {problem}", + "validation_problem_flow_invalid": "A munkafolyamat lépései nincsenek egyetlen futtatható folyamattá összekapcsolva.", + "validation_problem_generic": "Ennek a munkafolyamat-résznek konfigurációs problémája van.", + "validation_problem_name_missing": "Adjon nevet a munkafolyamatnak.", + "validation_problem_step_incomplete": "Töltse ki az e-mail lépés címzettjét, tárgyát és törzsét.", + "validation_problem_step_not_executable": "Ez a lépéstípus még nem futtatható. Távolítsa el, mielőtt engedélyezi a munkafolyamatot.", + "validation_problem_trigger_ending_not_found": "A kiválasztott végpont már nem létezik a csatlakoztatott kérdőíven.", + "validation_problem_trigger_missing": "Adjon hozzá egy triggert a munkafolyamat indításához.", + "validation_problem_trigger_not_connected": "Kapcsoljon egy lépést a trigger után.", + "validation_problem_trigger_survey_unbound": "Kapcsolja a triggert egy felméréshez ebben a munkaterületen.", + "validation_problems_count": "{count, plural, one {# probléma} other {# probléma}}", + "validation_problems_description": "Javítsa ki ezeket a problémákat, mielőtt a munkafolyamat futhat:", + "validation_problems_title": "Érvényesítési problémák", + "validation_status_valid": "Érvényes", + "zoom_in": "Nagyítás", + "zoom_out": "Kicsinyítés" + }, "xm-templates": { "ces": "CES", "ces_description": "Ügyfélmegterhelési pontszám", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index d39950956bd6..fa2677e62662 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "承認済み", "account": "アカウント", "account_settings": "アカウント設定", + "act": "行動", "action": "アクション", "actions": "アクション", "actions_description": "コードとノーコードアクションは、アプリ内やウェブサイト上で調査を発動するために使用されます。", @@ -185,6 +188,7 @@ "archive": "アーカイブ", "archived": "アーカイブ済み", "are_you_sure": "よろしいですか?", + "attempt": "試行", "attributes": "属性", "authorized_apps": "Authorized Apps", "back": "戻る", @@ -193,6 +197,7 @@ "bottom_left": "左下", "bottom_right": "右下", "cancel": "キャンセル", + "canceled": "キャンセル済み", "centered_modal": "中央モーダル", "chart": "チャート", "charts": "チャート", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(コピー {copyNumber})", "e_commerce": "Eコマース", "edit": "編集", + "editor": "エディター", "elements": "要素", "email": "メールアドレス", "enable": "有効化", + "enabled": "有効", "ending_card": "終了カード", "enter_url": "URLを入力", "enterprise_license": "エンタープライズライセンス", @@ -278,6 +285,7 @@ "error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。", "error_rate_limit_title": "レート制限を超えました", "expand_rows": "行を展開", + "failed": "失敗", "failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました", "failed_to_load_organizations": "組織の読み込みに失敗しました", "failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "フィルター", "finish": "完了", + "finished_at": "完了日時", "first_name": "名", "formbricks_version": "Formbricksバージョン", "full_name": "氏名", @@ -310,6 +319,7 @@ "imprint": "企業情報", "in_progress": "進行中", "inactive_surveys": "非アクティブなフォーム", + "input": "入力", "integration": "連携", "integrations": "連携", "invalid_date_with_value": "無効な日付です: {value}", @@ -350,6 +360,7 @@ "move_up": "上に移動", "name": "名前", "new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!", + "new_workflow": "新しいワークフロー", "next": "次へ", "no": "いいえ", "no_actions_found": "アクションが見つかりません", @@ -388,10 +399,12 @@ "other": "その他", "other_filters": "その他のフィルター", "other_placeholder": "その他のプレースホルダー", + "output": "出力", "overlay_color": "オーバーレイの色", "overview": "概要", "password": "パスワード", "paused": "一時停止", + "pending": "保留中", "pending_downgrade": "ダウングレード保留中", "people_manager": "従業員エクスペリエンス", "person": "人", @@ -412,6 +425,7 @@ "question": "質問", "question_id": "質問ID", "questions": "質問", + "queued": "待機中", "quota": "クォータ", "quotas": "クォータ", "quotas_description": "特定の基準を満たす参加者からの回答数を制限する", @@ -424,15 +438,20 @@ "replace": "置き換え", "report_survey": "フォームを報告", "request_trial_license": "トライアルライセンスをリクエスト", + "required": "必須", "reset_to_default": "デフォルトにリセット", "resize": "サイズ変更", "response": "回答", + "response_completed": "応答完了", "response_id": "回答ID", "responses": "回答", "restart": "再開", "retry": "再試行", "role": "役割", "row_n": "行 {n}", + "run_data": "実行データ", + "running": "実行中", + "runs": "実行", "saas": "SaaS", "sales": "セールス", "save": "保存", @@ -468,12 +487,16 @@ "something_went_wrong": "問題が発生しました", "something_went_wrong_please_try_again": "問題が発生しました。もう一度お試しください。", "sort_by": "並び替え", + "sort_by_value": "並び替え: {label}", + "started_at": "開始日時", "status": "ステータス", + "steps": "ステップ", "storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります", "string": "テキスト", "styling": "スタイル", "subheader": "小見出し", "submit": "送信", + "succeeded": "成功", "summary": "概要", "survey": "フォーム", "survey_completed": "フォームが完了しました。", @@ -506,8 +529,11 @@ "trial_expired": "トライアル期間が終了しました", "trial_one_day_remaining": "トライアル期間の残り1日", "trial_plan_badge": "{plan}トライアル", + "trigger": "トリガー", + "trigger_payload": "トリガーペイロード", "try_again": "もう一度お試しください", "type": "種類", + "unarchive": "アーカイブ解除", "undo": "元に戻す", "unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。", "update": "更新", @@ -527,6 +553,7 @@ "verified_email": "認証済みメールアドレス", "video": "動画", "view": "表示", + "view_workflow": "ワークフローを表示", "warning": "警告", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "ライセンスサーバーにアクセスできないため、ライセンスを認証できませんでした。", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "週間", "welcome_card": "ウェルカムカード", "whats_new": "新機能", + "workflow_name": "ワークフロー名", + "workflow_runs": "ワークフロー実行", + "workflows": "ワークフロー", "workspace": "ワークスペース", "workspace_created_successfully": "ワークスペースが正常に作成されました", "workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "データプライバシーのため、アップロードされたファイルへのリンクは含まれていません", "response_data": "回答データ", "response_finished_email_subject": "{surveyName} の回答が完了しました ✅", - "response_finished_email_subject_with_email": "{personEmail} が {surveyName} フォームを完了しました ✅", "schedule_your_meeting": "ミーティングを予約", "select_a_date": "日付を選択", "survey_response_finished_email_congrats": "おめでとうございます、新しい回答が届きました!{surveyName} フォームへの回答が完了しました。", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "二段階認証", "comparison_row_unify_feedback": "すべてのソースからのフィードバックを統合", "comparison_row_unlimited_seats": "無制限のシート数", + "comparison_row_workflows": "ワークフロー", "comparison_row_workspaces": "ワークスペース", "comparison_section_all_plans": "すべてのプラン", "comparison_section_basic_usage": "基本利用", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "月間5,000件のレスポンス(動的価格設定)", "plan_scale_feature_security": "2FA&スパム保護", "plan_scale_feature_semantic_analysis": "セマンティック分析(AI)", + "plan_scale_feature_workflows": "ワークフロー", "plan_scale_feature_workspaces": "5つのワークスペース", "plan_selection_description": "Hobby、Pro、Scaleプランを比較して、Formbricksから直接プランを切り替えられます。", "plan_selection_title": "プランを選択", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "回答データを添付", "follow_ups_modal_action_body_label": "本文", "follow_ups_modal_action_body_placeholder": "メールの本文", + "follow_ups_modal_action_email_already_added": "このメールアドレスは既に追加されています", "follow_ups_modal_action_email_content": "メールの内容", + "follow_ups_modal_action_email_input_placeholder": "メールアドレスを入力してスペースキーを押してください", + "follow_ups_modal_action_email_invalid": "有効なメールアドレスを入力してください", "follow_ups_modal_action_email_settings": "メール設定", "follow_ups_modal_action_from_description": "メールを送信するメールアドレス", "follow_ups_modal_action_from_label": "送信元", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "回答者がフォームを完了したとき", "follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。", "follow_ups_new": "新しいフォローアップ", + "follow_ups_workflows_alert_title": "より柔軟性が必要ですか?ワークフローでフォローアップなどを自動化しましょう。", "formbricks_sdk_is_not_connected": "Formbricks SDKが接続されていません", "four_points": "4点", "heading": "見出し", @@ -4134,6 +4169,119 @@ "value_number": "値(数値)", "value_text": "値 (テキスト)" }, + "workflows": { + "add_action": "アクションを追加", + "add_trigger": "トリガーを追加", + "add_trigger_description": "このワークフローを開始する条件を選択してください。", + "all_changes_saved": "すべての変更を保存しました", + "alphabetical": "アルファベット順", + "archive_confirm_body": "アーカイブすると、このワークフローが無効化され、実行されなくなります。後で再度アーカイブ解除できます。", + "archive_confirm_title": "ワークフローをアーカイブしますか?", + "archive_failed": "ワークフローのアーカイブに失敗しました。もう一度お試しください。", + "archive_success": "ワークフローをアーカイブしました。", + "archive_workflow": "ワークフローをアーカイブ", + "archive_workflow_confirmation": "「{name}」をアーカイブしてもよろしいですか?後で復元できます。", + "archive_workflow_description": "アーカイブすると、ワークフローがリストに表示されなくなります。後で復元できます。", + "auto_layout": "自動レイアウト", + "autosave_failed": "保存に失敗しました", + "autosave_failed_tooltip": "最新の変更を保存できませんでした。接続を確認して、もう一度お試しください。", + "autosave_failed_tooltip_rejected": "最新の変更を保存できませんでした: {detail}", + "collapse_inspector": "インスペクターを折りたたむ", + "create_failed": "ワークフローの作成に失敗しました。もう一度お試しください。", + "delete_failed": "ワークフローの削除に失敗しました。もう一度お試しください。", + "delete_success": "ワークフローを削除しました。", + "delete_workflow_confirmation": "「{name}」と実行履歴が完全に削除されます。", + "disable_failed": "ワークフローを無効化できませんでした。", + "disable_success": "ワークフローを無効化しました。", + "duplicate_failed": "ワークフローの複製に失敗しました。もう一度お試しください。", + "duplicate_success": "ワークフローを複製しました。", + "edit_blocked_active": "ここで変更を行うには、ワークフローを無効にしてください。", + "email_attach_response_data_description": "トリガーとなったアンケート回答をメールペイロードに含めます。", + "email_attach_response_data_label": "回答データを添付", + "email_body_label": "本文", + "email_body_placeholder": "送信したいメッセージを入力してください…", + "email_body_required": "送信するメッセージを追加してください。", + "email_from_label": "送信者", + "email_include_hidden_fields_label": "非表示フィールドを含める", + "email_include_variables_label": "変数を含める", + "email_needs_survey": "まずトリガーステップでアンケートを接続してください。受信者とメッセージのオプションは、アンケートの回答から取得されます。", + "email_reply_to_label": "返信先", + "email_set_up_trigger": "トリガーを設定", + "email_subject_label": "件名", + "email_subject_placeholder": "アンケートへのご協力ありがとうございます", + "email_subject_required": "件名を追加してください。", + "email_to_label": "送信先", + "email_to_placeholder": "team@example.com", + "email_to_required": "このメールを受信する宛先を選択してください。", + "enable_blocked_unsaved_changes": "最新の変更を保存できなかったため、ワークフローは有効化されませんでした。", + "enable_failed": "ワークフローを有効化できませんでした。", + "enable_success": "ワークフローを有効化しました。", + "expand_inspector": "インスペクターを展開", + "if_else": "条件分岐", + "if_else_summary": "条件に基づいてワークフローを分岐します。", + "inspector_unsupported_node": "このノードタイプにはまだ設定フォームがありません。", + "load_failed": "ワークフローを読み込めませんでした。", + "name_required": "名前を入力してください。", + "no_results_description": "検索条件やフィルターを調整してみてください。", + "no_results_title": "ワークフローが見つかりません", + "no_workflows_description": "最初のワークフローを作成して、回答が届いたときのアクションを自動化しましょう。", + "no_workflows_title": "ワークフローはまだありません", + "node_actions": "ノードアクション", + "node_needs_email_content": "受信者と内容を設定", + "node_needs_survey": "開始するにはアンケートを選択してください", + "pan_mode": "パンモード", + "pointer_mode": "ポインターモード", + "read_only": "読み取り専用", + "relative_date": "{date} {time}", + "relative_days_ago": "{count, plural, other {#日前}} {time}", + "relative_today": "今日 {time}", + "relative_yesterday": "昨日 {time}", + "response_completed": "応答完了", + "response_completed_description": "誰かがアンケートの回答を完了したときに実行されます。", + "save_failed": "ワークフローを保存できませんでした。", + "save_success": "ワークフローを保存しました。", + "saving_changes": "保存中…", + "search_by_workflow_name": "ワークフロー名で検索", + "send_email": "メール送信", + "send_email_description": "このワークフローが実行されるとメールを送信します。", + "send_email_summary": "{to}にメールを送信します。", + "send_email_unconfigured": "メールの送信先を設定してください。", + "trigger_ending_cards_label": "エンディングカード", + "trigger_ending_cards_none": "このアンケートにはエンディングが設定されていません。", + "trigger_ending_cards_pick_survey": "エンディングを表示するアンケートを選択してください。", + "trigger_ending_cards_scope_all": "すべてのエンディング", + "trigger_ending_cards_scope_specific": "特定のエンディング", + "trigger_ending_cards_select_at_least_one": "少なくとも1つのエンディングを選択してください。何も選択しない場合、すべてのエンディングでこのワークフローが実行されます。", + "trigger_summary_all_endings": "すべてのアンケート回答でトリガーします。", + "trigger_summary_ending_cards": "{count, plural, other {# 個のエンディングカード}}でトリガーします。", + "trigger_survey_description": "このワークフローを実行する、完了した回答のアンケートを選択してください。", + "trigger_survey_empty": "このワークスペースにはまだアンケートがありません。", + "trigger_survey_label": "アンケート", + "trigger_survey_placeholder": "アンケートを選択", + "triggers": "トリガー", + "unarchive": "アーカイブを解除", + "unarchive_failed": "ワークフローのアーカイブ解除に失敗しました。もう一度お試しください。", + "unarchive_success": "ワークフローのアーカイブを解除しました。", + "upgrade_prompt_description": "トリガー、フィルター、アクションを使用して、レスポンス駆動型のタスクを自動化します。", + "upgrade_prompt_title": "アップグレードしてワークフローを利用する", + "validation_failed": "ワークフローの検証に失敗しました。", + "validation_problem_fix_label": "修正: {problem}", + "validation_problem_flow_invalid": "ワークフローのステップが実行可能な単一のフローに接続されていません。", + "validation_problem_generic": "ワークフローのこの部分に設定の問題があります。", + "validation_problem_name_missing": "ワークフローに名前を付けてください。", + "validation_problem_step_incomplete": "メールステップの宛先、件名、本文を入力してください。", + "validation_problem_step_not_executable": "このステップタイプはまだ実行できません。ワークフローを有効にする前に削除してください。", + "validation_problem_trigger_ending_not_found": "選択された終了条件が、接続されたアンケートに存在しなくなりました。", + "validation_problem_trigger_missing": "ワークフローを開始するトリガーを追加してください。", + "validation_problem_trigger_not_connected": "トリガーの後にステップを接続してください。", + "validation_problem_trigger_survey_unbound": "トリガーをこのワークスペース内のアンケートに接続してください。", + "validation_problems_count": "{count, plural, other {#件の問題}}", + "validation_problems_description": "ワークフローを実行する前に、これらの問題を修正してください:", + "validation_problems_title": "検証の問題", + "validation_status_valid": "有効", + "zoom_in": "拡大", + "zoom_out": "縮小" + }, "xm-templates": { "ces": "CES", "ces_description": "顧客努力指標", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 9c12fd8e0dc6..ef9d398ab070 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Geaccepteerd", "account": "Rekening", "account_settings": "Accountinstellingen", + "act": "Handelen", "action": "Actie", "actions": "Acties", "actions_description": "Code- en no-code-acties worden gebruikt om onderscheppingsenquêtes in apps en op websites te activeren.", @@ -185,6 +188,7 @@ "archive": "Archiveren", "archived": "Gearchiveerd", "are_you_sure": "Weet je het zeker?", + "attempt": "Poging", "attributes": "Kenmerken", "authorized_apps": "Authorized Apps", "back": "Rug", @@ -193,6 +197,7 @@ "bottom_left": "Linksonder", "bottom_right": "Rechtsonder", "cancel": "Annuleren", + "canceled": "Geannuleerd", "centered_modal": "Gecentreerd modaal", "chart": "Grafiek", "charts": "Grafieken", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(kopie {copyNumber})", "e_commerce": "E-commerce", "edit": "Bewerking", + "editor": "Editor", "elements": "Elementen", "email": "E-mail", "enable": "Inschakelen", + "enabled": "Ingeschakeld", "ending_card": "Einde kaart", "enter_url": "URL invoeren", "enterprise_license": "Enterprise-licentie", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.", "error_rate_limit_title": "Tarieflimiet overschreden", "expand_rows": "Vouw rijen uit", + "failed": "Mislukt", "failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt", "failed_to_load_organizations": "Laden van organisaties mislukt", "failed_to_load_workspaces": "Laden van werkruimtes mislukt", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filter", "finish": "Finish", + "finished_at": "Voltooid op", "first_name": "Voornaam", "formbricks_version": "Formbricks-versie", "full_name": "Volledige naam", @@ -310,6 +319,7 @@ "imprint": "Wettelijke vermeldingen", "in_progress": "In uitvoering", "inactive_surveys": "Inactieve enquêtes", + "input": "Invoer", "integration": "integratie", "integrations": "Integraties", "invalid_date_with_value": "Ongeldige datum: {value}", @@ -350,6 +360,7 @@ "move_up": "Ga omhoog", "name": "Naam", "new_version_available": "Formbricks {version} is hier. Upgrade nu!", + "new_workflow": "Nieuwe workflow", "next": "Volgende", "no": "Nee", "no_actions_found": "Geen acties gevonden", @@ -388,10 +399,12 @@ "other": "Ander", "other_filters": "Overige filters", "other_placeholder": "Andere tijdelijke aanduiding", + "output": "Uitvoer", "overlay_color": "Overlaykleur", "overview": "Overzicht", "password": "Wachtwoord", "paused": "Gepauzeerd", + "pending": "In behandeling", "pending_downgrade": "In afwachting van downgrade", "people_manager": "Medewerkersevaring", "person": "Persoon", @@ -412,6 +425,7 @@ "question": "vraag", "question_id": "Vraag-ID", "questions": "Vragen", + "queued": "In wachtrij", "quota": "Quotum", "quotas": "Quota", "quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.", @@ -424,15 +438,20 @@ "replace": "Vervangen", "report_survey": "Enquête melden", "request_trial_license": "Proeflicentie aanvragen", + "required": "Verplicht", "reset_to_default": "Resetten naar standaard", "resize": "Formaat wijzigen", "response": "Antwoord", + "response_completed": "Reactie voltooid", "response_id": "Antwoord-ID", "responses": "Reacties", "restart": "Opnieuw opstarten", "retry": "Opnieuw proberen", "role": "Rol", "row_n": "Rij {n}", + "run_data": "Uitvoeringsgegevens", + "running": "Actief", + "runs": "Uitvoeringen", "saas": "SaaS", "sales": "Verkoop", "save": "Redden", @@ -468,12 +487,16 @@ "something_went_wrong": "Er is iets misgegaan", "something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.", "sort_by": "Sorteer op", + "sort_by_value": "Sorteer op: {label}", + "started_at": "Gestart op", "status": "Status", + "steps": "Stappen", "storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken", "string": "Tekst", "styling": "Styling", "subheader": "Subkop", "submit": "Indienen", + "succeeded": "Geslaagd", "summary": "Samenvatting", "survey": "Vragenlijst", "survey_completed": "Enquête voltooid.", @@ -506,8 +529,11 @@ "trial_expired": "Je proefperiode is verlopen", "trial_one_day_remaining": "1 dag over in je proefperiode", "trial_plan_badge": "{plan} Proefperiode", + "trigger": "Trigger", + "trigger_payload": "Trigger-payload", "try_again": "Probeer het opnieuw", "type": "Type", + "unarchive": "Dearchiveren", "undo": "Ongedaan maken", "unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.", "update": "Update", @@ -527,6 +553,7 @@ "verified_email": "Geverifieerde e-mail", "video": "Video", "view": "Bekijken", + "view_workflow": "Workflow bekijken", "warning": "Waarschuwing", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We kunnen uw licentie niet verifiëren omdat de licentieserver niet bereikbaar is.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "weken", "welcome_card": "Welkomstkaart", "whats_new": "Wat is er nieuw", + "workflow_name": "Workflownaam", + "workflow_runs": "Workflow-uitvoeringen", + "workflows": "Workflows", "workspace": "Werkruimte", "workspace_created_successfully": "Werkruimte succesvol aangemaakt", "workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "De link naar het geüploade bestand is om redenen van gegevensprivacy niet opgenomen", "response_data": "Responsgegevens", "response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅", - "response_finished_email_subject_with_email": "{personEmail} heeft zojuist uw {surveyName} enquête voltooid ✅", "schedule_your_meeting": "Plan uw vergadering", "select_a_date": "Selecteer een datum", "survey_response_finished_email_congrats": "Gefeliciteerd, u heeft een nieuwe reactie op uw enquête ontvangen! Iemand heeft zojuist uw enquête ingevuld: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Tweefactorauthenticatie", "comparison_row_unify_feedback": "Feedback uit alle bronnen bundelen", "comparison_row_unlimited_seats": "Onbeperkt aantal seats", + "comparison_row_workflows": "Workflows", "comparison_row_workspaces": "Workspaces", "comparison_section_all_plans": "Alle abonnementen", "comparison_section_basic_usage": "Kerngebruik", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5.000 reacties / maand met dynamische prijzen", "plan_scale_feature_security": "2FA & spambescherming", "plan_scale_feature_semantic_analysis": "Semantische analyse (AI)", + "plan_scale_feature_workflows": "Workflows", "plan_scale_feature_workspaces": "5 werkruimtes", "plan_selection_description": "Vergelijk Hobby, Pro en Scale, en schakel direct vanuit Formbricks tussen abonnementen.", "plan_selection_title": "Kies je abonnement", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Reactiegegevens bijvoegen", "follow_ups_modal_action_body_label": "Lichaam", "follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail", + "follow_ups_modal_action_email_already_added": "Dit e-mailadres is al toegevoegd", "follow_ups_modal_action_email_content": "E-mailinhoud", + "follow_ups_modal_action_email_input_placeholder": "Typ een e-mailadres en druk op de spatiebalk", + "follow_ups_modal_action_email_invalid": "Voer een geldig e-mailadres in", "follow_ups_modal_action_email_settings": "E-mailinstellingen", "follow_ups_modal_action_from_description": "E-mailadres waar vandaan de e-mail moet worden verzonden", "follow_ups_modal_action_from_label": "Van", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Respondent vult enquête in", "follow_ups_modal_updated_successfull_toast": "Follow-up bijgewerkt en wordt opgeslagen zodra u de enquête opslaat.", "follow_ups_new": "Nieuw vervolg", + "follow_ups_workflows_alert_title": "Meer flexibiliteit nodig? Automatiseer follow-ups en nog veel meer met Workflows.", "formbricks_sdk_is_not_connected": "Formbricks SDK is niet verbonden", "four_points": "4 punten", "heading": "Rubriek", @@ -4134,6 +4169,119 @@ "value_number": "Waarde (getal)", "value_text": "Waarde (tekst)" }, + "workflows": { + "add_action": "Actie toevoegen", + "add_trigger": "Trigger toevoegen", + "add_trigger_description": "Kies wat deze workflow start.", + "all_changes_saved": "Alle wijzigingen opgeslagen", + "alphabetical": "Alfabetisch", + "archive_confirm_body": "Archiveren schakelt deze workflow uit en stopt de uitvoering ervan. Je kunt het later weer uit het archief halen.", + "archive_confirm_title": "Workflow archiveren?", + "archive_failed": "Archiveren van de workflow is mislukt. Probeer het opnieuw.", + "archive_success": "Workflow gearchiveerd.", + "archive_workflow": "Workflow archiveren", + "archive_workflow_confirmation": "Weet je zeker dat je \"{name}\" wilt archiveren? Je kunt deze later herstellen.", + "archive_workflow_description": "Archiveren verbergt de workflow uit de lijst. Je kunt deze later herstellen.", + "auto_layout": "Automatische indeling", + "autosave_failed": "Opslaan mislukt", + "autosave_failed_tooltip": "Je laatste wijzigingen konden niet worden opgeslagen. Controleer je verbinding en probeer het opnieuw.", + "autosave_failed_tooltip_rejected": "Je laatste wijzigingen konden niet worden opgeslagen: {detail}", + "collapse_inspector": "Inspector inklappen", + "create_failed": "Aanmaken van de workflow is mislukt. Probeer het opnieuw.", + "delete_failed": "Verwijderen van de workflow is mislukt. Probeer het opnieuw.", + "delete_success": "Workflow verwijderd.", + "delete_workflow_confirmation": "Dit verwijdert \"{name}\" en de uitvoeringsgeschiedenis permanent.", + "disable_failed": "Kon de workflow niet uitschakelen.", + "disable_success": "Workflow uitgeschakeld.", + "duplicate_failed": "Dupliceren van de workflow is mislukt. Probeer het opnieuw.", + "duplicate_success": "Workflow gedupliceerd.", + "edit_blocked_active": "Schakel de workflow uit om hier wijzigingen aan te brengen.", + "email_attach_response_data_description": "Voeg de antwoordgegevens van de enquête toe aan de e-mailpayload.", + "email_attach_response_data_label": "Antwoordgegevens toevoegen", + "email_body_label": "Bericht", + "email_body_placeholder": "Schrijf het bericht dat je wilt versturen…", + "email_body_required": "Voeg het bericht toe om te verzenden.", + "email_from_label": "Van", + "email_include_hidden_fields_label": "Verborgen velden opnemen", + "email_include_variables_label": "Variabelen opnemen", + "email_needs_survey": "Koppel eerst een enquête in de triggerstap. De ontvanger- en berichtopties komen uit de antwoorden van de enquête.", + "email_reply_to_label": "Beantwoorden aan", + "email_set_up_trigger": "Trigger instellen", + "email_subject_label": "Onderwerp", + "email_subject_placeholder": "Bedankt voor het invullen van de enquête", + "email_subject_required": "Voeg een onderwerpregeltje toe.", + "email_to_label": "Verzenden naar", + "email_to_placeholder": "team@voorbeeld.nl", + "email_to_required": "Kies wie deze e-mail moet ontvangen.", + "enable_blocked_unsaved_changes": "Je laatste wijzigingen konden niet worden opgeslagen, dus de workflow is niet ingeschakeld.", + "enable_failed": "Workflow kon niet worden ingeschakeld.", + "enable_success": "Workflow ingeschakeld.", + "expand_inspector": "Inspector uitklappen", + "if_else": "Als / Anders", + "if_else_summary": "Splits de workflow op basis van een voorwaarde.", + "inspector_unsupported_node": "Dit type node heeft nog geen configuratieformulier.", + "load_failed": "Kon de workflow niet laden.", + "name_required": "Voer een naam in.", + "no_results_description": "Probeer je zoekopdracht of filters aan te passen.", + "no_results_title": "Geen workflows gevonden", + "no_workflows_description": "Maak je eerste workflow aan om acties te automatiseren wanneer er reacties binnenkomen.", + "no_workflows_title": "Nog geen workflows", + "node_actions": "Nodeacties", + "node_needs_email_content": "Stel ontvanger en inhoud in", + "node_needs_survey": "Kies een enquête om te beginnen", + "pan_mode": "Versleepstand", + "pointer_mode": "Aanwijzerstand", + "read_only": "Alleen-lezen", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {# dag geleden} other {# dagen geleden}}, {time}", + "relative_today": "Vandaag, {time}", + "relative_yesterday": "Gisteren, {time}", + "response_completed": "Reactie voltooid", + "response_completed_description": "Wordt uitgevoerd wanneer iemand een enquête volledig heeft ingevuld.", + "save_failed": "Kon de workflow niet opslaan.", + "save_success": "Workflow opgeslagen.", + "saving_changes": "Opslaan…", + "search_by_workflow_name": "Zoeken op workflownaam", + "send_email": "E-mail versturen", + "send_email_description": "Verstuur een e-mail wanneer deze workflow wordt uitgevoerd.", + "send_email_summary": "Verstuur een e-mail naar {to}.", + "send_email_unconfigured": "Configureer de e-mailontvanger.", + "trigger_ending_cards_label": "Eindschermen", + "trigger_ending_cards_none": "Deze enquête heeft geen eindschermen geconfigureerd.", + "trigger_ending_cards_pick_survey": "Kies een enquête om de eindschermen te zien.", + "trigger_ending_cards_scope_all": "Alle eindschermen", + "trigger_ending_cards_scope_specific": "Specifieke eindschermen", + "trigger_ending_cards_select_at_least_one": "Selecteer minimaal één eindscherm. Als je er geen selecteert, activeert elk eindscherm deze workflow.", + "trigger_summary_all_endings": "Activeren bij elke enquêterespons.", + "trigger_summary_ending_cards": "Activeren bij {count, plural, one {# eindkaart} other {# eindkaarten}}.", + "trigger_survey_description": "Kies de enquête waarvan de voltooide antwoorden deze workflow activeren.", + "trigger_survey_empty": "Nog geen enquêtes in deze workspace.", + "trigger_survey_label": "Enquête", + "trigger_survey_placeholder": "Selecteer een enquête", + "triggers": "Triggers", + "unarchive": "Dearchiveren", + "unarchive_failed": "Dearchiveren van de workflow is mislukt. Probeer het opnieuw.", + "unarchive_success": "Workflow gedearchiveerd.", + "upgrade_prompt_description": "Automatiseer taken op basis van reacties met triggers, filters en acties.", + "upgrade_prompt_title": "Upgrade om Workflows te ontgrendelen", + "validation_failed": "Workflowvalidatie mislukt.", + "validation_problem_fix_label": "Oplossen: {problem}", + "validation_problem_flow_invalid": "De workflowstappen zijn niet verbonden tot een enkele uitvoerbare flow.", + "validation_problem_generic": "Dit deel van de workflow heeft een configuratieprobleem.", + "validation_problem_name_missing": "Geef de workflow een naam.", + "validation_problem_step_incomplete": "Vul de ontvanger, onderwerp en tekst van de e-mailstap in.", + "validation_problem_step_not_executable": "Dit staptype kan nog niet worden uitgevoerd. Verwijder het voordat je de workflow activeert.", + "validation_problem_trigger_ending_not_found": "Een geselecteerd einde bestaat niet meer in de gekoppelde enquête.", + "validation_problem_trigger_missing": "Voeg een trigger toe om de workflow te starten.", + "validation_problem_trigger_not_connected": "Verbind een stap na de trigger.", + "validation_problem_trigger_survey_unbound": "Verbind de trigger met een enquête in deze workspace.", + "validation_problems_count": "{count, plural, one {# probleem} other {# problemen}}", + "validation_problems_description": "Los deze problemen op voordat de workflow kan draaien:", + "validation_problems_title": "Validatieproblemen", + "validation_status_valid": "Geldig", + "zoom_in": "Inzoomen", + "zoom_out": "Uitzoomen" + }, "xm-templates": { "ces": "CES", "ces_description": "Customer Effort Score", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 0864297e313d..8c1ee786e312 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Aceito", "account": "conta", "account_settings": "Configurações da conta", + "act": "Agir", "action": "Ação", "actions": "Ações", "actions_description": "Ações de Código e Sem Código são usadas para acionar interceptar pesquisas dentro de apps & em sites.", @@ -185,6 +188,7 @@ "archive": "Arquivar", "archived": "Arquivado", "are_you_sure": "Certeza?", + "attempt": "Tentativa", "attributes": "atributos", "authorized_apps": "Authorized Apps", "back": "Voltar", @@ -193,6 +197,7 @@ "bottom_left": "canto inferior esquerdo", "bottom_right": "Canto Inferior Direito", "cancel": "Cancelar", + "canceled": "Cancelado", "centered_modal": "Modal Centralizado", "chart": "Gráfico", "charts": "Gráficos", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(cópia {copyNumber})", "e_commerce": "comércio eletrônico", "edit": "Editar", + "editor": "Editor", "elements": "Elementos", "email": "Email", "enable": "Ativar", + "enabled": "Ativado", "ending_card": "Cartão de encerramento", "enter_url": "Inserir URL", "enterprise_license": "Licença Empresarial", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.", "error_rate_limit_title": "Limite de Taxa Excedido", "expand_rows": "Expandir linhas", + "failed": "Falhou", "failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência", "failed_to_load_organizations": "Falha ao carregar organizações", "failed_to_load_workspaces": "Falha ao carregar workspaces", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filtro", "finish": "Terminar", + "finished_at": "Finalizado em", "first_name": "Primeiro nome", "formbricks_version": "Versão do Formbricks", "full_name": "Nome completo", @@ -310,6 +319,7 @@ "imprint": "impressão", "in_progress": "Em andamento", "inactive_surveys": "Pesquisas inativas", + "input": "Entrada", "integration": "integração", "integrations": "Integrações", "invalid_date_with_value": "Data inválida: {value}", @@ -350,6 +360,7 @@ "move_up": "Subir", "name": "Nome", "new_version_available": "Formbricks {version} chegou. Atualize agora!", + "new_workflow": "Novo fluxo de trabalho", "next": "Próximo", "no": "Não", "no_actions_found": "Nenhuma ação encontrada", @@ -388,10 +399,12 @@ "other": "outro", "other_filters": "Outros Filtros", "other_placeholder": "Outro espaço reservado", + "output": "Saída", "overlay_color": "Cor da sobreposição", "overview": "Visão Geral", "password": "Senha", "paused": "Pausado", + "pending": "Pendente", "pending_downgrade": "Rebaixamento Pendente", "people_manager": "Experiência do Colaborador", "person": "Pessoa", @@ -412,6 +425,7 @@ "question": "pergunta", "question_id": "ID da Pergunta", "questions": "Perguntas", + "queued": "Na fila", "quota": "Cota", "quotas": "Cotas", "quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.", @@ -424,15 +438,20 @@ "replace": "Substituir", "report_survey": "Relatório de Pesquisa", "request_trial_license": "Pedir licença de teste", + "required": "Obrigatório", "reset_to_default": "Restaurar para o padrão", "resize": "Redimensionar", "response": "Resposta", + "response_completed": "Resposta concluída", "response_id": "ID da resposta", "responses": "Respostas", "restart": "Reiniciar", "retry": "Tentar novamente", "role": "Rolê", "row_n": "Linha {n}", + "run_data": "Dados de execução", + "running": "Executando", + "runs": "Execuções", "saas": "SaaS", "sales": "vendas", "save": "Salvar", @@ -468,12 +487,16 @@ "something_went_wrong": "Algo deu errado", "something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.", "sort_by": "Ordenar por", + "sort_by_value": "Ordenar por: {label}", + "started_at": "Iniciado em", "status": "status", + "steps": "Etapas", "storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão", "string": "Texto", "styling": "Estilização", "subheader": "Subtítulo", "submit": "Enviar", + "succeeded": "Bem-sucedido", "summary": "Resumo", "survey": "Pesquisa", "survey_completed": "Pesquisa concluída.", @@ -506,8 +529,11 @@ "trial_expired": "Seu período de teste expirou", "trial_one_day_remaining": "1 dia restante no seu período de teste", "trial_plan_badge": "Teste {plan}", + "trigger": "Gatilho", + "trigger_payload": "Payload do gatilho", "try_again": "Tenta de novo", "type": "Tipo", + "unarchive": "Desarquivar", "undo": "Desfazer", "unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais workspaces com um plano superior.", "update": "atualizar", @@ -527,6 +553,7 @@ "verified_email": "Email Verificado", "video": "vídeo", "view": "Visualizar", + "view_workflow": "Ver fluxo de trabalho", "warning": "Aviso", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não conseguimos verificar sua licença porque o servidor de licenças está inacessível.", "webhook": "webhook", @@ -536,6 +563,9 @@ "weeks": "semanas", "welcome_card": "Cartão de boas-vindas", "whats_new": "Novidades", + "workflow_name": "Nome do Fluxo de Trabalho", + "workflow_runs": "Execuções de workflows", + "workflows": "Fluxos de trabalho", "workspace": "Espaço de trabalho", "workspace_created_successfully": "Workspace criado com sucesso", "workspace_creation_description": "Organize pesquisas em workspaces para melhor controle de acesso.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados", "response_data": "Dados de resposta", "response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅", - "response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅", "schedule_your_meeting": "Agendar sua reunião", "select_a_date": "Selecione uma data", "survey_response_finished_email_congrats": "Parabéns, você recebeu uma nova resposta na sua pesquisa! Alguém acabou de completar sua pesquisa: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Autenticação de dois fatores", "comparison_row_unify_feedback": "Unifique feedback de todas as fontes", "comparison_row_unlimited_seats": "Assentos ilimitados", + "comparison_row_workflows": "Fluxos de trabalho", "comparison_row_workspaces": "Workspaces", "comparison_section_all_plans": "Todos os planos", "comparison_section_basic_usage": "Uso principal", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos", "plan_scale_feature_security": "Autenticação 2FA e proteção contra spam", "plan_scale_feature_semantic_analysis": "Análise Semântica (IA)", + "plan_scale_feature_workflows": "Fluxos de trabalho", "plan_scale_feature_workspaces": "5 espaços de trabalho", "plan_selection_description": "Compare os planos Hobby, Pro e Scale e mude de plano diretamente no Formbricks.", "plan_selection_title": "Escolha seu plano", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta", "follow_ups_modal_action_body_label": "Corpo", "follow_ups_modal_action_body_placeholder": "Corpo do e-mail", + "follow_ups_modal_action_email_already_added": "Este e-mail já foi adicionado", "follow_ups_modal_action_email_content": "Conteúdo do e-mail", + "follow_ups_modal_action_email_input_placeholder": "Digite um e-mail e pressione a barra de espaço", + "follow_ups_modal_action_email_invalid": "Por favor, insira um endereço de e-mail válido", "follow_ups_modal_action_email_settings": "Configuração de e-mail", "follow_ups_modal_action_from_description": "Endereço de e-mail de onde o e-mail será enviado", "follow_ups_modal_action_from_label": "De", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa", "follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.", "follow_ups_new": "Novo acompanhamento", + "follow_ups_workflows_alert_title": "Precisa de mais flexibilidade? Automatize follow-ups e muito mais com Workflows.", "formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado", "four_points": "4 pontos", "heading": "Título", @@ -4134,6 +4169,119 @@ "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, + "workflows": { + "add_action": "Adicionar ação", + "add_trigger": "Adicionar gatilho", + "add_trigger_description": "Escolha o que inicia este fluxo de trabalho.", + "all_changes_saved": "Todas as alterações salvas", + "alphabetical": "Alfabética", + "archive_confirm_body": "Arquivar desativa este fluxo de trabalho e impede que ele seja executado. Você pode desarquivá-lo novamente mais tarde.", + "archive_confirm_title": "Arquivar fluxo de trabalho?", + "archive_failed": "Falha ao arquivar o fluxo de trabalho. Tente novamente.", + "archive_success": "Fluxo de trabalho arquivado.", + "archive_workflow": "Arquivar fluxo de trabalho", + "archive_workflow_confirmation": "Tem certeza que deseja arquivar \"{name}\"? Você pode restaurá-lo depois.", + "archive_workflow_description": "Arquivar oculta o fluxo de trabalho da lista. Você pode restaurá-lo depois.", + "auto_layout": "Layout automático", + "autosave_failed": "Falha ao salvar", + "autosave_failed_tooltip": "Suas últimas alterações não puderam ser salvas. Verifique sua conexão e tente novamente.", + "autosave_failed_tooltip_rejected": "Suas últimas alterações não puderam ser salvas: {detail}", + "collapse_inspector": "Recolher inspetor", + "create_failed": "Falha ao criar o fluxo de trabalho. Tente novamente.", + "delete_failed": "Falha ao excluir o fluxo de trabalho. Tente novamente.", + "delete_success": "Fluxo de trabalho excluído.", + "delete_workflow_confirmation": "Isso exclui permanentemente \"{name}\" e seu histórico de execuções.", + "disable_failed": "Não foi possível desabilitar o fluxo de trabalho.", + "disable_success": "Fluxo de trabalho desabilitado.", + "duplicate_failed": "Falha ao duplicar o fluxo de trabalho. Tente novamente.", + "duplicate_success": "Fluxo de trabalho duplicado.", + "edit_blocked_active": "Desative o fluxo de trabalho para fazer alterações aqui.", + "email_attach_response_data_description": "Inclui a resposta da pesquisa que disparou o fluxo junto com os dados do e-mail.", + "email_attach_response_data_label": "Anexar dados da resposta", + "email_body_label": "Corpo", + "email_body_placeholder": "Escreva a mensagem que você quer enviar…", + "email_body_required": "Adicione a mensagem para enviar.", + "email_from_label": "De", + "email_include_hidden_fields_label": "Incluir campos ocultos", + "email_include_variables_label": "Incluir variáveis", + "email_needs_survey": "Conecte uma pesquisa na etapa de gatilho primeiro. As opções de destinatário e mensagem vêm das respostas da pesquisa.", + "email_reply_to_label": "Responder para", + "email_set_up_trigger": "Configurar gatilho", + "email_subject_label": "Assunto", + "email_subject_placeholder": "Obrigado por completar a pesquisa", + "email_subject_required": "Adicione um assunto.", + "email_to_label": "Enviar para", + "email_to_placeholder": "equipe@exemplo.com", + "email_to_required": "Escolha quem deve receber este e-mail.", + "enable_blocked_unsaved_changes": "Suas últimas alterações não puderam ser salvas, então o fluxo de trabalho não foi ativado.", + "enable_failed": "Não foi possível ativar o fluxo de trabalho.", + "enable_success": "Fluxo de trabalho ativado.", + "expand_inspector": "Expandir inspetor", + "if_else": "Se / Senão", + "if_else_summary": "Ramifica o fluxo de trabalho com base em uma condição.", + "inspector_unsupported_node": "Este tipo de nó ainda não tem um formulário de configuração.", + "load_failed": "Não foi possível carregar o fluxo de trabalho.", + "name_required": "Insira um nome.", + "no_results_description": "Tente ajustar sua busca ou filtros.", + "no_results_title": "Nenhum workflow encontrado", + "no_workflows_description": "Crie seu primeiro fluxo de trabalho para automatizar ações quando as respostas chegarem.", + "no_workflows_title": "Ainda não há workflows", + "node_actions": "Ações do nó", + "node_needs_email_content": "Definir destinatário e conteúdo", + "node_needs_survey": "Escolha uma pesquisa para começar", + "pan_mode": "Modo panorâmica", + "pointer_mode": "Modo ponteiro", + "read_only": "Somente leitura", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {# dia atrás} other {# dias atrás}}, {time}", + "relative_today": "Hoje, {time}", + "relative_yesterday": "Ontem, {time}", + "response_completed": "Resposta concluída", + "response_completed_description": "Executa quando alguém completa uma resposta de pesquisa.", + "save_failed": "Não foi possível salvar o fluxo de trabalho.", + "save_success": "Fluxo de trabalho salvo.", + "saving_changes": "Salvando…", + "search_by_workflow_name": "Buscar por nome do fluxo de trabalho", + "send_email": "Enviar e-mail", + "send_email_description": "Envia um e-mail quando este fluxo de trabalho é executado.", + "send_email_summary": "Enviar um e-mail para {to}.", + "send_email_unconfigured": "Configure o destinatário do e-mail.", + "trigger_ending_cards_label": "Telas de encerramento", + "trigger_ending_cards_none": "Esta pesquisa não tem encerramentos configurados.", + "trigger_ending_cards_pick_survey": "Escolha uma pesquisa para ver seus encerramentos.", + "trigger_ending_cards_scope_all": "Todos os encerramentos", + "trigger_ending_cards_scope_specific": "Encerramentos específicos", + "trigger_ending_cards_select_at_least_one": "Selecione pelo menos um encerramento. Com nenhum selecionado, todos os encerramentos acionam este fluxo de trabalho.", + "trigger_summary_all_endings": "Acionar em qualquer resposta da pesquisa.", + "trigger_summary_ending_cards": "Acionar em {count, plural, one {# cartão de encerramento} other {# cartões de encerramento}}.", + "trigger_survey_description": "Escolha a pesquisa cujas respostas concluídas acionam este fluxo de trabalho.", + "trigger_survey_empty": "Ainda não há pesquisas neste workspace.", + "trigger_survey_label": "Pesquisa", + "trigger_survey_placeholder": "Selecione uma pesquisa", + "triggers": "Gatilhos", + "unarchive": "Desarquivar", + "unarchive_failed": "Falha ao desarquivar o fluxo de trabalho. Tente novamente.", + "unarchive_success": "Fluxo de trabalho desarquivado.", + "upgrade_prompt_description": "Automatize tarefas orientadas por respostas com gatilhos, filtros e ações.", + "upgrade_prompt_title": "Faça upgrade para desbloquear Fluxos de trabalho", + "validation_failed": "Falha na validação do workflow.", + "validation_problem_fix_label": "Corrigir: {problem}", + "validation_problem_flow_invalid": "As etapas do fluxo de trabalho não estão conectadas em um fluxo executável único.", + "validation_problem_generic": "Esta parte do fluxo de trabalho tem um problema de configuração.", + "validation_problem_name_missing": "Dê um nome ao fluxo de trabalho.", + "validation_problem_step_incomplete": "Preencha o destinatário, assunto e corpo da etapa de e-mail.", + "validation_problem_step_not_executable": "Este tipo de etapa ainda não pode ser executado. Remova-o antes de ativar o fluxo de trabalho.", + "validation_problem_trigger_ending_not_found": "Um final selecionado não existe mais na pesquisa conectada.", + "validation_problem_trigger_missing": "Adicione um gatilho para iniciar o fluxo de trabalho.", + "validation_problem_trigger_not_connected": "Conecte uma etapa após o gatilho.", + "validation_problem_trigger_survey_unbound": "Conecte o gatilho a uma pesquisa neste espaço de trabalho.", + "validation_problems_count": "{count, plural, one {# problema} other {# problemas}}", + "validation_problems_description": "Corrija esses problemas antes que o fluxo de trabalho possa ser executado:", + "validation_problems_title": "Problemas de validação", + "validation_status_valid": "Válido", + "zoom_in": "Aumentar zoom", + "zoom_out": "Diminuir zoom" + }, "xm-templates": { "ces": "CES", "ces_description": "Customer Effort Score", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index feffc039f973..634da4c5e06b 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Aceite", "account": "Conta", "account_settings": "Configurações da conta", + "act": "Agir", "action": "Ação", "actions": "Ações", "actions_description": "As ações com código e sem código são usadas para acionar pesquisas de interceptação em apps e em sites.", @@ -185,6 +188,7 @@ "archive": "Arquivar", "archived": "Arquivado", "are_you_sure": "Tem a certeza?", + "attempt": "Tentativa", "attributes": "Atributos", "authorized_apps": "Authorized Apps", "back": "Voltar", @@ -193,6 +197,7 @@ "bottom_left": "Inferior Esquerdo", "bottom_right": "Inferior Direito", "cancel": "Cancelar", + "canceled": "Cancelado", "centered_modal": "Modal Centralizado", "chart": "Gráfico", "charts": "Gráficos", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(cópia {copyNumber})", "e_commerce": "Comércio Eletrónico", "edit": "Editar", + "editor": "Editor", "elements": "Elementos", "email": "Email", "enable": "Ativar", + "enabled": "Ativado", "ending_card": "Cartão de encerramento", "enter_url": "Introduzir URL", "enterprise_license": "Licença Enterprise", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.", "error_rate_limit_title": "Limite de Taxa Excedido", "expand_rows": "Expandir linhas", + "failed": "Falhou", "failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência", "failed_to_load_organizations": "Falha ao carregar organizações", "failed_to_load_workspaces": "Falha ao carregar espaços de trabalho", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filtro", "finish": "Concluir", + "finished_at": "Concluído Em", "first_name": "Primeiro nome", "formbricks_version": "Versão do Formbricks", "full_name": "Nome completo", @@ -310,6 +319,7 @@ "imprint": "Impressão", "in_progress": "Em Progresso", "inactive_surveys": "Inquéritos inativos", + "input": "Entrada", "integration": "integração", "integrations": "Integrações", "invalid_date_with_value": "Data inválida: {value}", @@ -350,6 +360,7 @@ "move_up": "Mover para cima", "name": "Nome", "new_version_available": "Formbricks {version} está aqui. Atualize agora!", + "new_workflow": "Novo fluxo de trabalho", "next": "Seguinte", "no": "Não", "no_actions_found": "Nenhuma ação encontrada", @@ -388,10 +399,12 @@ "other": "Outro", "other_filters": "Outros Filtros", "other_placeholder": "Outro espaço reservado", + "output": "Saída", "overlay_color": "Cor da sobreposição", "overview": "Visão geral", "password": "Palavra-passe", "paused": "Em pausa", + "pending": "Pendente", "pending_downgrade": "Rebaixamento Pendente", "people_manager": "Experiência do Colaborador", "person": "Pessoa", @@ -412,6 +425,7 @@ "question": "pergunta", "question_id": "ID da pergunta", "questions": "Perguntas", + "queued": "Na fila", "quota": "Quota", "quotas": "Quotas", "quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.", @@ -424,15 +438,20 @@ "replace": "Substituir", "report_survey": "Relatório de Inquérito", "request_trial_license": "Solicitar licença de teste", + "required": "Obrigatório", "reset_to_default": "Repor para o padrão", "resize": "Redimensionar", "response": "Resposta", + "response_completed": "Resposta concluída", "response_id": "ID de resposta", "responses": "Respostas", "restart": "Reiniciar", "retry": "Tentar novamente", "role": "Função", "row_n": "Linha {n}", + "run_data": "Dados de execução", + "running": "Em execução", + "runs": "Execuções", "saas": "SaaS", "sales": "Vendas", "save": "Guardar", @@ -468,12 +487,16 @@ "something_went_wrong": "Algo correu mal", "something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.", "sort_by": "Ordem", + "sort_by_value": "Ordem: {label}", + "started_at": "Iniciado Em", "status": "Estado", + "steps": "Passos", "storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão", "string": "Texto", "styling": "Estilo", "subheader": "Subtítulo", "submit": "Submeter", + "succeeded": "Com sucesso", "summary": "Resumo", "survey": "Inquérito", "survey_completed": "Inquérito concluído.", @@ -506,8 +529,11 @@ "trial_expired": "O teu período de teste expirou", "trial_one_day_remaining": "1 dia restante no teu período de teste", "trial_plan_badge": "Teste {plan}", + "trigger": "Acionar", + "trigger_payload": "Payload de acionamento", "try_again": "Tente novamente", "type": "Tipo", + "unarchive": "Desarquivar", "undo": "Desfazer", "unlock_more_workspaces_with_a_higher_plan": "Desbloqueia mais espaços de trabalho com um plano superior.", "update": "Atualizar", @@ -527,6 +553,7 @@ "verified_email": "Email verificado", "video": "Vídeo", "view": "Ver", + "view_workflow": "Ver fluxo de trabalho", "warning": "Aviso", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não foi possível verificar a sua licença porque o servidor de licenças está inacessível.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "semanas", "welcome_card": "Cartão de boas-vindas", "whats_new": "Novidades", + "workflow_name": "Nome do Fluxo de Trabalho", + "workflow_runs": "Execuções de workflows", + "workflows": "Fluxos de trabalho", "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.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados", "response_data": "Dados de resposta", "response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅", - "response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅", "schedule_your_meeting": "Agende a sua reunião", "select_a_date": "Selecionar uma data", "survey_response_finished_email_congrats": "Parabéns, recebeu uma nova resposta ao seu inquérito! Alguém acabou de completar o seu inquérito: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Autenticação de dois fatores", "comparison_row_unify_feedback": "Unificar feedback de todas as fontes", "comparison_row_unlimited_seats": "Lugares ilimitados", + "comparison_row_workflows": "Fluxos de Trabalho", "comparison_row_workspaces": "Áreas de trabalho", "comparison_section_all_plans": "Todos os planos", "comparison_section_basic_usage": "Utilização principal", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos", "plan_scale_feature_security": "2FA e proteção contra spam", "plan_scale_feature_semantic_analysis": "Análise Semântica (IA)", + "plan_scale_feature_workflows": "Fluxos de Trabalho", "plan_scale_feature_workspaces": "5 áreas de trabalho", "plan_selection_description": "Compara Hobby, Pro e Scale, e depois muda de plano diretamente no Formbricks.", "plan_selection_title": "Escolhe o teu plano", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta", "follow_ups_modal_action_body_label": "Corpo", "follow_ups_modal_action_body_placeholder": "Corpo do email", + "follow_ups_modal_action_email_already_added": "Este e-mail já foi adicionado", "follow_ups_modal_action_email_content": "Conteúdo do email", + "follow_ups_modal_action_email_input_placeholder": "Escreve um e-mail e pressiona a barra de espaço", + "follow_ups_modal_action_email_invalid": "Por favor, introduz um endereço de e-mail válido", "follow_ups_modal_action_email_settings": "Configurações de email", "follow_ups_modal_action_from_description": "Endereço de email para enviar o email de", "follow_ups_modal_action_from_label": "De", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Respondente conclui inquérito", "follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.", "follow_ups_new": "Novo acompanhamento", + "follow_ups_workflows_alert_title": "Precisas de mais flexibilidade? Automatiza seguimentos e muito mais com Workflows.", "formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado", "four_points": "4 pontos", "heading": "Cabeçalho", @@ -4134,6 +4169,119 @@ "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, + "workflows": { + "add_action": "Adicionar ação", + "add_trigger": "Adicionar acionador", + "add_trigger_description": "Escolhe o que inicia este fluxo de trabalho.", + "all_changes_saved": "Todas as alterações guardadas", + "alphabetical": "Alfabética", + "archive_confirm_body": "Arquivar desativa este fluxo de trabalho e impede a sua execução. Podes desarquivá-lo mais tarde.", + "archive_confirm_title": "Arquivar fluxo de trabalho?", + "archive_failed": "Falha ao arquivar o fluxo de trabalho. Por favor, tenta novamente.", + "archive_success": "Fluxo de trabalho arquivado.", + "archive_workflow": "Arquivar fluxo de trabalho", + "archive_workflow_confirmation": "Tens a certeza de que queres arquivar \"{name}\"? Podes restaurá-lo mais tarde.", + "archive_workflow_description": "Arquivar oculta o fluxo de trabalho da lista. Podes restaurá-lo mais tarde.", + "auto_layout": "Disposição automática", + "autosave_failed": "Falha ao guardar", + "autosave_failed_tooltip": "Não foi possível guardar as tuas últimas alterações. Verifica a tua ligação e tenta novamente.", + "autosave_failed_tooltip_rejected": "Não foi possível guardar as tuas últimas alterações: {detail}", + "collapse_inspector": "Recolher inspetor", + "create_failed": "Falha ao criar o fluxo de trabalho. Por favor, tenta novamente.", + "delete_failed": "Falha ao eliminar o fluxo de trabalho. Por favor, tenta novamente.", + "delete_success": "Fluxo de trabalho eliminado.", + "delete_workflow_confirmation": "Isto elimina permanentemente \"{name}\" e o seu histórico de execuções.", + "disable_failed": "Não foi possível desativar o fluxo de trabalho.", + "disable_success": "Fluxo de trabalho desativado.", + "duplicate_failed": "Falha ao duplicar o fluxo de trabalho. Por favor, tenta novamente.", + "duplicate_success": "Fluxo de trabalho duplicado.", + "edit_blocked_active": "Desativa o fluxo de trabalho para fazer alterações aqui.", + "email_attach_response_data_description": "Incluir a resposta do inquérito que desencadeou a ação no corpo do e-mail.", + "email_attach_response_data_label": "Anexar dados da resposta", + "email_body_label": "Corpo", + "email_body_placeholder": "Escreve a mensagem que queres enviar…", + "email_body_required": "Adiciona a mensagem a enviar.", + "email_from_label": "De", + "email_include_hidden_fields_label": "Incluir campos ocultos", + "email_include_variables_label": "Incluir variáveis", + "email_needs_survey": "Liga primeiro um questionário no passo do acionador. As opções de destinatário e mensagem vêm das respostas do questionário.", + "email_reply_to_label": "Responder para", + "email_set_up_trigger": "Configurar acionador", + "email_subject_label": "Assunto", + "email_subject_placeholder": "Obrigado por completares o questionário", + "email_subject_required": "Adiciona uma linha de assunto.", + "email_to_label": "Enviar para", + "email_to_placeholder": "equipa@exemplo.com", + "email_to_required": "Escolhe quem deve receber este email.", + "enable_blocked_unsaved_changes": "Não foi possível guardar as tuas últimas alterações, por isso o workflow não foi ativado.", + "enable_failed": "Não foi possível ativar o fluxo de trabalho.", + "enable_success": "Fluxo de trabalho ativado.", + "expand_inspector": "Expandir inspetor", + "if_else": "Se / Senão", + "if_else_summary": "Ramifica o fluxo de trabalho com base numa condição.", + "inspector_unsupported_node": "Este tipo de nó ainda não tem um formulário de configuração.", + "load_failed": "Não foi possível carregar o fluxo de trabalho.", + "name_required": "Introduza um nome.", + "no_results_description": "Tenta ajustar a tua pesquisa ou filtros.", + "no_results_title": "Nenhum fluxo de trabalho encontrado", + "no_workflows_description": "Cria o teu primeiro fluxo de trabalho para automatizar ações quando chegarem respostas.", + "no_workflows_title": "Ainda sem fluxos de trabalho", + "node_actions": "Ações do nó", + "node_needs_email_content": "Definir destinatário e conteúdo", + "node_needs_survey": "Escolhe um questionário para começar", + "pan_mode": "Modo panorâmica", + "pointer_mode": "Modo ponteiro", + "read_only": "Apenas leitura", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {há # dia} other {há # dias}}, {time}", + "relative_today": "Hoje, {time}", + "relative_yesterday": "Ontem, {time}", + "response_completed": "Resposta concluída", + "response_completed_description": "Executa quando alguém completa uma resposta ao questionário.", + "save_failed": "Não foi possível guardar o fluxo de trabalho.", + "save_success": "Fluxo de trabalho guardado.", + "saving_changes": "A guardar…", + "search_by_workflow_name": "Pesquisar por nome do fluxo de trabalho", + "send_email": "Enviar e-mail", + "send_email_description": "Envia um email quando este fluxo de trabalho é executado.", + "send_email_summary": "Envia um e-mail para {to}.", + "send_email_unconfigured": "Configura o destinatário do e-mail.", + "trigger_ending_cards_label": "Cartões de finalização", + "trigger_ending_cards_none": "Este inquérito não tem finalizações configuradas.", + "trigger_ending_cards_pick_survey": "Escolhe um inquérito para ver as suas finalizações.", + "trigger_ending_cards_scope_all": "Todas as finalizações", + "trigger_ending_cards_scope_specific": "Finalizações específicas", + "trigger_ending_cards_select_at_least_one": "Seleciona pelo menos uma finalização. Sem nenhuma selecionada, todas as finalizações acionam este fluxo de trabalho.", + "trigger_summary_all_endings": "Acionar em qualquer resposta ao questionário.", + "trigger_summary_ending_cards": "Acionar em {count, plural, one {# cartão de finalização} other {# cartões de finalização}}.", + "trigger_survey_description": "Escolhe o inquérito cujas respostas completas acionam este fluxo de trabalho.", + "trigger_survey_empty": "Ainda não há inquéritos neste espaço de trabalho.", + "trigger_survey_label": "Inquérito", + "trigger_survey_placeholder": "Seleciona um inquérito", + "triggers": "Acionadores", + "unarchive": "Desarquivar", + "unarchive_failed": "Falha ao desarquivar o fluxo de trabalho. Por favor, tenta novamente.", + "unarchive_success": "Fluxo de trabalho desarquivado.", + "upgrade_prompt_description": "Automatiza tarefas orientadas por respostas com acionadores, filtros e ações.", + "upgrade_prompt_title": "Faz upgrade para desbloquear Fluxos de Trabalho", + "validation_failed": "A validação do workflow falhou.", + "validation_problem_fix_label": "Corrigir: {problem}", + "validation_problem_flow_invalid": "Os passos do fluxo de trabalho não estão ligados num fluxo executável único.", + "validation_problem_generic": "Esta parte do fluxo de trabalho tem um problema de configuração.", + "validation_problem_name_missing": "Dá um nome ao fluxo de trabalho.", + "validation_problem_step_incomplete": "Preenche o destinatário, assunto e corpo do passo de e-mail.", + "validation_problem_step_not_executable": "Este tipo de passo ainda não pode ser executado. Remove-o antes de ativar o fluxo de trabalho.", + "validation_problem_trigger_ending_not_found": "Um final selecionado já não existe no questionário associado.", + "validation_problem_trigger_missing": "Adiciona um acionador para iniciar o fluxo de trabalho.", + "validation_problem_trigger_not_connected": "Liga um passo após o acionador.", + "validation_problem_trigger_survey_unbound": "Liga o acionador a um inquérito neste espaço de trabalho.", + "validation_problems_count": "{count, plural, one {# problema} other {# problemas}}", + "validation_problems_description": "Resolve estes problemas antes de o fluxo de trabalho poder ser executado:", + "validation_problems_title": "Problemas de validação", + "validation_status_valid": "Válido", + "zoom_in": "Ampliar", + "zoom_out": "Reduzir" + }, "xm-templates": { "ces": "CES", "ces_description": "Customer Effort Score", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 699661659f9c..94e6d095c16f 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Acceptat", "account": "Cont", "account_settings": "Setări cont", + "act": "Acționează", "action": "Acțiune", "actions": "Acțiuni", "actions_description": "Acțiunile Cod și No-Code sunt utilizate pentru a declanșa chestionare de interceptare în aplicații și pe site-uri web.", @@ -185,6 +188,7 @@ "archive": "Arhivează", "archived": "Arhivat", "are_you_sure": "Ești sigur?", + "attempt": "Încercare", "attributes": "Atribute", "authorized_apps": "Authorized Apps", "back": "Înapoi", @@ -193,6 +197,7 @@ "bottom_left": "Stânga Jos", "bottom_right": "Dreapta Jos", "cancel": "Anulare", + "canceled": "Anulat", "centered_modal": "Modală centralizată", "chart": "Grafic", "charts": "Grafice", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(copie {copyNumber})", "e_commerce": "Comerț electronic", "edit": "Editare", + "editor": "Editor", "elements": "Elemente", "email": "Email", "enable": "Activează", + "enabled": "Activat", "ending_card": "Cardul de finalizare", "enter_url": "Introduceți URL-ul", "enterprise_license": "Licență Întreprindere", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.", "error_rate_limit_title": "Limită de cereri depășită", "expand_rows": "Extinde rândurile", + "failed": "Eșuat", "failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard", "failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor", "failed_to_load_workspaces": "Nu s-au putut încărca workspaces", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filtru", "finish": "Finalizează", + "finished_at": "Terminat la", "first_name": "Prenume", "formbricks_version": "Versiunea Formbricks", "full_name": "Nume complet", @@ -310,6 +319,7 @@ "imprint": "Amprentă", "in_progress": "În progres", "inactive_surveys": "Sondaje inactive", + "input": "Intrare", "integration": "integrare", "integrations": "Integrări", "invalid_date_with_value": "Dată invalidă: {value}", @@ -350,6 +360,7 @@ "move_up": "Mută sus", "name": "Nume", "new_version_available": "Formbricks {version} este disponibil. Actualizați acum!", + "new_workflow": "Flux de lucru nou", "next": "Următorul", "no": "Nu", "no_actions_found": "Nu au fost găsite acțiuni", @@ -388,10 +399,12 @@ "other": "Altele", "other_filters": "Alte Filtre", "other_placeholder": "Alt substituent", + "output": "Ieșire", "overlay_color": "Culoare overlay", "overview": "Prezentare generală", "password": "Parolă", "paused": "Pauză", + "pending": "În așteptare", "pending_downgrade": "Reducere în aşteptare", "people_manager": "Experiența Angajaților", "person": "Persoană", @@ -412,6 +425,7 @@ "question": "întrebare", "question_id": "ID întrebare", "questions": "Întrebări", + "queued": "În coadă", "quota": "Cotă", "quotas": "Cote", "quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.", @@ -424,15 +438,20 @@ "replace": "Înlocuiește", "report_survey": "Raportează chestionarul", "request_trial_license": "Solicitați o licență de încercare", + "required": "Obligatoriu", "reset_to_default": "Revino la implicit", "resize": "Redimensionați", "response": "Răspuns", + "response_completed": "Răspuns finalizat", "response_id": "ID răspuns", "responses": "Răspunsuri", "restart": "Repornește", "retry": "Reîncearcă", "role": "Rolul", "row_n": "Rândul {n}", + "run_data": "Date de execuție", + "running": "În execuție", + "runs": "Rulări", "saas": "SaaS", "sales": "Vânzări", "save": "Salvează", @@ -468,12 +487,16 @@ "something_went_wrong": "Ceva nu a mers bine", "something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.", "sort_by": "Sortare după", + "sort_by_value": "Sortare după: {label}", + "started_at": "Început la", "status": "Stare", + "steps": "Pași", "storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil", "string": "Text", "styling": "Stilizare", "subheader": "Subtitlu", "submit": "Trimite", + "succeeded": "Reușit", "summary": "Sumar", "survey": "Chestionar", "survey_completed": "Sondaj finalizat", @@ -506,8 +529,11 @@ "trial_expired": "Perioada ta de probă a expirat", "trial_one_day_remaining": "1 zi rămasă în perioada ta de probă", "trial_plan_badge": "Perioadă de probă {plan}", + "trigger": "Declanșator", + "trigger_payload": "Date de declanșare", "try_again": "Încearcă din nou", "type": "Tip", + "unarchive": "Dezarhivează", "undo": "Anulează", "unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.", "update": "Actualizare", @@ -527,6 +553,7 @@ "verified_email": "Email verificat", "video": "Video", "view": "Vezi", + "view_workflow": "Vezi fluxul de lucru", "warning": "Avertisment", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "săptămâni", "welcome_card": "Card de bun venit", "whats_new": "Ce este nou", + "workflow_name": "Nume flux de lucru", + "workflow_runs": "Rulări de workflow", + "workflows": "Fluxuri de lucru", "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.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor", "response_data": "Datele răspunsului", "response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅", - "response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅", "schedule_your_meeting": "Programați întâlnirea", "select_a_date": "Selectați o dată", "survey_response_finished_email_congrats": "Felicitări, aţi primit un răspuns nou la sondaj! Cineva tocmai a completat sondajul dumneavoastră: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Autentificare cu doi factori", "comparison_row_unify_feedback": "Unifică feedback-ul din toate sursele", "comparison_row_unlimited_seats": "Locuri nelimitate", + "comparison_row_workflows": "Fluxuri de lucru", "comparison_row_workspaces": "Spații de lucru", "comparison_section_all_plans": "Toate planurile", "comparison_section_basic_usage": "Utilizare de bază", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5.000 de răspunsuri / lună cu prețuri dinamice", "plan_scale_feature_security": "2FA și protecție împotriva spam-ului", "plan_scale_feature_semantic_analysis": "Analiză semantică (AI)", + "plan_scale_feature_workflows": "Fluxuri de lucru", "plan_scale_feature_workspaces": "5 spații de lucru", "plan_selection_description": "Compară Hobby, Pro și Scale, apoi schimbă planurile direct din Formbricks.", "plan_selection_title": "Alege-ți planul", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului", "follow_ups_modal_action_body_label": "Corp", "follow_ups_modal_action_body_placeholder": "Corpul emailului", + "follow_ups_modal_action_email_already_added": "Acest email a fost deja adăugat", "follow_ups_modal_action_email_content": "Conținut email", + "follow_ups_modal_action_email_input_placeholder": "Scrie un email și apasă bara de spațiu", + "follow_ups_modal_action_email_invalid": "Te rugăm să introduci o adresă de email validă", "follow_ups_modal_action_email_settings": "Setări email", "follow_ups_modal_action_from_description": "Adresă de email de la care se trimite emailul", "follow_ups_modal_action_from_label": "De la", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul", "follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.", "follow_ups_new": "Follow-up nou", + "follow_ups_workflows_alert_title": "Ai nevoie de mai multă flexibilitate? Automatizează urmăririle și multe altele cu Workflows.", "formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat", "four_points": "4 puncte", "heading": "Titlu", @@ -4134,6 +4169,119 @@ "value_number": "Valoare (număr)", "value_text": "Valoare (Text)" }, + "workflows": { + "add_action": "Adaugă acțiune", + "add_trigger": "Adaugă declanșator", + "add_trigger_description": "Alege ce pornește acest flux de lucru.", + "all_changes_saved": "Toate modificările au fost salvate", + "alphabetical": "Alfabetic", + "archive_confirm_body": "Arhivarea dezactivează acest flux de lucru și îl oprește din rulare. Îl poți dezarhiva mai târziu.", + "archive_confirm_title": "Arhivezi fluxul de lucru?", + "archive_failed": "Nu s-a putut arhiva workflow-ul. Te rugăm să încerci din nou.", + "archive_success": "Workflow arhivat.", + "archive_workflow": "Arhivează workflow-ul", + "archive_workflow_confirmation": "Ești sigur că vrei să arhivezi \"{name}\"? Îl poți restabili mai târziu.", + "archive_workflow_description": "Arhivarea ascunde workflow-ul din listă. Îl poți restabili mai târziu.", + "auto_layout": "Aranjare automată", + "autosave_failed": "Salvarea a eșuat", + "autosave_failed_tooltip": "Ultimele modificări nu au putut fi salvate. Verifică conexiunea și încearcă din nou.", + "autosave_failed_tooltip_rejected": "Ultimele tale modificări nu au putut fi salvate: {detail}", + "collapse_inspector": "Restrânge inspectorul", + "create_failed": "Nu s-a putut crea workflow-ul. Te rugăm să încerci din nou.", + "delete_failed": "Nu s-a putut șterge workflow-ul. Te rugăm să încerci din nou.", + "delete_success": "Workflow șters.", + "delete_workflow_confirmation": "Această acțiune șterge definitiv \"{name}\" și istoricul său de rulări.", + "disable_failed": "Workflow-ul nu a putut fi dezactivat.", + "disable_success": "Workflow dezactivat.", + "duplicate_failed": "Nu s-a putut duplica workflow-ul. Te rugăm să încerci din nou.", + "duplicate_success": "Workflow duplicat.", + "edit_blocked_active": "Dezactivează fluxul de lucru pentru a face modificări aici.", + "email_attach_response_data_description": "Include răspunsul la sondaj care a declanșat fluxul împreună cu payload-ul emailului.", + "email_attach_response_data_label": "Atașează datele răspunsului", + "email_body_label": "Conținut", + "email_body_placeholder": "Scrie mesajul pe care vrei să-l trimiți…", + "email_body_required": "Adaugă mesajul de trimis.", + "email_from_label": "De la", + "email_include_hidden_fields_label": "Includeți câmpurile ascunse", + "email_include_variables_label": "Includeți variabilele", + "email_needs_survey": "Conectează mai întâi un sondaj în pasul de declanșare. Opțiunile pentru destinatar și mesaj provin din răspunsurile sondajului.", + "email_reply_to_label": "Răspunde la", + "email_set_up_trigger": "Configurează declanșatorul", + "email_subject_label": "Subiect", + "email_subject_placeholder": "Mulțumim că ai completat sondajul", + "email_subject_required": "Adaugă un subiect.", + "email_to_label": "Trimite către", + "email_to_placeholder": "echipa@exemplu.com", + "email_to_required": "Alege cine ar trebui să primească acest email.", + "enable_blocked_unsaved_changes": "Ultimele tale modificări nu au putut fi salvate, așa că fluxul de lucru nu a fost activat.", + "enable_failed": "Nu am putut activa workflow-ul.", + "enable_success": "Workflow activat.", + "expand_inspector": "Extinde inspectorul", + "if_else": "Dacă / Altfel", + "if_else_summary": "Ramifică fluxul de lucru pe baza unei condiții.", + "inspector_unsupported_node": "Acest tip de nod nu are încă un formular de configurare.", + "load_failed": "Nu am putut încărca fluxul de lucru.", + "name_required": "Introduceți un nume.", + "no_results_description": "Încearcă să ajustezi căutarea sau filtrele.", + "no_results_title": "Nu s-au găsit fluxuri de lucru", + "no_workflows_description": "Creează primul tău workflow pentru a automatiza acțiuni atunci când primești răspunsuri.", + "no_workflows_title": "Niciun flux de lucru încă", + "node_actions": "Acțiuni nod", + "node_needs_email_content": "Setează destinatarul și conținutul", + "node_needs_survey": "Alege un sondaj pentru a începe", + "pan_mode": "Mod panoramare", + "pointer_mode": "Mod cursor", + "read_only": "Doar citire", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {Acum # zi} few {Acum # zile} other {Acum # de zile}}, {time}", + "relative_today": "Astăzi, {time}", + "relative_yesterday": "Ieri, {time}", + "response_completed": "Răspuns finalizat", + "response_completed_description": "Se execută când cineva completează un răspuns la sondaj.", + "save_failed": "Nu am putut salva fluxul de lucru.", + "save_success": "Flux de lucru salvat.", + "saving_changes": "Se salvează…", + "search_by_workflow_name": "Caută după numele workflow-ului", + "send_email": "Trimite email", + "send_email_description": "Trimite un email când acest flux de lucru se execută.", + "send_email_summary": "Trimite un email către {to}.", + "send_email_unconfigured": "Configurează destinatarul emailului.", + "trigger_ending_cards_label": "Ecrane de încheiere", + "trigger_ending_cards_none": "Acest chestionar nu are încheieri configurate.", + "trigger_ending_cards_pick_survey": "Alege un chestionar pentru a vedea încheierea acestuia.", + "trigger_ending_cards_scope_all": "Toate încheierea", + "trigger_ending_cards_scope_specific": "Încheieri specifice", + "trigger_ending_cards_select_at_least_one": "Selectează cel puțin o încheiere. Dacă nu selectezi niciuna, fiecare încheiere va declanșa acest flux de lucru.", + "trigger_summary_all_endings": "Declanșare la orice răspuns la sondaj.", + "trigger_summary_ending_cards": "Declanșare pe {count, plural, one {# card de încheiere} few {# carduri de încheiere} other {# de carduri de încheiere}}.", + "trigger_survey_description": "Alege chestionarul ale cărui răspunsuri completate declanșează acest flux de lucru.", + "trigger_survey_empty": "Încă nu există chestionare în acest spațiu de lucru.", + "trigger_survey_label": "Chestionar", + "trigger_survey_placeholder": "Selectează un chestionar", + "triggers": "Declanșatori", + "unarchive": "Dezarhivează", + "unarchive_failed": "Nu s-a putut dezarhiva workflow-ul. Te rugăm să încerci din nou.", + "unarchive_success": "Workflow dezarhivat.", + "upgrade_prompt_description": "Automatizează sarcinile bazate pe răspunsuri cu declanșatori, filtre și acțiuni.", + "upgrade_prompt_title": "Treci la un plan superior pentru a debloca Fluxurile de lucru", + "validation_failed": "Validarea fluxului de lucru a eșuat.", + "validation_problem_fix_label": "Remediază: {problem}", + "validation_problem_flow_invalid": "Pașii fluxului de lucru nu sunt conectați într-un flux executabil unic.", + "validation_problem_generic": "Această parte a fluxului de lucru are o problemă de configurare.", + "validation_problem_name_missing": "Dă un nume fluxului de lucru.", + "validation_problem_step_incomplete": "Completează destinatarul, subiectul și corpul mesajului pentru pasul de email.", + "validation_problem_step_not_executable": "Acest tip de pas nu poate fi executat încă. Elimină-l înainte de a activa fluxul de lucru.", + "validation_problem_trigger_ending_not_found": "Un final selectat nu mai există în sondajul conectat.", + "validation_problem_trigger_missing": "Adaugă un declanșator pentru a porni fluxul de lucru.", + "validation_problem_trigger_not_connected": "Conectează un pas după declanșator.", + "validation_problem_trigger_survey_unbound": "Conectează declanșatorul la un chestionar din acest spațiu de lucru.", + "validation_problems_count": "{count, plural, one {# problemă} few {# probleme} other {# de probleme}}", + "validation_problems_description": "Rezolvă aceste probleme înainte ca fluxul de lucru să poată rula:", + "validation_problems_title": "Probleme de validare", + "validation_status_valid": "Valid", + "zoom_in": "Mărește", + "zoom_out": "Micșorează" + }, "xm-templates": { "ces": "CES", "ces_description": "Scorul Efortului Clientului", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 8ca769e1dbb1..aec8499b8fae 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Принято", "account": "Аккаунт", "account_settings": "Настройки аккаунта", + "act": "Действовать", "action": "Действие", "actions": "Действия", "actions_description": "Действия с кодом и без кода используются для запуска опросов-перехватчиков в приложениях и на сайтах.", @@ -185,6 +188,7 @@ "archive": "Архивировать", "archived": "Архивный", "are_you_sure": "Вы уверены?", + "attempt": "Попытка", "attributes": "Атрибуты", "authorized_apps": "Authorized Apps", "back": "Назад", @@ -193,6 +197,7 @@ "bottom_left": "Внизу слева", "bottom_right": "Внизу справа", "cancel": "Отмена", + "canceled": "Отменено", "centered_modal": "Центрированное модальное окно", "chart": "График", "charts": "Графики", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(копия {copyNumber})", "e_commerce": "E-Commerce", "edit": "Редактировать", + "editor": "Редактор", "elements": "Элементы", "email": "Email", "enable": "Включить", + "enabled": "Включено", "ending_card": "Завершающая карточка", "enter_url": "Введите URL", "enterprise_license": "Корпоративная лицензия", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Достигнуто максимальное количество запросов. Пожалуйста, попробуйте позже.", "error_rate_limit_title": "Превышен лимит запросов", "expand_rows": "Развернуть строки", + "failed": "Не удалось", "failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена", "failed_to_load_organizations": "Не удалось загрузить организации", "failed_to_load_workspaces": "Не удалось загрузить рабочие пространства", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Фильтр", "finish": "Завершить", + "finished_at": "Завершено", "first_name": "Имя", "formbricks_version": "Версия Formbricks", "full_name": "Полное имя", @@ -310,6 +319,7 @@ "imprint": "Выходные данные", "in_progress": "В процессе", "inactive_surveys": "Неактивные опросы", + "input": "Входные данные", "integration": "интеграция", "integrations": "Интеграции", "invalid_date_with_value": "Неверная дата: {value}", @@ -350,6 +360,7 @@ "move_up": "Переместить вверх", "name": "Имя", "new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!", + "new_workflow": "Новый рабочий процесс", "next": "Далее", "no": "Нет", "no_actions_found": "Действия не найдены", @@ -388,10 +399,12 @@ "other": "Другое", "other_filters": "Другие фильтры", "other_placeholder": "Другой заполнитель", + "output": "Выходные данные", "overlay_color": "Цвет наложения", "overview": "Обзор", "password": "Пароль", "paused": "Приостановлено", + "pending": "Ожидание", "pending_downgrade": "Ожидает понижения тарифа", "people_manager": "Опыт сотрудников", "person": "Человек", @@ -412,6 +425,7 @@ "question": "вопрос", "question_id": "ID вопроса", "questions": "Вопросы", + "queued": "В очереди", "quota": "Квота", "quotas": "Квоты", "quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.", @@ -424,15 +438,20 @@ "replace": "Заменить", "report_survey": "Пожаловаться на опрос", "request_trial_license": "Запросить пробную лицензию", + "required": "Обязательно", "reset_to_default": "Сбросить по умолчанию", "resize": "Изменить размер", "response": "Ответ", + "response_completed": "Ответ получен", "response_id": "ID ответа", "responses": "Ответы", "restart": "Перезапустить", "retry": "Повторить", "role": "Роль", "row_n": "Строка {n}", + "run_data": "Данные выполнения", + "running": "Выполняется", + "runs": "Запуски", "saas": "SaaS", "sales": "Продажи", "save": "Сохранить", @@ -468,12 +487,16 @@ "something_went_wrong": "Что-то пошло не так", "something_went_wrong_please_try_again": "Что-то пошло не так. Пожалуйста, попробуйте ещё раз.", "sort_by": "Сортировать по", + "sort_by_value": "Сортировать по: {label}", + "started_at": "Начато", "status": "Статус", + "steps": "Шаги", "storage_not_configured": "Хранилище файлов не настроено, загрузка, скорее всего, не удастся", "string": "Текст", "styling": "Стилизация", "subheader": "Подзаголовок", "submit": "Отправить", + "succeeded": "Успешно", "summary": "Сводка", "survey": "Опрос", "survey_completed": "Опрос завершён.", @@ -506,8 +529,11 @@ "trial_expired": "Пробный период истёк", "trial_one_day_remaining": "Остался 1 день пробного периода", "trial_plan_badge": "Пробная версия {plan}", + "trigger": "Триггер", + "trigger_payload": "Полезная нагрузка триггера", "try_again": "Попробуйте ещё раз", "type": "Тип", + "unarchive": "Разархивировать", "undo": "Отменить", "unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.", "update": "Обновить", @@ -527,6 +553,7 @@ "verified_email": "Подтверждённый email", "video": "Видео", "view": "Просмотреть", + "view_workflow": "Посмотреть рабочий процесс", "warning": "Предупреждение", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "недели", "welcome_card": "Приветственная карточка", "whats_new": "Что нового", + "workflow_name": "Название рабочего процесса", + "workflow_runs": "Запуски рабочих процессов", + "workflows": "Рабочие процессы", "workspace": "Рабочее пространство", "workspace_created_successfully": "Рабочее пространство успешно создано", "workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Ссылка на загруженный файл не включена по соображениям конфиденциальности данных", "response_data": "Данные ответа", "response_finished_email_subject": "Ответ на {surveyName} был получен ✅", - "response_finished_email_subject_with_email": "{personEmail} только что завершил(а) ваш опрос {surveyName} ✅", "schedule_your_meeting": "Запланируйте встречу", "select_a_date": "Выберите дату", "survey_response_finished_email_congrats": "Поздравляем, вы получили новый ответ на свой опрос! Кто-то только что завершил ваш опрос: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Двухфакторная аутентификация", "comparison_row_unify_feedback": "Объединение обратной связи из всех источников", "comparison_row_unlimited_seats": "Неограниченное количество мест", + "comparison_row_workflows": "Рабочие процессы", "comparison_row_workspaces": "Рабочие пространства", "comparison_section_all_plans": "Все планы", "comparison_section_basic_usage": "Основное использование", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5 000 ответов в месяц с динамическим ценообразованием", "plan_scale_feature_security": "Двухфакторная аутентификация и защита от спама", "plan_scale_feature_semantic_analysis": "Семантический анализ (AI)", + "plan_scale_feature_workflows": "Рабочие процессы", "plan_scale_feature_workspaces": "5 рабочих пространств", "plan_selection_description": "Сравни планы Hobby, Pro и Scale, а затем переключайся между ними прямо в Formbricks.", "plan_selection_title": "Выбери свой план", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Прикрепить данные ответа", "follow_ups_modal_action_body_label": "Текст письма", "follow_ups_modal_action_body_placeholder": "Текст письма", + "follow_ups_modal_action_email_already_added": "Этот email уже добавлен", "follow_ups_modal_action_email_content": "Содержимое письма", + "follow_ups_modal_action_email_input_placeholder": "Введите email и нажмите пробел", + "follow_ups_modal_action_email_invalid": "Пожалуйста, введите корректный email-адрес", "follow_ups_modal_action_email_settings": "Настройки email", "follow_ups_modal_action_from_description": "Адрес электронной почты, с которого будет отправлено письмо", "follow_ups_modal_action_from_label": "От кого", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Респондент завершает опрос", "follow_ups_modal_updated_successfull_toast": "Фоллоу-ап обновлён и будет сохранён после сохранения опроса.", "follow_ups_new": "Новый фоллоу-ап", + "follow_ups_workflows_alert_title": "Нужно больше гибкости? Автоматизируйте напоминания и многое другое с помощью Workflows.", "formbricks_sdk_is_not_connected": "Formbricks SDK не подключён", "four_points": "4 балла", "heading": "Заголовок", @@ -4134,6 +4169,119 @@ "value_number": "Значение (число)", "value_text": "Значение (текст)" }, + "workflows": { + "add_action": "Добавить действие", + "add_trigger": "Добавить триггер", + "add_trigger_description": "Выберите, что запускает этот рабочий процесс.", + "all_changes_saved": "Все изменения сохранены", + "alphabetical": "По алфавиту", + "archive_confirm_body": "Архивирование отключает этот рабочий процесс и останавливает его выполнение. Вы сможете восстановить его позже.", + "archive_confirm_title": "Архивировать рабочий процесс?", + "archive_failed": "Не удалось архивировать воркфлоу. Попробуй ещё раз.", + "archive_success": "Воркфлоу архивирован.", + "archive_workflow": "Архивировать воркфлоу", + "archive_workflow_confirmation": "Точно хочешь архивировать «{name}»? Ты сможешь восстановить его позже.", + "archive_workflow_description": "Архивирование скрывает воркфлоу из списка. Ты сможешь восстановить его позже.", + "auto_layout": "Автораскладка", + "autosave_failed": "Не удалось сохранить", + "autosave_failed_tooltip": "Не удалось сохранить последние изменения. Проверьте подключение к интернету и попробуйте снова.", + "autosave_failed_tooltip_rejected": "Последние изменения не удалось сохранить: {detail}", + "collapse_inspector": "Свернуть инспектор", + "create_failed": "Не удалось создать воркфлоу. Попробуй ещё раз.", + "delete_failed": "Не удалось удалить воркфлоу. Попробуй ещё раз.", + "delete_success": "Воркфлоу удалён.", + "delete_workflow_confirmation": "Это навсегда удалит «{name}» и историю его запусков.", + "disable_failed": "Не удалось отключить рабочий процесс.", + "disable_success": "Рабочий процесс отключен.", + "duplicate_failed": "Не удалось дублировать воркфлоу. Попробуй ещё раз.", + "duplicate_success": "Воркфлоу дублирован.", + "edit_blocked_active": "Отключите процесс, чтобы внести изменения.", + "email_attach_response_data_description": "Включить в письмо данные ответа на опрос, который запустил рабочий процесс.", + "email_attach_response_data_label": "Прикрепить данные ответа", + "email_body_label": "Текст письма", + "email_body_placeholder": "Напишите сообщение, которое хотите отправить…", + "email_body_required": "Добавьте текст сообщения.", + "email_from_label": "От кого", + "email_include_hidden_fields_label": "Включить скрытые поля", + "email_include_variables_label": "Включить переменные", + "email_needs_survey": "Сначала подключи опрос на этапе триггера. Параметры получателя и сообщения берутся из ответов опроса.", + "email_reply_to_label": "Ответить на", + "email_set_up_trigger": "Настроить триггер", + "email_subject_label": "Тема", + "email_subject_placeholder": "Спасибо за прохождение опроса", + "email_subject_required": "Добавьте тему письма.", + "email_to_label": "Отправить на", + "email_to_placeholder": "team@example.com", + "email_to_required": "Укажите, кто должен получить это письмо.", + "enable_blocked_unsaved_changes": "Последние изменения не удалось сохранить, поэтому процесс не был включён.", + "enable_failed": "Не удалось включить рабочий процесс.", + "enable_success": "Рабочий процесс включен.", + "expand_inspector": "Развернуть инспектор", + "if_else": "Если / Иначе", + "if_else_summary": "Разветвить workflow на основе условия.", + "inspector_unsupported_node": "Для этого типа узла пока нет формы настройки.", + "load_failed": "Не удалось загрузить workflow.", + "name_required": "Введите название.", + "no_results_description": "Попробуйте изменить поисковый запрос или фильтры.", + "no_results_title": "Рабочие процессы не найдены", + "no_workflows_description": "Создай свой первый воркфлоу, чтобы автоматизировать действия при получении ответов.", + "no_workflows_title": "Пока нет рабочих процессов", + "node_actions": "Действия узла", + "node_needs_email_content": "Укажите получателя и содержимое", + "node_needs_survey": "Выбери опрос, чтобы начать", + "pan_mode": "Режим панорамирования", + "pointer_mode": "Режим курсора", + "read_only": "Только для чтения", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {# день назад} few {# дня назад} many {# дней назад} other {# дней назад}}, {time}", + "relative_today": "Сегодня, {time}", + "relative_yesterday": "Вчера, {time}", + "response_completed": "Ответ завершён", + "response_completed_description": "Запускается, когда кто-то заполняет опрос.", + "save_failed": "Не удалось сохранить workflow.", + "save_success": "Workflow сохранён.", + "saving_changes": "Сохранение…", + "search_by_workflow_name": "Поиск по названию воркфлоу", + "send_email": "Отправить email", + "send_email_description": "Отправить письмо при запуске этого рабочего процесса.", + "send_email_summary": "Отправить email на {to}.", + "send_email_unconfigured": "Настрой получателя email.", + "trigger_ending_cards_label": "Завершающие экраны", + "trigger_ending_cards_none": "В этом опросе не настроено ни одного завершающего экрана.", + "trigger_ending_cards_pick_survey": "Выберите опрос, чтобы увидеть его завершающие экраны.", + "trigger_ending_cards_scope_all": "Все завершающие экраны", + "trigger_ending_cards_scope_specific": "Определённые завершающие экраны", + "trigger_ending_cards_select_at_least_one": "Выберите хотя бы один завершающий экран. Если ничего не выбрано, каждый завершающий экран запускает этот рабочий процесс.", + "trigger_summary_all_endings": "Запускать при любом ответе на опрос.", + "trigger_summary_ending_cards": "Запускать при {count, plural, one {# финальной карточке} few {# финальных карточках} many {# финальных карточках} other {# финальных карточках}}.", + "trigger_survey_description": "Выберите опрос, завершённые ответы которого запускают этот рабочий процесс.", + "trigger_survey_empty": "В этом рабочем пространстве пока нет опросов.", + "trigger_survey_label": "Опрос", + "trigger_survey_placeholder": "Выберите опрос", + "triggers": "Триггеры", + "unarchive": "Восстановить из архива", + "unarchive_failed": "Не удалось восстановить воркфлоу из архива. Попробуй ещё раз.", + "unarchive_success": "Воркфлоу восстановлен из архива.", + "upgrade_prompt_description": "Автоматизируйте задачи на основе ответов с помощью триггеров, фильтров и действий.", + "upgrade_prompt_title": "Обновите план, чтобы получить доступ к рабочим процессам", + "validation_failed": "Проверка рабочего процесса не пройдена.", + "validation_problem_fix_label": "Исправить: {problem}", + "validation_problem_flow_invalid": "Шаги рабочего процесса не связаны в единый выполняемый поток.", + "validation_problem_generic": "Эта часть рабочего процесса имеет проблему конфигурации.", + "validation_problem_name_missing": "Дай рабочему процессу название.", + "validation_problem_step_incomplete": "Заполни получателя, тему и текст для шага отправки email.", + "validation_problem_step_not_executable": "Этот тип шага пока не может выполняться. Удали его перед включением рабочего процесса.", + "validation_problem_trigger_ending_not_found": "Выбранное окончание больше не существует в подключенном опросе.", + "validation_problem_trigger_missing": "Добавь триггер для запуска рабочего процесса.", + "validation_problem_trigger_not_connected": "Подключи шаг после триггера.", + "validation_problem_trigger_survey_unbound": "Привяжи триггер к опросу в этом рабочем пространстве.", + "validation_problems_count": "{count, plural, one {# проблема} few {# проблемы} many {# проблем} other {# проблем}}", + "validation_problems_description": "Исправь эти проблемы, чтобы рабочий процесс мог запуститься:", + "validation_problems_title": "Проблемы валидации", + "validation_status_valid": "Валидный", + "zoom_in": "Увеличить", + "zoom_out": "Уменьшить" + }, "xm-templates": { "ces": "CES", "ces_description": "Индекс усилий клиента", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 9caa707cb15c..91b7107769c5 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Accepterad", "account": "Konto", "account_settings": "Kontoinställningar", + "act": "Agera", "action": "Åtgärd", "actions": "Åtgärder", "actions_description": "Kod- och No-Code-åtgärder används för att utlösa enkäter i appar och på webbplatser.", @@ -185,6 +188,7 @@ "archive": "Arkivera", "archived": "Arkiverad", "are_you_sure": "Är du säker?", + "attempt": "Försök", "attributes": "Attribut", "authorized_apps": "Authorized Apps", "back": "Tillbaka", @@ -193,6 +197,7 @@ "bottom_left": "Nedre vänster", "bottom_right": "Nedre höger", "cancel": "Avbryt", + "canceled": "Avbruten", "centered_modal": "Centrerad modal", "chart": "Diagram", "charts": "Diagram", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(kopia {copyNumber})", "e_commerce": "E-handel", "edit": "Redigera", + "editor": "Redigerare", "elements": "Element", "email": "E-post", "enable": "Aktivera", + "enabled": "Aktiverad", "ending_card": "Avslutningskort", "enter_url": "Ange URL", "enterprise_license": "Företagslicens", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Maximalt antal förfrågningar har nåtts. Försök igen senare.", "error_rate_limit_title": "Begränsningsgräns överskriden", "expand_rows": "Visa rader", + "failed": "Misslyckades", "failed_to_copy_to_clipboard": "Misslyckades att kopiera till urklipp", "failed_to_load_organizations": "Misslyckades att ladda organisationer", "failed_to_load_workspaces": "Det gick inte att ladda arbetsytor", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filter", "finish": "Slutför", + "finished_at": "Avslutad", "first_name": "Förnamn", "formbricks_version": "Formbricks-version", "full_name": "Fullständigt namn", @@ -310,6 +319,7 @@ "imprint": "Impressum", "in_progress": "Pågående", "inactive_surveys": "Inaktiva enkäter", + "input": "Indata", "integration": "integration", "integrations": "Integrationer", "invalid_date_with_value": "Ogiltigt datum: {value}", @@ -350,6 +360,7 @@ "move_up": "Flytta upp", "name": "Namn", "new_version_available": "Formbricks {version} är här. Uppgradera nu!", + "new_workflow": "Nytt arbetsflöde", "next": "Nästa", "no": "Nej", "no_actions_found": "Inga åtgärder hittades", @@ -388,10 +399,12 @@ "other": "Annat", "other_filters": "Andra filter", "other_placeholder": "Annan platshållare", + "output": "Utdata", "overlay_color": "Overlay-färg", "overview": "Översikt", "password": "Lösenord", "paused": "Pausad", + "pending": "Väntande", "pending_downgrade": "Väntande nedgradering", "people_manager": "Medarbetarupplevelse", "person": "Person", @@ -412,6 +425,7 @@ "question": "fråga", "question_id": "Fråge-ID", "questions": "Frågor", + "queued": "I kö", "quota": "Kvot", "quotas": "Kvoter", "quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.", @@ -424,15 +438,20 @@ "replace": "Ersätt", "report_survey": "Rapportera enkät", "request_trial_license": "Begär provlicens", + "required": "Obligatorisk", "reset_to_default": "Återställ till standard", "resize": "Ändra storlek", "response": "Svar", + "response_completed": "Svar slutfört", "response_id": "Svar-ID", "responses": "Svar", "restart": "Starta om", "retry": "Försök igen", "role": "Roll", "row_n": "Rad {n}", + "run_data": "Körningsdata", + "running": "Körs", + "runs": "Körningar", "saas": "SaaS", "sales": "Försäljning", "save": "Spara", @@ -468,12 +487,16 @@ "something_went_wrong": "Något gick fel", "something_went_wrong_please_try_again": "Något gick fel. Försök igen.", "sort_by": "Sortera efter", + "sort_by_value": "Sortera efter: {label}", + "started_at": "Startad", "status": "Status", + "steps": "Steg", "storage_not_configured": "Fillagring är inte konfigurerad, uppladdningar kommer sannolikt att misslyckas", "string": "Text", "styling": "Styling", "subheader": "Underrubrik", "submit": "Skicka", + "succeeded": "Lyckades", "summary": "Sammanfattning", "survey": "Enkät", "survey_completed": "Enkät slutförd.", @@ -506,8 +529,11 @@ "trial_expired": "Din provperiod har gått ut", "trial_one_day_remaining": "1 dag kvar av din provperiod", "trial_plan_badge": "{plan} provperiod", + "trigger": "Utlösare", + "trigger_payload": "Utlösningslast", "try_again": "Försök igen", "type": "Typ", + "unarchive": "Återställ från arkiv", "undo": "Ångra", "unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.", "update": "Uppdatera", @@ -527,6 +553,7 @@ "verified_email": "Verifierad e-post", "video": "Video", "view": "Visa", + "view_workflow": "Visa arbetsflöde", "warning": "Varning", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Vi kunde inte verifiera din licens eftersom licensservern inte kan nås.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "veckor", "welcome_card": "Välkomstkort", "whats_new": "Nyheter", + "workflow_name": "Arbetsflödesnamn", + "workflow_runs": "Arbetsflödeskörningar", + "workflows": "Arbetsflöden", "workspace": "Arbetsyta", "workspace_created_successfully": "Arbetsytan har skapats", "workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Länk till uppladdad fil ingår inte av dataskyddsskäl", "response_data": "Svarsdata", "response_finished_email_subject": "Ett svar för {surveyName} har slutförts ✅", - "response_finished_email_subject_with_email": "{personEmail} har precis slutfört din {surveyName}-enkät ✅", "schedule_your_meeting": "Boka ditt möte", "select_a_date": "Välj ett datum", "survey_response_finished_email_congrats": "Grattis, du har fått ett nytt svar på din enkät! Någon har precis slutfört din enkät: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "Tvåfaktorsautentisering", "comparison_row_unify_feedback": "Samla feedback från alla källor", "comparison_row_unlimited_seats": "Obegränsat antal platser", + "comparison_row_workflows": "Arbetsflöden", "comparison_row_workspaces": "Arbetsytor", "comparison_section_all_plans": "Alla planer", "comparison_section_basic_usage": "Grundläggande användning", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "5 000 svar / månad med dynamisk prissättning", "plan_scale_feature_security": "2FA och skräppostskydd", "plan_scale_feature_semantic_analysis": "Semantisk analys (AI)", + "plan_scale_feature_workflows": "Arbetsflöden", "plan_scale_feature_workspaces": "5 arbetsytor", "plan_selection_description": "Jämför Hobby, Pro och Scale och byt sedan plan direkt från Formbricks.", "plan_selection_title": "Välj din plan", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Bifoga svarsdata", "follow_ups_modal_action_body_label": "Brödtext", "follow_ups_modal_action_body_placeholder": "E-postmeddelandets brödtext", + "follow_ups_modal_action_email_already_added": "Den här e-postadressen har redan lagts till", "follow_ups_modal_action_email_content": "E-postinnehåll", + "follow_ups_modal_action_email_input_placeholder": "Skriv en e-postadress och tryck på mellanslag", + "follow_ups_modal_action_email_invalid": "Ange en giltig e-postadress", "follow_ups_modal_action_email_settings": "E-postinställningar", "follow_ups_modal_action_from_description": "E-postadress att skicka e-post från", "follow_ups_modal_action_from_label": "Från", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Respondenten slutför enkäten", "follow_ups_modal_updated_successfull_toast": "Uppföljning uppdaterad och sparas när du sparar enkäten.", "follow_ups_new": "Ny uppföljning", + "follow_ups_workflows_alert_title": "Behöver du mer flexibilitet? Automatisera uppföljningar och mycket mer med Workflows.", "formbricks_sdk_is_not_connected": "Formbricks SDK är inte anslutet", "four_points": "4 poäng", "heading": "Rubrik", @@ -4134,6 +4169,119 @@ "value_number": "Värde (antal)", "value_text": "Värde (text)" }, + "workflows": { + "add_action": "Lägg till åtgärd", + "add_trigger": "Lägg till utlösare", + "add_trigger_description": "Välj vad som startar det här arbetsflödet.", + "all_changes_saved": "Alla ändringar sparade", + "alphabetical": "Alfabetisk", + "archive_confirm_body": "Arkivering inaktiverar det här arbetsflödet och stoppar det från att köras. Du kan återställa det senare.", + "archive_confirm_title": "Arkivera arbetsflöde?", + "archive_failed": "Det gick inte att arkivera arbetsflödet. Försök igen.", + "archive_success": "Arbetsflödet har arkiverats.", + "archive_workflow": "Arkivera arbetsflöde", + "archive_workflow_confirmation": "Är du säker på att du vill arkivera \"{name}\"? Du kan återställa det senare.", + "archive_workflow_description": "Arkivering döljer arbetsflödet från listan. Du kan återställa det senare.", + "auto_layout": "Automatisk layout", + "autosave_failed": "Sparande misslyckades", + "autosave_failed_tooltip": "Dina senaste ändringar kunde inte sparas. Kontrollera din anslutning och försök igen.", + "autosave_failed_tooltip_rejected": "Dina senaste ändringar kunde inte sparas: {detail}", + "collapse_inspector": "Dölj inspektör", + "create_failed": "Det gick inte att skapa arbetsflödet. Försök igen.", + "delete_failed": "Det gick inte att ta bort arbetsflödet. Försök igen.", + "delete_success": "Arbetsflödet har tagits bort.", + "delete_workflow_confirmation": "Detta tar permanent bort \"{name}\" och dess körningshistorik.", + "disable_failed": "Kunde inte inaktivera arbetsflödet.", + "disable_success": "Arbetsflödet inaktiverat.", + "duplicate_failed": "Det gick inte att duplicera arbetsflödet. Försök igen.", + "duplicate_success": "Arbetsflödet har duplicerats.", + "edit_blocked_active": "Inaktivera arbetsflödet för att göra ändringar här.", + "email_attach_response_data_description": "Inkludera det utlösande enkätsvaret tillsammans med e-postinnehållet.", + "email_attach_response_data_label": "Bifoga svarsdata", + "email_body_label": "Meddelande", + "email_body_placeholder": "Skriv meddelandet du vill skicka…", + "email_body_required": "Lägg till meddelandet som ska skickas.", + "email_from_label": "Från", + "email_include_hidden_fields_label": "Inkludera dolda fält", + "email_include_variables_label": "Inkludera variabler", + "email_needs_survey": "Koppla en enkät i utlösarsteget först. Mottagare och meddelandealternativ kommer från enkätens svar.", + "email_reply_to_label": "Svara till", + "email_set_up_trigger": "Konfigurera utlösare", + "email_subject_label": "Ämne", + "email_subject_placeholder": "Tack för att du slutförde enkäten", + "email_subject_required": "Lägg till en ämnesrad.", + "email_to_label": "Skicka till", + "email_to_placeholder": "team@example.com", + "email_to_required": "Välj vem som ska ta emot det här mejlet.", + "enable_blocked_unsaved_changes": "Dina senaste ändringar kunde inte sparas, så arbetsflödet aktiverades inte.", + "enable_failed": "Kunde inte aktivera arbetsflödet.", + "enable_success": "Arbetsflöde aktiverat.", + "expand_inspector": "Visa inspektör", + "if_else": "Om / Annars", + "if_else_summary": "Förgrena arbetsflödet baserat på ett villkor.", + "inspector_unsupported_node": "Den här nodtypen har inte något konfigurationsformulär än.", + "load_failed": "Kunde inte ladda arbetsflödet.", + "name_required": "Ange ett namn.", + "no_results_description": "Prova att justera din sökning eller dina filter.", + "no_results_title": "Inga arbetsflöden hittades", + "no_workflows_description": "Skapa ditt första arbetsflöde för att automatisera åtgärder när svar kommer in.", + "no_workflows_title": "Inga arbetsflöden än", + "node_actions": "Nodåtgärder", + "node_needs_email_content": "Ange mottagare och innehåll", + "node_needs_survey": "Välj en enkät för att komma igång", + "pan_mode": "Panoreringsläge", + "pointer_mode": "Pekarläge", + "read_only": "Skrivskyddad", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {# dag sedan} other {# dagar sedan}}, {time}", + "relative_today": "Idag, {time}", + "relative_yesterday": "Igår, {time}", + "response_completed": "Svar slutfört", + "response_completed_description": "Körs när någon slutför ett enkätsvar.", + "save_failed": "Kunde inte spara arbetsflödet.", + "save_success": "Arbetsflödet sparat.", + "saving_changes": "Sparar…", + "search_by_workflow_name": "Sök efter arbetsflödets namn", + "send_email": "Skicka e-post", + "send_email_description": "Skicka ett e-postmeddelande när det här arbetsflödet körs.", + "send_email_summary": "Skicka ett e-postmeddelande till {to}.", + "send_email_unconfigured": "Konfigurera e-postmottagaren.", + "trigger_ending_cards_label": "Avslutningskort", + "trigger_ending_cards_none": "Denna undersökning har inga avslutningar konfigurerade.", + "trigger_ending_cards_pick_survey": "Välj en undersökning för att se dess avslutningar.", + "trigger_ending_cards_scope_all": "Alla avslutningar", + "trigger_ending_cards_scope_specific": "Specifika avslutningar", + "trigger_ending_cards_select_at_least_one": "Välj minst en avslutning. Om ingen är vald utlöser varje avslutning detta arbetsflöde.", + "trigger_summary_all_endings": "Utlös vid alla enkätsvar.", + "trigger_summary_ending_cards": "Utlös vid {count, plural, one {# avslutningskort} other {# avslutningskort}}.", + "trigger_survey_description": "Välj den undersökning vars slutförda svar utlöser detta arbetsflöde.", + "trigger_survey_empty": "Inga undersökningar i den här arbetsytan ännu.", + "trigger_survey_label": "Undersökning", + "trigger_survey_placeholder": "Välj en undersökning", + "triggers": "Utlösare", + "unarchive": "Återställ", + "unarchive_failed": "Det gick inte att återställa arbetsflödet. Försök igen.", + "unarchive_success": "Arbetsflödet har återställts.", + "upgrade_prompt_description": "Automatisera svardrivna uppgifter med triggers, filter och åtgärder.", + "upgrade_prompt_title": "Uppgradera för att låsa upp Arbetsflöden", + "validation_failed": "Validering av arbetsflöde misslyckades.", + "validation_problem_fix_label": "Åtgärda: {problem}", + "validation_problem_flow_invalid": "Arbetsflödesstegen är inte sammankopplade till ett körbart flöde.", + "validation_problem_generic": "Den här delen av arbetsflödet har ett konfigurationsproblem.", + "validation_problem_name_missing": "Ge arbetsflödet ett namn.", + "validation_problem_step_incomplete": "Fyll i e-poststegets mottagare, ämne och brödtext.", + "validation_problem_step_not_executable": "Den här steptypen kan inte köras än. Ta bort den innan du aktiverar arbetsflödet.", + "validation_problem_trigger_ending_not_found": "Ett valt avslut finns inte längre i den anslutna enkäten.", + "validation_problem_trigger_missing": "Lägg till en utlösare för att starta arbetsflödet.", + "validation_problem_trigger_not_connected": "Koppla ett steg efter utlösaren.", + "validation_problem_trigger_survey_unbound": "Koppla utlösaren till en enkät i den här arbetsytan.", + "validation_problems_count": "{count, plural, one {# problem} other {# problem}}", + "validation_problems_description": "Åtgärda dessa problem innan arbetsflödet kan köras:", + "validation_problems_title": "Valideringsproblem", + "validation_status_valid": "Giltig", + "zoom_in": "Zooma in", + "zoom_out": "Zooma ut" + }, "xm-templates": { "ces": "CES", "ces_description": "Customer Effort Score", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index cf81add80693..db6cc49bfd8a 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "Kabul Edildi", "account": "Hesap", "account_settings": "Hesap ayarları", + "act": "Hareket Et", "action": "Eylem", "actions": "Eylemler", "actions_description": "Kod ve Kodsuz Eylemler, uygulamalarda ve web sitelerinde survey'leri tetiklemek için kullanılır.", @@ -185,6 +188,7 @@ "archive": "Arşivle", "archived": "Arşivlenmiş", "are_you_sure": "Emin misiniz?", + "attempt": "Deneme", "attributes": "Öznitelikler", "authorized_apps": "Authorized Apps", "back": "Geri", @@ -193,6 +197,7 @@ "bottom_left": "Sol Alt", "bottom_right": "Sağ Alt", "cancel": "İptal", + "canceled": "İptal Edildi", "centered_modal": "Ortalanmış Modal", "chart": "Grafik", "charts": "Grafikler", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(kopya {copyNumber})", "e_commerce": "E-Ticaret", "edit": "Düzenle", + "editor": "Editör", "elements": "Elemanlar", "email": "E-posta", "enable": "Etkinleştir", + "enabled": "Etkin", "ending_card": "Bitiş kartı", "enter_url": "URL girin", "enterprise_license": "Kurumsal Lisans", @@ -278,6 +285,7 @@ "error_rate_limit_description": "Maksimum istek sayısına ulaşıldı. Lütfen daha sonra tekrar deneyin.", "error_rate_limit_title": "İstek Sınırı Aşıldı", "expand_rows": "Satırları genişlet", + "failed": "Başarısız", "failed_to_copy_to_clipboard": "Panoya kopyalama başarısız oldu", "failed_to_load_organizations": "Organizasyonlar yüklenemedi", "failed_to_load_workspaces": "Çalışma alanları yüklenemedi", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "Filtre", "finish": "Bitir", + "finished_at": "Tamamlanma Zamanı", "first_name": "Ad", "formbricks_version": "Formbricks Sürümü", "full_name": "Tam ad", @@ -310,6 +319,7 @@ "imprint": "Künye", "in_progress": "Devam Ediyor", "inactive_surveys": "Pasif anketler", + "input": "Girdi", "integration": "entegrasyon", "integrations": "Entegrasyonlar", "invalid_date_with_value": "Geçersiz tarih: {value}", @@ -350,6 +360,7 @@ "move_up": "Yukarı taşı", "name": "Ad", "new_version_available": "Formbricks {version} burada. Şimdi yükseltin!", + "new_workflow": "Yeni iş akışı", "next": "Sonraki", "no": "Hayır", "no_actions_found": "Eylem bulunamadı", @@ -388,10 +399,12 @@ "other": "Diğer", "other_filters": "Diğer Filtreler", "other_placeholder": "Diğer Yer Tutucu", + "output": "Çıktı", "overlay_color": "Kaplama rengi", "overview": "Genel Bakış", "password": "Şifre", "paused": "Duraklatıldı", + "pending": "Beklemede", "pending_downgrade": "Bekleyen Düşürme", "people_manager": "Çalışan Deneyimi", "person": "Kişi", @@ -412,6 +425,7 @@ "question": "soru", "question_id": "Soru ID", "questions": "Sorular", + "queued": "Sırada", "quota": "Kota", "quotas": "Kotalar", "quotas_description": "Belirli kriterleri karşılayan katılımcılardan aldığınız yanıt miktarını sınırlayın.", @@ -424,15 +438,20 @@ "replace": "Değiştir", "report_survey": "Anketi Raporla", "request_trial_license": "Deneme lisansı iste", + "required": "Gerekli", "reset_to_default": "Varsayılana sıfırla", "resize": "Yeniden boyutlandır", "response": "Yanıt", + "response_completed": "Yanıt tamamlandı", "response_id": "Yanıt ID", "responses": "Yanıtlar", "restart": "Yeniden başlat", "retry": "Yeniden dene", "role": "Rol", "row_n": "Satır {n}", + "run_data": "Çalıştırma verisi", + "running": "Çalışıyor", + "runs": "Çalıştırmalar", "saas": "SaaS", "sales": "Satış", "save": "Kaydet", @@ -468,12 +487,16 @@ "something_went_wrong": "Bir şeyler ters gitti", "something_went_wrong_please_try_again": "Bir sorun oluştu. Lütfen tekrar deneyin.", "sort_by": "Sıralama", + "sort_by_value": "Sıralama: {label}", + "started_at": "Başlangıç Zamanı", "status": "Durum", + "steps": "Adımlar", "storage_not_configured": "Dosya depolama yapılandırılmadı, yüklemeler muhtemelen başarısız olacak", "string": "Metin", "styling": "Stil", "subheader": "Alt Başlık", "submit": "Gönder", + "succeeded": "Başarılı", "summary": "Özet", "survey": "Anket", "survey_completed": "Anket tamamlandı.", @@ -506,8 +529,11 @@ "trial_expired": "Deneme süreniz doldu", "trial_one_day_remaining": "Deneme sürenizde 1 gün kaldı", "trial_plan_badge": "{plan} Deneme", + "trigger": "Tetikleyici", + "trigger_payload": "Tetikleyici yükü", "try_again": "Tekrar dene", "type": "Tür", + "unarchive": "Arşivden Çıkar", "undo": "Geri Al", "unlock_more_workspaces_with_a_higher_plan": "Daha yüksek bir planla daha fazla çalışma alanının kilidini açın.", "update": "Güncelle", @@ -527,6 +553,7 @@ "verified_email": "Doğrulanmış E-posta", "video": "Video", "view": "Görüntüle", + "view_workflow": "İş akışını görüntüle", "warning": "Uyarı", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Lisans sunucusuna erişilemediği için lisansınızı doğrulayamadık.", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "hafta", "welcome_card": "Karşılama kartı", "whats_new": "Yenilikler", + "workflow_name": "İş Akışı Adı", + "workflow_runs": "İş akışı çalıştırmaları", + "workflows": "İş akışları", "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.", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "Yüklenen dosya bağlantısı veri gizliliği nedeniyle dahil edilmemiştir", "response_data": "Yanıt verileri", "response_finished_email_subject": "{surveyName} için bir yanıt tamamlandı ✅", - "response_finished_email_subject_with_email": "{personEmail} az önce {surveyName} survey'inizi tamamladı ✅", "schedule_your_meeting": "Toplantınızı planlayın", "select_a_date": "Bir tarih seçin", "survey_response_finished_email_congrats": "Tebrikler, survey'inize yeni bir yanıt aldınız! Birisi survey'inizi tamamladı: {surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "İki faktörlü kimlik doğrulama", "comparison_row_unify_feedback": "Tüm kaynaklardan geri bildirimleri birleştir", "comparison_row_unlimited_seats": "Sınırsız kullanıcı", + "comparison_row_workflows": "İş Akışları", "comparison_row_workspaces": "Çalışma alanları", "comparison_section_all_plans": "Tüm planlar", "comparison_section_basic_usage": "Temel kullanım", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "Ayda 5.000 yanıt, dinamik fiyatlandırma ile", "plan_scale_feature_security": "2FA ve spam koruması", "plan_scale_feature_semantic_analysis": "Anlamsal Analiz (Yapay Zeka)", + "plan_scale_feature_workflows": "İş Akışları", "plan_scale_feature_workspaces": "5 çalışma alanı", "plan_selection_description": "Hobby, Pro ve Scale planlarını karşılaştır, ardından doğrudan Formbricks'ten plan değiştir.", "plan_selection_title": "Planını seç", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "Yanıt verilerini ekle", "follow_ups_modal_action_body_label": "Gövde", "follow_ups_modal_action_body_placeholder": "E-postanın içeriği", + "follow_ups_modal_action_email_already_added": "Bu e-posta adresi zaten eklendi", "follow_ups_modal_action_email_content": "E-posta içeriği", + "follow_ups_modal_action_email_input_placeholder": "Bir e-posta yazın ve boşluk tuşuna basın", + "follow_ups_modal_action_email_invalid": "Lütfen geçerli bir e-posta adresi girin", "follow_ups_modal_action_email_settings": "E-posta ayarları", "follow_ups_modal_action_from_description": "E-postanın gönderileceği adres", "follow_ups_modal_action_from_label": "Gönderen", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "Katılımcı anketi tamamlıyor", "follow_ups_modal_updated_successfull_toast": "Takip güncellendi ve anketi kaydettiğinde kaydedilecek.", "follow_ups_new": "Yeni takip", + "follow_ups_workflows_alert_title": "Daha fazla esnekliğe mi ihtiyacın var? Takip işlemlerini ve daha fazlasını İş Akışları ile otomatikleştir.", "formbricks_sdk_is_not_connected": "Formbricks SDK bağlı değil", "four_points": "4 puan", "heading": "Başlık", @@ -4134,6 +4169,119 @@ "value_number": "Değer (Sayı)", "value_text": "Değer (Metin)" }, + "workflows": { + "add_action": "Eylem ekle", + "add_trigger": "Tetikleyici ekle", + "add_trigger_description": "Bu iş akışını neyin başlatacağını seç.", + "all_changes_saved": "Tüm değişiklikler kaydedildi", + "alphabetical": "Alfabetik", + "archive_confirm_body": "Arşivleme bu iş akışını devre dışı bırakır ve çalışmasını durdurur. Daha sonra tekrar arşivden çıkarabilirsin.", + "archive_confirm_title": "İş akışı arşivlensin mi?", + "archive_failed": "İş akışı arşivlenemedi. Lütfen tekrar deneyin.", + "archive_success": "İş akışı arşivlendi.", + "archive_workflow": "İş akışını arşivle", + "archive_workflow_confirmation": "\"{name}\" iş akışını arşivlemek istediğinizden emin misiniz? Daha sonra geri yükleyebilirsiniz.", + "archive_workflow_description": "Arşivleme, iş akışını listeden gizler. Daha sonra geri yükleyebilirsiniz.", + "auto_layout": "Otomatik düzen", + "autosave_failed": "Kaydetme başarısız oldu", + "autosave_failed_tooltip": "Son değişiklikleriniz kaydedilemedi. Bağlantınızı kontrol edin ve tekrar deneyin.", + "autosave_failed_tooltip_rejected": "Son değişiklikleriniz kaydedilemedi: {detail}", + "collapse_inspector": "Denetçiyi daralt", + "create_failed": "İş akışı oluşturulamadı. Lütfen tekrar deneyin.", + "delete_failed": "İş akışı silinemedi. Lütfen tekrar deneyin.", + "delete_success": "İş akışı silindi.", + "delete_workflow_confirmation": "Bu işlem \"{name}\" iş akışını ve çalıştırma geçmişini kalıcı olarak siler.", + "disable_failed": "İş akışı devre dışı bırakılamadı.", + "disable_success": "İş akışı devre dışı bırakıldı.", + "duplicate_failed": "İş akışı kopyalanamadı. Lütfen tekrar deneyin.", + "duplicate_success": "İş akışı kopyalandı.", + "edit_blocked_active": "Değişiklik yapmak için iş akışını devre dışı bırakın.", + "email_attach_response_data_description": "E-posta yüküne tetikleyen anket yanıtını ekle.", + "email_attach_response_data_label": "Yanıt verisini ekle", + "email_body_label": "Gövde", + "email_body_placeholder": "Göndermek istediğin mesajı yaz…", + "email_body_required": "Gönderilecek mesajı ekleyin.", + "email_from_label": "Gönderen", + "email_include_hidden_fields_label": "Gizli alanları dahil et", + "email_include_variables_label": "Değişkenleri dahil et", + "email_needs_survey": "Önce tetikleyici adımında bir anket bağla. Alıcı ve mesaj seçenekleri anketin yanıtlarından gelir.", + "email_reply_to_label": "Yanıtla", + "email_set_up_trigger": "Tetikleyiciyi ayarla", + "email_subject_label": "Konu", + "email_subject_placeholder": "Anketi tamamladığın için teşekkürler", + "email_subject_required": "Bir konu satırı ekleyin.", + "email_to_label": "Gönder", + "email_to_placeholder": "ekip@ornek.com", + "email_to_required": "Bu e-postayı kimin alacağını seçin.", + "enable_blocked_unsaved_changes": "Son değişiklikleriniz kaydedilemediği için iş akışı etkinleştirilemedi.", + "enable_failed": "İş akışı etkinleştirilemedi.", + "enable_success": "İş akışı etkinleştirildi.", + "expand_inspector": "Denetçiyi genişlet", + "if_else": "Eğer / Değilse", + "if_else_summary": "İş akışını bir koşula göre dallandır.", + "inspector_unsupported_node": "Bu düğüm türü henüz bir yapılandırma formuna sahip değil.", + "load_failed": "İş akışı yüklenemedi.", + "name_required": "Lütfen bir ad girin.", + "no_results_description": "Aramayı veya filtreleri ayarlamayı dene.", + "no_results_title": "İş akışı bulunamadı", + "no_workflows_description": "Yanıtlar geldiğinde işlemleri otomatikleştirmek için ilk iş akışınızı oluşturun.", + "no_workflows_title": "Henüz iş akışı yok", + "node_actions": "Düğüm eylemleri", + "node_needs_email_content": "Alıcı ve içeriği ayarla", + "node_needs_survey": "Başlamak için bir anket seç", + "pan_mode": "Kaydırma modu", + "pointer_mode": "İşaretçi modu", + "read_only": "Salt okunur", + "relative_date": "{date}, {time}", + "relative_days_ago": "{count, plural, one {# gün önce} other {# gün önce}}, {time}", + "relative_today": "Bugün, {time}", + "relative_yesterday": "Dün, {time}", + "response_completed": "Yanıt tamamlandı", + "response_completed_description": "Birisi bir anket yanıtını tamamladığında çalışır.", + "save_failed": "İş akışı kaydedilemedi.", + "save_success": "İş akışı kaydedildi.", + "saving_changes": "Kaydediliyor…", + "search_by_workflow_name": "İş akışı adına göre ara", + "send_email": "E-posta gönder", + "send_email_description": "Bu iş akışı çalıştığında bir e-posta gönder.", + "send_email_summary": "{to} adresine e-posta gönder.", + "send_email_unconfigured": "E-posta alıcısını yapılandır.", + "trigger_ending_cards_label": "Bitiş kartları", + "trigger_ending_cards_none": "Bu ankette yapılandırılmış bitiş yok.", + "trigger_ending_cards_pick_survey": "Bitişlerini görmek için bir anket seç.", + "trigger_ending_cards_scope_all": "Tüm bitişler", + "trigger_ending_cards_scope_specific": "Belirli bitişler", + "trigger_ending_cards_select_at_least_one": "En az bir bitiş seç. Hiçbiri seçilmezse, her bitiş bu iş akışını tetikler.", + "trigger_summary_all_endings": "Herhangi bir anket yanıtında tetikle.", + "trigger_summary_ending_cards": "{count, plural, one {# bitiş kartında} other {# bitiş kartında}} tetikle.", + "trigger_survey_description": "Tamamlanan yanıtları bu iş akışını tetikleyecek anketi seç.", + "trigger_survey_empty": "Bu çalışma alanında henüz anket yok.", + "trigger_survey_label": "Anket", + "trigger_survey_placeholder": "Bir anket seç", + "triggers": "Tetikleyiciler", + "unarchive": "Arşivden çıkar", + "unarchive_failed": "İş akışı arşivden çıkarılamadı. Lütfen tekrar deneyin.", + "unarchive_success": "İş akışı arşivden çıkarıldı.", + "upgrade_prompt_description": "Tetikleyiciler, filtreler ve eylemlerle yanıt odaklı görevleri otomatikleştir.", + "upgrade_prompt_title": "İş Akışlarının kilidini açmak için yükselt", + "validation_failed": "İş akışı doğrulaması başarısız oldu.", + "validation_problem_fix_label": "Düzelt: {problem}", + "validation_problem_flow_invalid": "İş akışı adımları tek bir çalıştırılabilir akışa bağlı değil.", + "validation_problem_generic": "İş akışının bu kısmında bir yapılandırma sorunu var.", + "validation_problem_name_missing": "İş akışına bir isim ver.", + "validation_problem_step_incomplete": "E-posta adımının alıcısını, konusunu ve içeriğini doldur.", + "validation_problem_step_not_executable": "Bu adım türü henüz çalıştırılamıyor. İş akışını etkinleştirmeden önce kaldır.", + "validation_problem_trigger_ending_not_found": "Seçilen son, bağlı ankette artık mevcut değil.", + "validation_problem_trigger_missing": "İş akışını başlatmak için bir tetikleyici ekle.", + "validation_problem_trigger_not_connected": "Tetikleyiciden sonra bir adım bağla.", + "validation_problem_trigger_survey_unbound": "Tetikleyiciyi bu çalışma alanındaki bir ankete bağla.", + "validation_problems_count": "{count, plural, one {# sorun} other {# sorun}}", + "validation_problems_description": "İş akışının çalışabilmesi için bu sorunları çöz:", + "validation_problems_title": "Doğrulama sorunları", + "validation_status_valid": "Geçerli", + "zoom_in": "Yakınlaştır", + "zoom_out": "Uzaklaştır" + }, "xm-templates": { "ces": "CES", "ces_description": "Müşteri Çaba Skoru", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 65f5f4350085..392326d0fbcf 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "已接受", "account": "账号", "account_settings": "帐户设置", + "act": "行动", "action": "操作", "actions": "操作", "actions_description": "代码 和 无代码 操作 用于 触发 拦截 调查 在 应用程序 和 网站 中。", @@ -185,6 +188,7 @@ "archive": "归档", "archived": "已归档", "are_you_sure": "你 确定 吗?", + "attempt": "尝试", "attributes": "属性", "authorized_apps": "Authorized Apps", "back": "返回", @@ -193,6 +197,7 @@ "bottom_left": "右下", "bottom_right": "右下", "cancel": "取消", + "canceled": "已取消", "centered_modal": "居中 模态", "chart": "图表", "charts": "图表", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(副本 {copyNumber})", "e_commerce": "电子商务", "edit": "编辑", + "editor": "编辑器", "elements": "元素", "email": "邮箱", "enable": "启用", + "enabled": "已启用", "ending_card": "结尾卡片", "enter_url": "输入 URL", "enterprise_license": "企业 许可证", @@ -278,6 +285,7 @@ "error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。", "error_rate_limit_title": "速率 限制 超过", "expand_rows": "展开 行", + "failed": "失败", "failed_to_copy_to_clipboard": "复制到剪贴板失败", "failed_to_load_organizations": "加载组织失败", "failed_to_load_workspaces": "加载工作区失败", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "File upload service unavailable", "filter": "筛选", "finish": "完成", + "finished_at": "完成时间", "first_name": "名字", "formbricks_version": "Formbricks 版本", "full_name": "全名", @@ -310,6 +319,7 @@ "imprint": "印记", "in_progress": "进行中", "inactive_surveys": "不 活跃 调查", + "input": "输入", "integration": "集成", "integrations": "集成", "invalid_date_with_value": "无效 日期: {value}", @@ -350,6 +360,7 @@ "move_up": "上移", "name": "名称", "new_version_available": "Formbricks {version} 在 这里。立即 升级!", + "new_workflow": "新建工作流", "next": "下一步", "no": "否", "no_actions_found": "未找到操作", @@ -388,10 +399,12 @@ "other": "其他", "other_filters": "其他筛选条件", "other_placeholder": "其他占位符", + "output": "输出", "overlay_color": "覆盖层颜色", "overview": "概览", "password": "密码", "paused": "暂停", + "pending": "待处理", "pending_downgrade": "等待降级", "people_manager": "员工体验", "person": "人员", @@ -412,6 +425,7 @@ "question": "问题", "question_id": "问题 ID", "questions": "问题", + "queued": "排队中", "quota": "配额", "quotas": "配额", "quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。", @@ -424,15 +438,20 @@ "replace": "替换", "report_survey": "报告调查", "request_trial_license": "申请试用许可证", + "required": "必填", "reset_to_default": "重置为 默认", "resize": "调整大小", "response": "响应", + "response_completed": "响应已完成", "response_id": "响应 ID", "responses": "反馈", "restart": "重新启动", "retry": "重试", "role": "角色", "row_n": "第 {n} 行", + "run_data": "运行数据", + "running": "运行中", + "runs": "运行", "saas": "SaaS", "sales": "销售", "save": "保存", @@ -468,12 +487,16 @@ "something_went_wrong": "出错了", "something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。", "sort_by": "排序 依据", + "sort_by_value": "排序 依据: {label}", + "started_at": "开始时间", "status": "状态", + "steps": "步骤", "storage_not_configured": "文件存储 未设置,上传 可能 失败", "string": "文本", "styling": "样式", "subheader": "副标题", "submit": "提交", + "succeeded": "成功", "summary": "概要", "survey": "调查", "survey_completed": "调查 完成", @@ -506,8 +529,11 @@ "trial_expired": "您的试用期已过期", "trial_one_day_remaining": "试用期还剩 1 天", "trial_plan_badge": "{plan} 试用版", + "trigger": "触发器", + "trigger_payload": "触发负载", "try_again": "再试一次", "type": "类型", + "unarchive": "取消归档", "undo": "撤销", "unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。", "update": "更新", @@ -527,6 +553,7 @@ "verified_email": "已验证 电子邮件", "video": "视频", "view": "查看", + "view_workflow": "查看工作流", "warning": "警告", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我们无法验证您的许可证,因为许可证服务器无法访问。", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "周", "welcome_card": "欢迎 卡片", "whats_new": "最新动态", + "workflow_name": "工作流名称", + "workflow_runs": "工作流运行", + "workflows": "工作流", "workspace": "工作区", "workspace_created_successfully": "工作区创建成功", "workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "未包括上传文件的链接 数据隐私原因", "response_data": "响应数据", "response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅", - "response_finished_email_subject_with_email": "{personEmail} 刚刚完成了你的 {surveyName} 调查 ✅", "schedule_your_meeting": "安排你的会议", "select_a_date": "选择 日期", "survey_response_finished_email_congrats": "恭喜,您收到了一份新的问卷回复!有人刚刚完成了您的问卷:{surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "双因素身份验证", "comparison_row_unify_feedback": "统一所有来源的反馈", "comparison_row_unlimited_seats": "无限席位", + "comparison_row_workflows": "工作流", "comparison_row_workspaces": "工作区", "comparison_section_all_plans": "所有套餐", "comparison_section_basic_usage": "核心用量", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "每月 5,000 次响应,采用动态定价", "plan_scale_feature_security": "双因素认证和垃圾邮件防护", "plan_scale_feature_semantic_analysis": "语义分析(AI)", + "plan_scale_feature_workflows": "工作流", "plan_scale_feature_workspaces": "5 个工作区", "plan_selection_description": "比较 Hobby、Pro 和 Scale 套餐,然后直接从 Formbricks 切换套餐。", "plan_selection_title": "选择您的套餐", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "附加响应数据", "follow_ups_modal_action_body_label": "正文", "follow_ups_modal_action_body_placeholder": "电子邮件正文", + "follow_ups_modal_action_email_already_added": "此邮箱已添加", "follow_ups_modal_action_email_content": "电子邮件 内容", + "follow_ups_modal_action_email_input_placeholder": "输入邮箱并按空格键", + "follow_ups_modal_action_email_invalid": "请输入有效的邮箱地址", "follow_ups_modal_action_email_settings": "邮件设置", "follow_ups_modal_action_from_description": "发送邮件的电子邮箱地址", "follow_ups_modal_action_from_label": "从", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "受访者 完成 调查", "follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。", "follow_ups_new": "新的跟进", + "follow_ups_workflows_alert_title": "需要更多灵活性?使用工作流自动化后续跟进及更多操作。", "formbricks_sdk_is_not_connected": "Formbricks SDK 未连接", "four_points": "4 分", "heading": "标题", @@ -4134,6 +4169,119 @@ "value_number": "值(数量)", "value_text": "值(文本)" }, + "workflows": { + "add_action": "添加操作", + "add_trigger": "添加触发器", + "add_trigger_description": "选择启动此工作流的方式。", + "all_changes_saved": "所有更改已保存", + "alphabetical": "按字母顺序", + "archive_confirm_body": "归档后将禁用此工作流程并停止运行。你可以稍后再取消归档。", + "archive_confirm_title": "归档工作流程?", + "archive_failed": "工作流归档失败。请重试。", + "archive_success": "工作流已归档。", + "archive_workflow": "归档工作流", + "archive_workflow_confirmation": "确定要归档“{name}”吗?你可以稍后恢复它。", + "archive_workflow_description": "归档后,工作流会从列表中隐藏。你可以随时恢复。", + "auto_layout": "自动布局", + "autosave_failed": "保存失败", + "autosave_failed_tooltip": "无法保存你的最新更改。请检查网络连接并重试。", + "autosave_failed_tooltip_rejected": "无法保存你的最新更改:{detail}", + "collapse_inspector": "收起检查器", + "create_failed": "创建工作流失败。请重试。", + "delete_failed": "删除工作流失败。请重试。", + "delete_success": "工作流已删除。", + "delete_workflow_confirmation": "这将永久删除“{name}”及其运行历史。", + "disable_failed": "无法禁用该工作流。", + "disable_success": "工作流已禁用。", + "duplicate_failed": "复制工作流失败。请重试。", + "duplicate_success": "工作流已复制。", + "edit_blocked_active": "请停用工作流以进行更改。", + "email_attach_response_data_description": "在电子邮件负载中包含触发调查的响应数据。", + "email_attach_response_data_label": "附加响应数据", + "email_body_label": "正文", + "email_body_placeholder": "输入你想发送的消息…", + "email_body_required": "添加要发送的消息。", + "email_from_label": "发件人", + "email_include_hidden_fields_label": "包含隐藏字段", + "email_include_variables_label": "包含变量", + "email_needs_survey": "请先在触发器步骤中连接一个调查问卷。收件人和消息选项将来自调查问卷的回答。", + "email_reply_to_label": "回复至", + "email_set_up_trigger": "设置触发器", + "email_subject_label": "主题", + "email_subject_placeholder": "感谢您完成调查", + "email_subject_required": "添加主题行。", + "email_to_label": "发送至", + "email_to_placeholder": "team@example.com", + "email_to_required": "选择应接收此邮件的收件人。", + "enable_blocked_unsaved_changes": "无法保存你的最新更改,因此工作流未启用。", + "enable_failed": "无法启用工作流。", + "enable_success": "工作流已启用。", + "expand_inspector": "展开检查器", + "if_else": "条件分支", + "if_else_summary": "根据条件分支工作流。", + "inspector_unsupported_node": "此节点类型暂无配置表单。", + "load_failed": "无法加载工作流。", + "name_required": "请输入名称。", + "no_results_description": "试试调整你的搜索或筛选条件。", + "no_results_title": "未找到工作流", + "no_workflows_description": "创建你的第一个工作流,让回复自动化。", + "no_workflows_title": "暂无工作流", + "node_actions": "节点操作", + "node_needs_email_content": "设置收件人和内容", + "node_needs_survey": "选择一个调查问卷以开始", + "pan_mode": "平移模式", + "pointer_mode": "指针模式", + "read_only": "只读", + "relative_date": "{date} {time}", + "relative_days_ago": "{count, plural, other {# 天前}} {time}", + "relative_today": "今天 {time}", + "relative_yesterday": "昨天 {time}", + "response_completed": "回复已完成", + "response_completed_description": "当有人完成调查问卷回复时运行。", + "save_failed": "无法保存工作流。", + "save_success": "工作流已保存。", + "saving_changes": "保存中…", + "search_by_workflow_name": "按工作流名称搜索", + "send_email": "发送邮件", + "send_email_description": "当此工作流运行时发送一封邮件。", + "send_email_summary": "向 {to} 发送邮件。", + "send_email_unconfigured": "配置邮件收件人。", + "trigger_ending_cards_label": "结束卡片", + "trigger_ending_cards_none": "此调查问卷尚未配置结束页面。", + "trigger_ending_cards_pick_survey": "选择一个调查问卷以查看其结束页面。", + "trigger_ending_cards_scope_all": "所有结束页面", + "trigger_ending_cards_scope_specific": "特定结束页面", + "trigger_ending_cards_select_at_least_one": "至少选择一个结束页面。如果未选择任何结束页面,则每个结束页面都会触发此工作流。", + "trigger_summary_all_endings": "在任何调查问卷响应时触发。", + "trigger_summary_ending_cards": "在 {count, plural, other {# 个结束卡片}}时触发。", + "trigger_survey_description": "选择完成回复后触发此工作流的调查问卷。", + "trigger_survey_empty": "此工作区中还没有调查问卷。", + "trigger_survey_label": "调查问卷", + "trigger_survey_placeholder": "选择调查问卷", + "triggers": "触发器", + "unarchive": "取消归档", + "unarchive_failed": "取消归档工作流失败。请重试。", + "unarchive_success": "工作流已取消归档。", + "upgrade_prompt_description": "通过触发器、筛选器和操作自动执行响应驱动的任务。", + "upgrade_prompt_title": "升级以解锁工作流功能", + "validation_failed": "工作流验证失败。", + "validation_problem_fix_label": "修复:{problem}", + "validation_problem_flow_invalid": "工作流步骤未连接成单个可运行的流程。", + "validation_problem_generic": "工作流的这部分存在配置问题。", + "validation_problem_name_missing": "为工作流命名。", + "validation_problem_step_incomplete": "填写邮件步骤的收件人、主题和正文。", + "validation_problem_step_not_executable": "此步骤类型暂时无法运行。请在启用工作流之前移除它。", + "validation_problem_trigger_ending_not_found": "所选结束页在关联的问卷中已不存在。", + "validation_problem_trigger_missing": "添加触发器以启动工作流。", + "validation_problem_trigger_not_connected": "在触发器后连接一个步骤。", + "validation_problem_trigger_survey_unbound": "将触发器连接到此工作区中的调查问卷。", + "validation_problems_count": "{count, plural, other {# 个问题}}", + "validation_problems_description": "修复这些问题后工作流才能运行:", + "validation_problems_title": "验证问题", + "validation_status_valid": "有效", + "zoom_in": "放大", + "zoom_out": "缩小" + }, "xm-templates": { "ces": "客户努力评分", "ces_description": "客户费力度分数", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 7544ec518c6e..8884c2ed2948 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -98,7 +98,9 @@ "openid": "Identify you with OpenID Connect", "profile": "View your profile", "surveys_read": "Read surveys", - "surveys_write": "Create, update, and delete surveys" + "surveys_write": "Create, update, and delete surveys", + "workflows_read": "Read workflows", + "workflows_write": "Create, update, and delete workflows" }, "unknown_client": "Unknown client" }, @@ -151,6 +153,7 @@ "accepted": "已接受", "account": "帳戶", "account_settings": "帳戶設定", + "act": "行動", "action": "操作", "actions": "操作", "actions_description": "程式碼動作與無程式碼動作可用於在應用程式與網站上觸發截取式問卷。", @@ -185,6 +188,7 @@ "archive": "封存", "archived": "已封存", "are_you_sure": "您確定嗎?", + "attempt": "嘗試", "attributes": "屬性", "authorized_apps": "Authorized Apps", "back": "返回", @@ -193,6 +197,7 @@ "bottom_left": "左下", "bottom_right": "右下", "cancel": "取消", + "canceled": "已取消", "centered_modal": "置中彈窗", "chart": "圖表", "charts": "圖表", @@ -265,9 +270,11 @@ "duplicate_copy_number": "(複製 {copyNumber})", "e_commerce": "電子商務", "edit": "編輯", + "editor": "編輯器", "elements": "元素", "email": "電子郵件", "enable": "啟用", + "enabled": "已啟用", "ending_card": "結尾卡片", "enter_url": "輸入 URL", "enterprise_license": "企業授權", @@ -278,6 +285,7 @@ "error_rate_limit_description": "已達到最大請求次數。請稍後再試。", "error_rate_limit_title": "限流超過", "expand_rows": "展開列", + "failed": "失敗", "failed_to_copy_to_clipboard": "無法複製到剪貼簿", "failed_to_load_organizations": "無法載入組織", "failed_to_load_workspaces": "載入工作區失敗", @@ -288,6 +296,7 @@ "file_upload_service_unavailable": "檔案上傳服務無法使用", "filter": "篩選", "finish": "完成", + "finished_at": "完成時間", "first_name": "名字", "formbricks_version": "Formbricks 版本", "full_name": "全名", @@ -310,6 +319,7 @@ "imprint": "出版資訊", "in_progress": "進行中", "inactive_surveys": "停用中的問卷", + "input": "輸入", "integration": "整合", "integrations": "整合", "invalid_date_with_value": "無效日期: {value}", @@ -350,6 +360,7 @@ "move_up": "上移", "name": "名稱", "new_version_available": "Formbricks {version} 已推出。立即升級!", + "new_workflow": "新增工作流程", "next": "下一步", "no": "否", "no_actions_found": "找不到動作", @@ -388,10 +399,12 @@ "other": "其他", "other_filters": "其他篩選條件", "other_placeholder": "其他預設文字", + "output": "輸出", "overlay_color": "覆蓋層顏色", "overview": "概覽", "password": "密碼", "paused": "已暫停", + "pending": "待處理", "pending_downgrade": "等待降級", "people_manager": "員工體驗", "person": "人員", @@ -412,6 +425,7 @@ "question": "問題", "question_id": "問題 ID", "questions": "問題", + "queued": "佇列中", "quota": "配額", "quotas": "配額", "quotas_description": "限制符合特定條件的參與者所能提交的回應數量。", @@ -424,15 +438,20 @@ "replace": "取代", "report_survey": "報告問卷", "request_trial_license": "請求試用授權", + "required": "必填", "reset_to_default": "重設為預設值", "resize": "調整大小", "response": "回應", + "response_completed": "回應已完成", "response_id": "回應 ID", "responses": "回應", "restart": "重新開始", "retry": "重試", "role": "角色", "row_n": "列 {n}", + "run_data": "執行資料", + "running": "執行中", + "runs": "執行", "saas": "SaaS", "sales": "銷售", "save": "儲存", @@ -468,12 +487,16 @@ "something_went_wrong": "發生錯誤", "something_went_wrong_please_try_again": "發生錯誤。請再試一次。", "sort_by": "排序方式", + "sort_by_value": "排序方式: {label}", + "started_at": "開始時間", "status": "狀態", + "steps": "步驟", "storage_not_configured": "檔案儲存未設定,上傳可能會失敗", "string": "文字", "styling": "樣式設定", "subheader": "副標題", "submit": "提交", + "succeeded": "成功", "summary": "摘要", "survey": "問卷", "survey_completed": "問卷已完成。", @@ -506,8 +529,11 @@ "trial_expired": "您的試用期已結束", "trial_one_day_remaining": "試用期剩餘 1 天", "trial_plan_badge": "{plan} 試用版", + "trigger": "觸發器", + "trigger_payload": "觸發資料", "try_again": "再試一次", "type": "類型", + "unarchive": "取消封存", "undo": "復原", "unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。", "update": "更新", @@ -527,6 +553,7 @@ "verified_email": "已驗證的電子郵件", "video": "影片", "view": "檢視", + "view_workflow": "檢視工作流程", "warning": "警告", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我們無法驗證您的授權,因為授權伺服器無法連線。", "webhook": "Webhook", @@ -536,6 +563,9 @@ "weeks": "週", "welcome_card": "歡迎卡片", "whats_new": "最新消息", + "workflow_name": "工作流程名稱", + "workflow_runs": "工作流程執行", + "workflows": "工作流程", "workspace": "工作區", "workspace_created_successfully": "工作區已成功建立", "workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。", @@ -601,7 +631,6 @@ "render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結", "response_data": "回應資料", "response_finished_email_subject": "{surveyName} 的回應已完成 ✅", - "response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅", "schedule_your_meeting": "安排你的會議", "select_a_date": "選擇日期", "survey_response_finished_email_congrats": "恭喜,您收到了新的問卷回應!有人剛完成您的問卷:{surveyName}", @@ -2455,6 +2484,7 @@ "comparison_row_two_factor_auth": "雙重驗證", "comparison_row_unify_feedback": "整合所有來源的意見回饋", "comparison_row_unlimited_seats": "無限制席次", + "comparison_row_workflows": "工作流程", "comparison_row_workspaces": "工作區", "comparison_section_all_plans": "所有方案", "comparison_section_basic_usage": "核心用量", @@ -2541,6 +2571,7 @@ "plan_scale_feature_responses": "每月 5,000 次回應,採用動態定價", "plan_scale_feature_security": "雙因素驗證與垃圾訊息防護", "plan_scale_feature_semantic_analysis": "語義分析(AI)", + "plan_scale_feature_workflows": "工作流程", "plan_scale_feature_workspaces": "5 個工作區", "plan_selection_description": "比較 Hobby、Pro 和 Scale 方案,然後直接在 Formbricks 中切換方案。", "plan_selection_title": "選擇您的方案", @@ -3165,7 +3196,10 @@ "follow_ups_modal_action_attach_response_data_label": "附加 response data", "follow_ups_modal_action_body_label": "內文", "follow_ups_modal_action_body_placeholder": "電子郵件內文", + "follow_ups_modal_action_email_already_added": "此電子郵件地址已新增", "follow_ups_modal_action_email_content": "電子郵件內容", + "follow_ups_modal_action_email_input_placeholder": "輸入電子郵件地址並按空白鍵", + "follow_ups_modal_action_email_invalid": "請輸入有效的電子郵件地址", "follow_ups_modal_action_email_settings": "電子郵件設定", "follow_ups_modal_action_from_description": "傳送電子郵件的電子郵件地址", "follow_ups_modal_action_from_label": "寄件者", @@ -3193,6 +3227,7 @@ "follow_ups_modal_trigger_type_response": "回應者完成問卷", "follow_ups_modal_updated_successfull_toast": "後續動作已更新,待你儲存問卷後一併保存。", "follow_ups_new": "新增後續追蹤", + "follow_ups_workflows_alert_title": "需要更多彈性嗎?使用工作流程自動化後續追蹤及更多功能。", "formbricks_sdk_is_not_connected": "Formbricks SDK 未連線", "four_points": "4 分", "heading": "標題", @@ -4134,6 +4169,119 @@ "value_number": "值(數量)", "value_text": "值(文字)" }, + "workflows": { + "add_action": "新增動作", + "add_trigger": "新增觸發條件", + "add_trigger_description": "選擇啟動此工作流程的條件。", + "all_changes_saved": "已儲存所有變更", + "alphabetical": "字母順序", + "archive_confirm_body": "封存會停用此工作流程並停止執行。你可以稍後再解除封存。", + "archive_confirm_title": "封存工作流程?", + "archive_failed": "無法封存工作流程,請再試一次。", + "archive_success": "工作流程已封存。", + "archive_workflow": "封存工作流程", + "archive_workflow_confirmation": "確定要封存「{name}」嗎?你之後可以還原它。", + "archive_workflow_description": "封存會將工作流程從列表中隱藏,你之後可以還原它。", + "auto_layout": "自動排版", + "autosave_failed": "儲存失敗", + "autosave_failed_tooltip": "無法儲存你的最新變更。請檢查你的連線並再試一次。", + "autosave_failed_tooltip_rejected": "無法儲存你的最新變更:{detail}", + "collapse_inspector": "收合檢查器", + "create_failed": "無法建立工作流程,請再試一次。", + "delete_failed": "無法刪除工作流程,請再試一次。", + "delete_success": "工作流程已刪除。", + "delete_workflow_confirmation": "這將永久刪除「{name}」及其執行記錄。", + "disable_failed": "無法停用工作流程。", + "disable_success": "工作流程已停用。", + "duplicate_failed": "無法複製工作流程,請再試一次。", + "duplicate_success": "工作流程已複製。", + "edit_blocked_active": "請停用工作流程以進行變更。", + "email_attach_response_data_description": "在電子郵件內容中包含觸發的調查回應。", + "email_attach_response_data_label": "附加回應資料", + "email_body_label": "內容", + "email_body_placeholder": "撰寫你想傳送的訊息…", + "email_body_required": "新增要傳送的訊息。", + "email_from_label": "寄件人", + "email_include_hidden_fields_label": "包含隱藏欄位", + "email_include_variables_label": "包含變數", + "email_needs_survey": "請先在觸發步驟中連接問卷。收件人和訊息選項來自問卷的回答內容。", + "email_reply_to_label": "回覆至", + "email_set_up_trigger": "設定觸發條件", + "email_subject_label": "主旨", + "email_subject_placeholder": "感謝你完成問卷", + "email_subject_required": "新增主旨。", + "email_to_label": "傳送至", + "email_to_placeholder": "team@example.com", + "email_to_required": "選擇誰應該收到這封電子郵件。", + "enable_blocked_unsaved_changes": "無法儲存你的最新變更,因此工作流程未啟用。", + "enable_failed": "無法啟用工作流程。", + "enable_success": "工作流程已啟用。", + "expand_inspector": "展開檢查器", + "if_else": "如果 / 否則", + "if_else_summary": "根據條件分支工作流程。", + "inspector_unsupported_node": "此節點類型尚未提供設定表單。", + "load_failed": "無法載入工作流程。", + "name_required": "請輸入名稱。", + "no_results_description": "試著調整你的搜尋條件或篩選器。", + "no_results_title": "找不到工作流程", + "no_workflows_description": "建立你的第一個工作流程,在收到回應時自動執行動作。", + "no_workflows_title": "尚無工作流程", + "node_actions": "節點動作", + "node_needs_email_content": "設定收件人與內容", + "node_needs_survey": "選擇一份問卷以開始", + "pan_mode": "平移模式", + "pointer_mode": "指標模式", + "read_only": "唯讀", + "relative_date": "{date} {time}", + "relative_days_ago": "{count, plural, other {# 天前}} {time}", + "relative_today": "今天 {time}", + "relative_yesterday": "昨天 {time}", + "response_completed": "回應已完成", + "response_completed_description": "當有人完成問卷回覆時執行。", + "save_failed": "無法儲存工作流程。", + "save_success": "工作流程已儲存。", + "saving_changes": "儲存中…", + "search_by_workflow_name": "依工作流程名稱搜尋", + "send_email": "傳送電子郵件", + "send_email_description": "在此工作流程執行時發送電子郵件。", + "send_email_summary": "傳送電子郵件給 {to}。", + "send_email_unconfigured": "設定電子郵件收件人。", + "trigger_ending_cards_label": "結束卡片", + "trigger_ending_cards_none": "此問卷沒有設定任何結束畫面。", + "trigger_ending_cards_pick_survey": "請選擇一個問卷以查看其結束畫面。", + "trigger_ending_cards_scope_all": "所有結束畫面", + "trigger_ending_cards_scope_specific": "特定結束畫面", + "trigger_ending_cards_select_at_least_one": "請至少選擇一個結束畫面。若未選擇任何結束畫面,則每個結束畫面都會觸發此工作流程。", + "trigger_summary_all_endings": "在任何問卷回應時觸發。", + "trigger_summary_ending_cards": "在 {count, plural, other {# 個結束卡片}}時觸發。", + "trigger_survey_description": "請選擇要用來觸發此工作流程的問卷(當有完成的回覆時)。", + "trigger_survey_empty": "此工作區尚無問卷。", + "trigger_survey_label": "問卷", + "trigger_survey_placeholder": "選擇問卷", + "triggers": "觸發條件", + "unarchive": "取消封存", + "unarchive_failed": "無法取消封存工作流程,請再試一次。", + "unarchive_success": "工作流程已取消封存。", + "upgrade_prompt_description": "透過觸發條件、篩選器和動作,自動化回應驅動的任務。", + "upgrade_prompt_title": "升級以解鎖工作流程", + "validation_failed": "工作流程驗證失敗。", + "validation_problem_fix_label": "修正:{problem}", + "validation_problem_flow_invalid": "工作流程步驟未連接成單一可執行流程。", + "validation_problem_generic": "工作流程的這個部分有設定問題。", + "validation_problem_name_missing": "請為工作流程命名。", + "validation_problem_step_incomplete": "請填寫電子郵件步驟的收件人、主旨和內容。", + "validation_problem_step_not_executable": "此步驟類型尚無法執行。請在啟用工作流程前將其移除。", + "validation_problem_trigger_ending_not_found": "所選的結尾在已連接的問卷中已不存在。", + "validation_problem_trigger_missing": "請新增觸發條件以啟動工作流程。", + "validation_problem_trigger_not_connected": "請在觸發條件後連接步驟。", + "validation_problem_trigger_survey_unbound": "請將觸發條件連接至此工作區中的問卷。", + "validation_problems_count": "{count, plural, other {# 個問題}}", + "validation_problems_description": "請修正這些問題後才能執行工作流程:", + "validation_problems_title": "驗證問題", + "validation_status_valid": "有效", + "zoom_in": "放大", + "zoom_out": "縮小" + }, "xm-templates": { "ces": "CES", "ces_description": "客戶費力度(CES)", diff --git a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts index 710c04d5e2e8..e5d2454b4ccc 100644 --- a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts +++ b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts @@ -137,7 +137,13 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape expect(response.status).toBe(200); expect(body.scope?.split(" ")).toEqual( - expect.arrayContaining(["surveys:read", "surveys:write", "offline_access"]) + expect.arrayContaining([ + "surveys:read", + "surveys:write", + "workflows:read", + "workflows:write", + "offline_access", + ]) ); }); diff --git a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts index 40a678459e94..f2bedb351186 100644 --- a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts +++ b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts @@ -20,19 +20,19 @@ export const getMcpOauthProviderOptions = (): TOauthProviderOptions => ({ validAudiences: [getMcpResourceUrl()], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, - // Register MCP clients with read + write by default so the consent screen offers write and the - // write tools are reachable (clients derive their DCR/authorize scopes from what we advertise, and - // the plugin validates authorize against the client's registered scopes). Granting the write scope - // is safe: actual write access is still enforced downstream by the user's workspace permissions. - // Derived from the shared list rather than repeated: a scope added there must reach default client - // registration too, or clients would be told about a scope they can't register for. + // Register MCP clients with the full advertised scope set by default so the consent screen offers + // write and the write tools are reachable (clients derive their DCR/authorize scopes from what we + // advertise, and the plugin validates authorize against the client's registered scopes). Granting + // write is safe: actual write access is still enforced downstream by the user's workspace + // permissions. Spread the single source of truth so the defaults can't drift from MCP_OAUTH_SCOPES. clientRegistrationDefaultScopes: [...MCP_OAUTH_SCOPES], accessTokenExpiresIn: 15 * 60, refreshTokenExpiresIn: 30 * 24 * 60 * 60, - scopeExpirations: { - "surveys:write": "15m", - "feedbackRecords:write": "15m", - }, + // Every write scope gets the 15-minute step-up expiry, derived from the scope list so a new + // `:write` scope inherits it automatically (no separate hand-edit to keep in sync). + scopeExpirations: Object.fromEntries( + MCP_OAUTH_SCOPES.filter((scope) => scope.endsWith(":write")).map((scope) => [scope, "15m"]) + ), // Store opaque access-token and refresh-token lookup values as hashes. JWT access tokens are // stateless and bounded by the short 15-minute lifetime above. storeTokens: "hashed", diff --git a/apps/web/modules/auth/lib/oauth-client-metadata.test.ts b/apps/web/modules/auth/lib/oauth-client-metadata.test.ts index 7b0e788d2d69..68cc0a9d54a7 100644 --- a/apps/web/modules/auth/lib/oauth-client-metadata.test.ts +++ b/apps/web/modules/auth/lib/oauth-client-metadata.test.ts @@ -32,6 +32,8 @@ describe("OAuth client metadata helpers", () => { expect(getOAuthScopeLabel("offline_access", t)).toBe("translated:auth.oauth.scopes.offline_access"); expect(getOAuthScopeLabel("surveys:read", t)).toBe("translated:auth.oauth.scopes.surveys_read"); expect(getOAuthScopeLabel("surveys:write", t)).toBe("translated:auth.oauth.scopes.surveys_write"); + expect(getOAuthScopeLabel("workflows:read", t)).toBe("translated:auth.oauth.scopes.workflows_read"); + expect(getOAuthScopeLabel("workflows:write", t)).toBe("translated:auth.oauth.scopes.workflows_write"); expect(getOAuthScopeLabel("feedbackRecords:read", t)).toBe( "translated:auth.oauth.scopes.feedback_records_read" ); diff --git a/apps/web/modules/auth/lib/oauth-client-metadata.ts b/apps/web/modules/auth/lib/oauth-client-metadata.ts index f33b7815fd3b..2aac8a372458 100644 --- a/apps/web/modules/auth/lib/oauth-client-metadata.ts +++ b/apps/web/modules/auth/lib/oauth-client-metadata.ts @@ -44,6 +44,10 @@ export const getOAuthScopeLabel = (scope: string, t: (key: string) => string): s return t("auth.oauth.scopes.surveys_read"); case "surveys:write": return t("auth.oauth.scopes.surveys_write"); + case "workflows:read": + return t("auth.oauth.scopes.workflows_read"); + case "workflows:write": + return t("auth.oauth.scopes.workflows_write"); case "feedbackRecords:read": return t("auth.oauth.scopes.feedback_records_read"); case "feedbackRecords:write": diff --git a/apps/web/modules/auth/lib/oauth-urls.ts b/apps/web/modules/auth/lib/oauth-urls.ts index d35d11ef1f7e..27814c613d7f 100644 --- a/apps/web/modules/auth/lib/oauth-urls.ts +++ b/apps/web/modules/auth/lib/oauth-urls.ts @@ -47,6 +47,8 @@ export const MCP_OAUTH_SCOPES = [ "offline_access", "surveys:read", "surveys:write", + "workflows:read", + "workflows:write", "feedbackRecords:read", "feedbackRecords:write", ] as const; @@ -54,6 +56,8 @@ export const MCP_OAUTH_SCOPES = [ export const MCP_RESOURCE_SCOPES = [ "surveys:read", "surveys:write", + "workflows:read", + "workflows:write", "feedbackRecords:read", "feedbackRecords:write", ] as const; diff --git a/apps/web/modules/billing/lib/stripe-catalog.ts b/apps/web/modules/billing/lib/stripe-catalog.ts index f54b53852f6c..0e74b8dc8afa 100644 --- a/apps/web/modules/billing/lib/stripe-catalog.ts +++ b/apps/web/modules/billing/lib/stripe-catalog.ts @@ -12,5 +12,6 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = { AI_SMART_TOOLS: "ai-smart-tools", FEEDBACK_DIRECTORIES: "feedback-directories", DASHBOARDS: "dashboards", + WORKFLOWS: "workflows", BULK_INVITE: "bulk-invite", } as const; diff --git a/apps/web/modules/ee/audit-logs/types/audit-log.ts b/apps/web/modules/ee/audit-logs/types/audit-log.ts index adbe4ae42923..2a4146d886df 100644 --- a/apps/web/modules/ee/audit-logs/types/audit-log.ts +++ b/apps/web/modules/ee/audit-logs/types/audit-log.ts @@ -6,6 +6,7 @@ export const UNKNOWN_DATA = "unknown"; export const ZAuditTarget = z.enum([ "segment", "survey", + "workflow", "webhook", "user", "contactAttributeKey", diff --git a/apps/web/modules/ee/billing/components/plan-comparison.tsx b/apps/web/modules/ee/billing/components/plan-comparison.tsx index a4b69c26fb73..cfd73ad8b262 100644 --- a/apps/web/modules/ee/billing/components/plan-comparison.tsx +++ b/apps/web/modules/ee/billing/components/plan-comparison.tsx @@ -194,6 +194,11 @@ export const PlanComparisonTable = ({ columns }: Readonly<{ columns: TPlanColumn label: t("workspace.settings.billing.comparison_row_quota"), values: [false, false, true], }, + { + type: "feature", + label: t("workspace.settings.billing.comparison_row_workflows"), + values: [false, false, true], + }, { type: "feature", label: t("workspace.settings.billing.comparison_row_unify_feedback"), diff --git a/apps/web/modules/ee/billing/components/pricing-table.tsx b/apps/web/modules/ee/billing/components/pricing-table.tsx index 7a72809ac636..fcb05173f54c 100644 --- a/apps/web/modules/ee/billing/components/pricing-table.tsx +++ b/apps/web/modules/ee/billing/components/pricing-table.tsx @@ -592,6 +592,7 @@ export const PricingTable = ({ { type: "text", label: t("workspace.settings.billing.plan_scale_feature_workspaces") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_rbac") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_quota") }, + { type: "text", label: t("workspace.settings.billing.plan_scale_feature_workflows") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_feedback") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_semantic_analysis") }, { type: "text", label: t("workspace.settings.billing.plan_scale_feature_security") }, diff --git a/apps/web/modules/ee/billing/lib/metering.test.ts b/apps/web/modules/ee/billing/lib/metering.test.ts index 352815a84335..a556334e1d64 100644 --- a/apps/web/modules/ee/billing/lib/metering.test.ts +++ b/apps/web/modules/ee/billing/lib/metering.test.ts @@ -118,4 +118,68 @@ describe("metering", () => { "Failed to record Stripe meter event for response_created" ); }); + + describe("recordWorkflowRunCreatedMeterEvent", () => { + test("records a workflow_run_created meter event with timestamp", async () => { + const { recordWorkflowRunCreatedMeterEvent } = await import("./metering"); + + await recordWorkflowRunCreatedMeterEvent({ + stripeCustomerId: "cus_1", + workflowRunId: "wr_1", + createdAt: new Date("2026-01-01T00:00:00Z"), + }); + + expect(mocks.meterEventsCreate).toHaveBeenCalledWith({ + event_name: "workflow_run_created", + identifier: "workflow_run_created:wr_1", + timestamp: Math.floor(new Date("2026-01-01T00:00:00Z").getTime() / 1000), + payload: { stripe_customer_id: "cus_1", value: "1" }, + }); + }); + + test("omits timestamp when createdAt is null", async () => { + const { recordWorkflowRunCreatedMeterEvent } = await import("./metering"); + + await recordWorkflowRunCreatedMeterEvent({ + stripeCustomerId: "cus_1", + workflowRunId: "wr_2", + createdAt: null, + }); + + expect(mocks.meterEventsCreate).toHaveBeenCalledWith({ + event_name: "workflow_run_created", + identifier: "workflow_run_created:wr_2", + payload: { stripe_customer_id: "cus_1", value: "1" }, + }); + }); + + test("skips when stripeCustomerId is null", async () => { + const { recordWorkflowRunCreatedMeterEvent } = await import("./metering"); + + await recordWorkflowRunCreatedMeterEvent({ stripeCustomerId: null, workflowRunId: "wr_3" }); + + expect(mocks.meterEventsCreate).not.toHaveBeenCalled(); + }); + + test("skips when not cloud", async () => { + mocks.isCloud = false; + const { recordWorkflowRunCreatedMeterEvent } = await import("./metering"); + + await recordWorkflowRunCreatedMeterEvent({ stripeCustomerId: "cus_1", workflowRunId: "wr_4" }); + + expect(mocks.meterEventsCreate).not.toHaveBeenCalled(); + }); + + test("logs warning on error", async () => { + mocks.meterEventsCreate.mockRejectedValue(new Error("stripe error")); + const { recordWorkflowRunCreatedMeterEvent } = await import("./metering"); + + await recordWorkflowRunCreatedMeterEvent({ stripeCustomerId: "cus_1", workflowRunId: "wr_5" }); + + expect(mocks.loggerWarn).toHaveBeenCalledWith( + { error: expect.any(Error), stripeCustomerId: "cus_1", workflowRunId: "wr_5" }, + "Failed to record Stripe meter event for workflow_run_created" + ); + }); + }); }); diff --git a/apps/web/modules/ee/billing/lib/metering.ts b/apps/web/modules/ee/billing/lib/metering.ts index 7f35185d09de..0d7e81ec1bbc 100644 --- a/apps/web/modules/ee/billing/lib/metering.ts +++ b/apps/web/modules/ee/billing/lib/metering.ts @@ -35,3 +35,41 @@ export const recordResponseCreatedMeterEvent = async (input: { } } }; + +// Metered like responses (ENG-1936): one meter event per created workflow run. Stripe aggregates +// events against the workflow-runs meter per customer per billing period and bills the overage +// beyond the plan's included volume — there is no hard cap here, matching response metering. Dry +// runs are never metered (the caller excludes them). Cloud-only, fire-and-forget: a failed meter +// event is logged but never blocks run creation. +export const recordWorkflowRunCreatedMeterEvent = async (input: { + stripeCustomerId: string | null | undefined; + workflowRunId: string; + createdAt?: Date | string | null; +}): Promise => { + if (IS_FORMBRICKS_CLOUD && stripeClient && input.stripeCustomerId) { + try { + let createdAtSeconds: number | undefined; + + if (input.createdAt instanceof Date || typeof input.createdAt === "string") { + createdAtSeconds = Math.floor(new Date(input.createdAt).getTime() / 1000); + } + + await stripeClient.billing.meterEvents.create({ + event_name: "workflow_run_created", + identifier: `workflow_run_created:${input.workflowRunId}`, + ...(typeof createdAtSeconds === "number" && Number.isFinite(createdAtSeconds) + ? { timestamp: createdAtSeconds } + : {}), + payload: { + stripe_customer_id: input.stripeCustomerId, + value: "1", + }, + }); + } catch (error) { + logger.warn( + { error, stripeCustomerId: input.stripeCustomerId, workflowRunId: input.workflowRunId }, + "Failed to record Stripe meter event for workflow_run_created" + ); + } + } +}; diff --git a/apps/web/modules/ee/billing/lib/organization-billing.test.ts b/apps/web/modules/ee/billing/lib/organization-billing.test.ts index a3686dc5c3d7..117f870c6438 100644 --- a/apps/web/modules/ee/billing/lib/organization-billing.test.ts +++ b/apps/web/modules/ee/billing/lib/organization-billing.test.ts @@ -758,6 +758,7 @@ describe("organization-billing", () => { data: [ { id: "ent_0", lookup_key: "workspace-limit-5" }, { id: "ent_00", lookup_key: "responses-included-2000" }, + { id: "ent_000", lookup_key: "workflow-runs-included-1000" }, { id: "ent_1", lookup_key: "custom-links-in-surveys" }, { id: "ent_2", lookup_key: "custom-links-in-surveys" }, { id: "ent_3", lookup_key: null }, @@ -775,12 +776,18 @@ describe("organization-billing", () => { workspaces: 5, monthly: { responses: 2000, + workflowRuns: 1000, }, }, stripe: expect.objectContaining({ plan: "pro", subscriptionId: "sub_1", - features: ["workspace-limit-5", "responses-included-2000", "custom-links-in-surveys"], + features: [ + "workspace-limit-5", + "responses-included-2000", + "workflow-runs-included-1000", + "custom-links-in-surveys", + ], lastSyncedEventId: "evt_new", lastStripeEventCreatedAt: expect.any(String), lastSyncedAt: expect.any(String), @@ -792,11 +799,74 @@ describe("organization-billing", () => { expect(result?.stripe?.features).toEqual([ "workspace-limit-5", "responses-included-2000", + "workflow-runs-included-1000", "custom-links-in-surveys", ]); + expect(result?.limits?.monthly?.workflowRuns).toBe(1000); expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]); }); + test("syncOrganizationBillingFromStripe clears a stale workflowRuns limit when the entitlement is absent (downgrade is not sticky)", async () => { + // Previous limits carry the Scale-era included volume; the new subscription has no + // workflow-runs entitlement (absence is the normal state on plans without workflows). + mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({ + stripeCustomerId: "cus_1", + limits: { + workspaces: 3, + monthly: { + responses: 1500, + workflowRuns: 1000, + }, + }, + usageCycleAnchor: new Date(), + stripe: { lastSyncedEventId: null }, + }); + mocks.subscriptionsList.mockResolvedValue({ + data: [ + { + id: "sub_1", + status: "active", + billing_cycle_anchor: 1739923200, + items: { + data: [ + { + price: { + metadata: {}, + product: { id: "prod_pro", metadata: { formbricks_plan: "pro" } }, + recurring: { usage_type: "licensed", interval: "year" }, + }, + }, + ], + }, + }, + ], + }); + mocks.entitlementsList.mockResolvedValue({ + data: [ + { id: "ent_0", lookup_key: "workspace-limit-5" }, + { id: "ent_00", lookup_key: "responses-included-2000" }, + ], + has_more: false, + }); + + const result = await syncOrganizationBillingFromStripe("org_1", { id: "evt_new", created: 1739923300 }); + + expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + limits: { + workspaces: 5, + monthly: { + responses: 2000, + workflowRuns: null, + }, + }, + }), + }) + ); + expect(result?.limits?.monthly?.workflowRuns).toBeNull(); + }); + test("createPaidPlanCheckoutSession rejects mixed-interval yearly checkout", async () => { await expect( createPaidPlanCheckoutSession({ @@ -1948,6 +2018,7 @@ describe("organization-billing", () => { workspaces: 5, monthly: { responses: null, + workflowRuns: null, }, }, stripe: expect.objectContaining({ @@ -2036,6 +2107,7 @@ describe("organization-billing", () => { workspaces: 3, monthly: { responses: 1500, + workflowRuns: null, }, }, stripe: expect.objectContaining({ diff --git a/apps/web/modules/ee/billing/lib/organization-billing.ts b/apps/web/modules/ee/billing/lib/organization-billing.ts index 098d828d9182..2eebad9fe882 100644 --- a/apps/web/modules/ee/billing/lib/organization-billing.ts +++ b/apps/web/modules/ee/billing/lib/organization-billing.ts @@ -71,6 +71,9 @@ export const getDefaultOrganizationBilling = (): TOrganizationBilling => ({ workspaces: IS_FORMBRICKS_CLOUD ? 1 : 3, monthly: { responses: IS_FORMBRICKS_CLOUD ? 250 : 1500, + // No included workflow runs by default — the Scale entitlement grants the volume, and + // self-hosted gates workflows by the boolean license feature rather than metering. + workflowRuns: null, }, }, stripeCustomerId: null, @@ -1326,6 +1329,10 @@ const resolveEntitlementDrivenLimits = ( ) => { const workspaceLimitFromEntitlements = parseEntitlementLimit(featureLookupKeys, "workspace-limit-"); const responsesIncludedFromEntitlements = parseEntitlementLimit(featureLookupKeys, "responses-included-"); + const workflowRunsIncludedFromEntitlements = parseEntitlementLimit( + featureLookupKeys, + "workflow-runs-included-" + ); const workspacesLimit = workspaceLimitFromEntitlements === undefined @@ -1351,10 +1358,18 @@ const resolveEntitlementDrivenLimits = ( ); } + // Absent workflow-runs entitlement resolves to null, NOT the previous value: unlike workspaces/ + // responses (present on every plan, so absence signals a bad read worth preserving against), this + // entitlement only exists on plans with workflows — absence is the normal state, and preserving + // would keep a stale included volume forever after a downgrade. A transient bad read self-heals + // on the next sync; `unlimited` still parses to null upstream. + const workflowRunsIncludedLimit = workflowRunsIncludedFromEntitlements ?? null; + return { workspaces: workspacesLimit, monthly: { responses: responsesIncludedLimit, + workflowRuns: workflowRunsIncludedLimit, }, }; }; diff --git a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts index 07f5aada817d..779d9c7dc19e 100644 --- a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts +++ b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.test.ts @@ -197,6 +197,53 @@ describe("stripe-billing-catalog", () => { TEST_TIMEOUT_MS ); + test( + "excludes a metered price tagged with a non-responses kind (e.g. workflow_runs) so it does not collide with responses", + async () => { + // A second metered price on Scale, tagged workflow_runs (ENG-1936). Without the getPriceKind + // guard it would fall through usage_type=metered -> "responses" and produce two matches for + // scale/responses/monthly, breaking the billing page. + const workflowRunsPrice = { + id: "price_scale_workflow_runs", + active: true, + currency: "usd", + unit_amount: 0, + tiers: RESPONSE_PRICE_TIERS, + tiers_mode: "graduated", + metadata: { + formbricks_plan: "scale", + formbricks_price_kind: "workflow_runs", + formbricks_interval: "monthly", + }, + recurring: { usage_type: "metered", interval: "month" }, + product: { id: "prod_scale", active: true, metadata: { formbricks_plan: "scale" } }, + }; + + mocks.pricesList.mockResolvedValue({ + data: [ + createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }), + createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }), + createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }), + createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }), + createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }), + createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }), + createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }), + workflowRunsPrice, + ], + has_more: false, + }); + + const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog"); + + // Resolves without throwing "found 2", and the workflow_runs price is not part of the catalog. + await expect(getCatalogItemsForPlan("scale", "monthly")).resolves.toEqual([ + { price: "price_scale_monthly", quantity: 1 }, + { price: "price_scale_responses" }, + ]); + }, + TEST_TIMEOUT_MS + ); + test( "fails fast when the catalog is incomplete", async () => { diff --git a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts index abf499f291a9..23912d516194 100644 --- a/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts +++ b/apps/web/modules/ee/billing/lib/stripe-billing-catalog.ts @@ -118,6 +118,14 @@ const getPriceKind = (price: Stripe.Price): TStripePriceKind | null => { return metadataKind; } + // A metered price explicitly tagged with a different kind (e.g. "workflow_runs") is a separate + // metered product, not part of the base/responses plan catalog. Exclude it here so the usage_type + // fallback below can't misclassify it as "responses" and collide with the real responses price on + // the same plan/interval (ENG-1936). Full catalog wiring for such kinds is added separately. + if (metadataKind) { + return null; + } + if (price.recurring?.usage_type === "licensed") { return "base"; } diff --git a/apps/web/modules/ee/license-check/lib/utils.test.ts b/apps/web/modules/ee/license-check/lib/utils.test.ts index ed1513c70fc7..c3c5d5463354 100644 --- a/apps/web/modules/ee/license-check/lib/utils.test.ts +++ b/apps/web/modules/ee/license-check/lib/utils.test.ts @@ -21,6 +21,7 @@ import { getIsSpamProtectionEnabled, getIsSsoEnabled, getIsTwoFactorAuthEnabled, + getIsWorkflowsEnabled, getOrganizationWorkspacesLimit, getRemoveBrandingPermission, getWhiteLabelPermission, @@ -84,6 +85,7 @@ const defaultEntitlementsContext: TOrganizationEntitlementsContext = { limits: { workspaces: 3, monthlyResponses: null, + monthlyWorkflowRuns: null, }, licenseStatus: "active", licenseFeatures: defaultFeatures, @@ -299,7 +301,21 @@ describe("License Utils", () => { expect(getEnterpriseLicense).not.toHaveBeenCalled(); }); - test("returns self-hosted FRD / dashboards from license", async () => { + test("uses cloud workflows entitlement", async () => { + vi.mocked(constants).IS_FORMBRICKS_CLOUD = true; + vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true); + + const result = await getIsWorkflowsEnabled("org_1"); + + expect(result).toBe(true); + expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith( + "org_1", + CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.WORKFLOWS + ); + expect(getEnterpriseLicense).not.toHaveBeenCalled(); + }); + + test("returns self-hosted FRD / dashboards / workflows from license", async () => { vi.mocked(constants).IS_FORMBRICKS_CLOUD = false; vi.mocked(getEnterpriseLicense).mockResolvedValue({ ...defaultLicense, @@ -307,33 +323,38 @@ describe("License Utils", () => { ...defaultFeatures, feedbackDirectories: true, dashboards: true, + workflows: true, }, }); - const [frd, dashboards] = await Promise.all([ + const [frd, dashboards, workflows] = await Promise.all([ getIsFeedbackDirectoriesEnabled("org_1"), getIsDashboardsEnabled("org_1"), + getIsWorkflowsEnabled("org_1"), ]); expect(frd).toBe(true); expect(dashboards).toBe(true); + expect(workflows).toBe(true); expect(hasOrganizationEntitlementWithLicenseGuard).not.toHaveBeenCalled(); }); - test("returns false for self-hosted FRD / dashboards when not enabled", async () => { + test("returns false for self-hosted FRD / dashboards / workflows when not enabled", async () => { vi.mocked(constants).IS_FORMBRICKS_CLOUD = false; vi.mocked(getEnterpriseLicense).mockResolvedValue({ ...defaultLicense, features: defaultFeatures, }); - const [frd, dashboards] = await Promise.all([ + const [frd, dashboards, workflows] = await Promise.all([ getIsFeedbackDirectoriesEnabled("org_1"), getIsDashboardsEnabled("org_1"), + getIsWorkflowsEnabled("org_1"), ]); expect(frd).toBe(false); expect(dashboards).toBe(false); + expect(workflows).toBe(false); expect(hasOrganizationEntitlementWithLicenseGuard).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/modules/ee/license-check/lib/utils.ts b/apps/web/modules/ee/license-check/lib/utils.ts index 9c9940d7137f..9c21170e72f2 100644 --- a/apps/web/modules/ee/license-check/lib/utils.ts +++ b/apps/web/modules/ee/license-check/lib/utils.ts @@ -31,7 +31,13 @@ const getCustomPlanFeaturePermission = async ( organizationId: string, featureKey: keyof Pick< TEnterpriseLicenseFeatures, - "accessControl" | "quotas" | "contacts" | "aiSmartTools" | "feedbackDirectories" | "dashboards" + | "accessControl" + | "quotas" + | "contacts" + | "aiSmartTools" + | "feedbackDirectories" + | "dashboards" + | "workflows" > ): Promise => { if (IS_FORMBRICKS_CLOUD) { @@ -42,6 +48,7 @@ const getCustomPlanFeaturePermission = async ( aiSmartTools: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS, feedbackDirectories: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.FEEDBACK_DIRECTORIES, dashboards: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.DASHBOARDS, + workflows: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.WORKFLOWS, }; const lookupKey = featureLookupKeyMap[featureKey]; if (lookupKey) { @@ -159,6 +166,10 @@ export const getIsDashboardsEnabled = async (organizationId: string): Promise => { + return getCustomPlanFeaturePermission(organizationId, "workflows"); +}; + export const getBulkInvitePermission = async (organizationId: string): Promise => { // Bulk invite is gated only on Formbricks Cloud (anti-spam, multi-tenant concern). Self-hosted // keeps the original unrestricted behavior for every tier, including community. diff --git a/apps/web/modules/ee/workflows/components/canvas/add-button-edge.tsx b/apps/web/modules/ee/workflows/components/canvas/add-button-edge.tsx new file mode 100644 index 000000000000..e5b9208e28bd --- /dev/null +++ b/apps/web/modules/ee/workflows/components/canvas/add-button-edge.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { BaseEdge, type EdgeProps, getStraightPath } from "@xyflow/react"; + +// The custom edge currently only renders the connector line. Inserting nodes mid-chain isn't +// supported yet — we cap workflows at one action after the trigger — so the `+` affordance +// lives on the trigger card itself (see workflow-canvas-node.tsx). When complex multi-step +// flows land, this is where the mid-edge insertion control should come back. +export const AddButtonEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + markerEnd, + style, +}: Readonly) => { + const [edgePath] = getStraightPath({ sourceX, sourceY, targetX, targetY }); + + return ( + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/canvas/canvas-controls.tsx b/apps/web/modules/ee/workflows/components/canvas/canvas-controls.tsx new file mode 100644 index 000000000000..97933726eb72 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/canvas/canvas-controls.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useReactFlow } from "@xyflow/react"; +import { + HandIcon, + type LucideIcon, + MousePointerClickIcon, + WandSparklesIcon, + ZoomInIcon, + ZoomOutIcon, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button, type ButtonProps } from "@/modules/ui/components/button"; +import { TooltipRenderer } from "@/modules/ui/components/tooltip"; + +interface CanvasControlsProps { + canMutate: boolean; + /** Pan mode = pan/browse only (nodes inert); pointer mode = select/inspect nodes, edit when permitted. */ + isPanMode: boolean; + onAutoLayout: () => void; + onPanMode: () => void; + onPointerMode: () => void; +} + +interface ControlDescriptor { + key: string; + Icon: LucideIcon; + // Resolved label string — labels are translated via inline `t("…")` calls in the descriptor + // list below so `pnpm scan-translations` can statically detect every key. Indirecting through + // a variable key (e.g. `t(labelKey)`) silently breaks the scanner. + label: string; + variant: ButtonProps["variant"]; + ariaPressed?: boolean; + disabled?: boolean; + onClick: () => void; +} + +export const CanvasControls = ({ + canMutate, + isPanMode, + onAutoLayout, + onPanMode, + onPointerMode, +}: Readonly) => { + const { t } = useTranslation(); + const { zoomIn, zoomOut } = useReactFlow(); + + const controls: ControlDescriptor[] = [ + { + key: "zoom-in", + Icon: ZoomInIcon, + label: t("workspace.workflows.zoom_in"), + variant: "outline", + onClick: () => zoomIn(), + }, + { + key: "zoom-out", + Icon: ZoomOutIcon, + label: t("workspace.workflows.zoom_out"), + variant: "outline", + onClick: () => zoomOut(), + }, + { + key: "pan-mode", + Icon: HandIcon, + label: t("workspace.workflows.pan_mode"), + variant: isPanMode ? "default" : "outline", + ariaPressed: isPanMode, + onClick: onPanMode, + }, + { + key: "pointer-mode", + Icon: MousePointerClickIcon, + label: t("workspace.workflows.pointer_mode"), + variant: isPanMode ? "outline" : "default", + ariaPressed: !isPanMode, + onClick: onPointerMode, + }, + { + key: "auto-layout", + Icon: WandSparklesIcon, + label: t("workspace.workflows.auto_layout"), + variant: "outline", + disabled: !canMutate, + onClick: onAutoLayout, + }, + ]; + + return ( +
+
+ {controls.map(({ key, Icon, label, variant, ariaPressed, disabled, onClick }) => ( + + + + ))} +
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-add-trigger-picker.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-add-trigger-picker.tsx new file mode 100644 index 000000000000..7cce6e96d3b6 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/canvas/workflow-add-trigger-picker.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { type LucideIcon, PlusIcon, ZapIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { type TWorkflowTriggerType, WORKFLOW_TRIGGERS } from "@formbricks/workflows"; +import { cn } from "@/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; +import { CATEGORY_CHIP_CLASS_NAMES } from "./workflow-canvas-node"; + +interface WorkflowAddTriggerPickerProps { + onSelect: (triggerType: TWorkflowTriggerType) => void; +} + +interface TriggerOption { + triggerType: TWorkflowTriggerType; + icon: LucideIcon; + label: string; + description: string; +} + +// The empty-canvas starting point: a card styled like the canvas nodes whose click opens a +// popover listing the available triggers. New drafts have no nodes, so this is the only +// affordance on the canvas until a trigger is chosen. +export const WorkflowAddTriggerPicker = ({ onSelect }: Readonly) => { + const { t } = useTranslation(); + + const options: TriggerOption[] = [ + { + triggerType: WORKFLOW_TRIGGERS.RESPONSE_COMPLETED, + icon: ZapIcon, + label: t("workspace.workflows.response_completed"), + description: t("workspace.workflows.response_completed_description"), + }, + ]; + + return ( + + + + + + {t("workspace.workflows.triggers")} + + {options.map((option) => ( + onSelect(option.triggerType)}> + + + + {option.label} + {option.description} + + + ))} + + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-canvas-node.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas-node.tsx new file mode 100644 index 000000000000..852899c68585 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas-node.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + GitBranchIcon, + type LucideIcon, + MailIcon, + MoreVerticalIcon, + PlusIcon, + Trash2Icon, + TriangleAlertIcon, + ZapIcon, +} from "lucide-react"; +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + type TWorkflowNodeData, + type TWorkflowNodeIcon, + appendSendEmailAfterNodeAtom, + canMutateCanvasAtom, + deleteWorkflowNodeAtom, +} from "@/modules/ee/workflows/state/editor"; +import { Button } from "@/modules/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; + +const NODE_ICONS: Record = { + trigger: ZapIcon, + ifElse: GitBranchIcon, + email: MailIcon, +}; + +// Shared with the add-trigger / add-action pickers so selector icons carry the same category +// colors as the canvas cards. +export const CATEGORY_CHIP_CLASS_NAMES: Record = { + trigger: "bg-indigo-500 text-white", + flow: "bg-purple-500 text-white", + action: "bg-green-600 text-white", +}; + +const HANDLE_CLASS_NAMES = "!h-0 !w-0 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0"; + +export const WorkflowCanvasNode = memo( + ({ id, data, selected }: Readonly>>) => { + const { t } = useTranslation(); + const deleteNode = useSetAtom(deleteWorkflowNodeAtom); + const appendSendEmail = useSetAtom(appendSendEmailAfterNodeAtom); + const canMutate = useAtomValue(canMutateCanvasAtom); + const Icon = NODE_ICONS[data.icon]; + const isTrigger = data.category === "trigger"; + + return ( +
+
+ + + + +
+ + {data.title} + + {data.issue ? ( + // The reason travels with the highlight — replaces the summary so the user never + // has to guess why a card is flagged. + + + ) : ( + {data.summary} + )} +
+ {!isTrigger && canMutate && ( + + + + + + { + event.stopPropagation(); + deleteNode(id); + }}> + + {t("common.delete")} + + + + )} + +
+ {isTrigger && data.isLeaf && canMutate && ( + // Only the trigger gets an inline `+` — workflows are currently capped at one action + // after the trigger, so neither the trailing send_email card nor mid-chain edges + // should advertise the affordance. The `+` opens an action picker instead of + // appending a default node, so the user consciously chooses each step. +
+
+ + + + + + {t("common.actions")} + + { + event.stopPropagation(); + appendSendEmail(id); + }}> + + + + {t("workspace.workflows.send_email")} + + {t("workspace.workflows.send_email_description")} + + + + + +
+ )} +
+ ); + } +); + +WorkflowCanvasNode.displayName = "WorkflowCanvasNode"; diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.css b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.css new file mode 100644 index 000000000000..a677ddd9ce7a --- /dev/null +++ b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.css @@ -0,0 +1,20 @@ +/* Cursor and hit-testing overrides on top of @xyflow/react/dist/style.css, scoped via the + .workflow-canvas class on the ReactFlow root. Selectors repeat xyflow's own classes to win + on specificity regardless of stylesheet order. */ + +/* Pointer mode: nodes are primarily click targets (a click opens the config panel), so show + the default arrow instead of xyflow's grab/pointer, and only switch to grabbing mid-drag. */ +.workflow-canvas .react-flow__node.selectable, +.workflow-canvas .react-flow__node.draggable { + cursor: default; +} + +.workflow-canvas .react-flow__node.draggable.dragging { + cursor: grabbing; +} + +/* Pan mode: nodes are inert, so let pointer events fall through to the pane. Panning then + works even when the drag starts on a node, and the pane's grab cursor shows everywhere. */ +.workflow-canvas.pan-mode .react-flow__node { + pointer-events: none; +} diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.tsx new file mode 100644 index 000000000000..f5aa611091bf --- /dev/null +++ b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { + Background, + BackgroundVariant, + type EdgeTypes, + type Node, + type NodeTypes, + type OnNodesChange, + ReactFlow, + ReactFlowProvider, + applyNodeChanges, + useReactFlow, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { useAtomValue, useSetAtom } from "jotai"; +import { PanelLeftIcon, PanelRightOpenIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + WORKFLOW_CANVAS_NODE_TYPE, + WORKFLOW_CANVAS_SNAP_GRID, + reorganizeWorkflowDefinition, + snapWorkflowNodePosition, + updateNodePosition, + workflowDefinitionToFlowEdges, + workflowDefinitionToFlowNodes, +} from "@/modules/ee/workflows/lib/definition-to-flow"; +import { + type TWorkflowNodeData, + addWorkflowTriggerAtom, + closeWorkflowNodeConfigModalAtom, + hasBoundTriggerSurveyAtom, + isCanvasLockedAtom, + isWorkflowInspectorCollapsedAtom, + isWorkflowNodeConfigModalOpenAtom, + isWorkflowSnapToCanvasEnabledAtom, + openWorkflowNodeConfigModalAtom, + setWorkflowDefinitionAtom, + toggleWorkflowInspectorAtom, + workflowAtom, + workflowDefinitionAtom, + workflowFlowNodesAtom, +} from "@/modules/ee/workflows/state/editor"; +import { Button } from "@/modules/ui/components/button"; +import { AddButtonEdge } from "./add-button-edge"; +import { CanvasControls } from "./canvas-controls"; +import { WorkflowAddTriggerPicker } from "./workflow-add-trigger-picker"; +import { WorkflowCanvasNode } from "./workflow-canvas-node"; +import "./workflow-canvas.css"; +import { WorkflowValidationStatus } from "./workflow-validation-status"; + +const NODE_TYPES: NodeTypes = { + [WORKFLOW_CANVAS_NODE_TYPE]: WorkflowCanvasNode, +}; + +const EDGE_TYPES: EdgeTypes = { + addButton: AddButtonEdge, +}; + +// The canvas is the page's main action — let fitView scale small flows past the former 0.85 cap, +// which rendered a fresh two-node workflow noticeably small. 2x proved too big; 1.15 is three +// zoom-out steps down from it (RF's zoomIn/zoomOut step is 1.2x, and 2 / 1.2^3 ≈ 1.157). +const WORKFLOW_CANVAS_MAX_ZOOM = 1.15; + +// The inspector column animates its width over 150ms (see workflow-inspector-panel.tsx); refit +// only after the canvas has its final size, with a small buffer. +const INSPECTOR_RESIZE_SETTLE_MS = 170; +// Near-instant pan: the refit should feel like part of the sidebar toggle, not a second animation. +const INSPECTOR_REFIT_PAN_MS = INSPECTOR_RESIZE_SETTLE_MS / 4; + +interface WorkflowCanvasProps { + isEditable: boolean; +} + +const WorkflowCanvasContent = ({ isEditable }: Readonly) => { + const { t } = useTranslation(); + const workflow = useAtomValue(workflowAtom); + const definition = useAtomValue(workflowDefinitionAtom); + const flowNodes = useAtomValue(workflowFlowNodesAtom); + const isSnapToCanvasEnabled = useAtomValue(isWorkflowSnapToCanvasEnabledAtom); + const isLocked = useAtomValue(isCanvasLockedAtom); + const setLocked = useSetAtom(isCanvasLockedAtom); + const isInspectorCollapsed = useAtomValue(isWorkflowInspectorCollapsedAtom); + const isNodeConfigOpen = useAtomValue(isWorkflowNodeConfigModalOpenAtom); + const toggleInspector = useSetAtom(toggleWorkflowInspectorAtom); + const closeNodeConfig = useSetAtom(closeWorkflowNodeConfigModalAtom); + const setDefinition = useSetAtom(setWorkflowDefinitionAtom); + const setFlowNodes = useSetAtom(workflowFlowNodesAtom); + const openNodeConfigModal = useSetAtom(openWorkflowNodeConfigModalAtom); + const addTrigger = useSetAtom(addWorkflowTriggerAtom); + const { fitView } = useReactFlow(); + // `isEditable` (canEditDefinition) is the API-side gate. The drag/pointer mode toggle is the + // user-driven gate layered on top: even when permissions allow editing, the canvas stays + // non-mutable until the user switches to pointer mode. + const canMutate = isEditable && !isLocked; + // Shared by the picker overlay and the validation chip: while the centered add-trigger picker + // is the canvas's main event, the chip would only restate it ("1 problem: no trigger"), so the + // two are mutually exclusive by construction. + const isTriggerPickerVisible = Boolean(definition && !definition.trigger && isEditable); + + // Shared flag owned by the builder page (server context OR workspace survey-list membership), + // so a just-picked survey clears the node's setup flag immediately. + const hasBoundSurvey = useAtomValue(hasBoundTriggerSurveyAtom); + // Unloaded state defaults to draft so a fresh page never flashes red before the workflow lands. + const isDraft = workflow ? workflow.status === "draft" : true; + + const derivedFlowNodes = useMemo( + () => (definition ? workflowDefinitionToFlowNodes(definition, t, { hasBoundSurvey, isDraft }) : []), + [definition, t, hasBoundSurvey, isDraft] + ); + const flowEdges = useMemo( + () => (definition ? workflowDefinitionToFlowEdges(definition) : []), + [definition] + ); + + // Keep ReactFlow's nodes in sync with the projected definition while preserving the user's + // current selection — recomputing from scratch would lose it on every definition edit. + useEffect(() => { + setFlowNodes((currentNodes) => { + const currentNodesById = new Map(currentNodes.map((node) => [node.id, node])); + + return derivedFlowNodes.map((node) => ({ + ...node, + selected: currentNodesById.get(node.id)?.selected ?? node.selected, + })); + }); + }, [derivedFlowNodes, setFlowNodes]); + + const handleNodesChange: OnNodesChange> = useCallback( + (changes) => setFlowNodes((currentNodes) => applyNodeChanges(changes, currentNodes)), + [setFlowNodes] + ); + + const handleNodeDragStop = useCallback( + (node: Node) => { + if (!canMutate) return; + + const position = isSnapToCanvasEnabled ? snapWorkflowNodePosition(node.position) : node.position; + setDefinition((currentDefinition) => + currentDefinition ? updateNodePosition(currentDefinition, node.id, position) : currentDefinition + ); + }, + [canMutate, isSnapToCanvasEnabled, setDefinition] + ); + + // Opening/closing the inspector resizes the canvas by 360px, which can push nodes behind the + // panel edge. Refit the VIEWPORT once the width transition settles — fitView only pans/zooms; + // node coordinates are untouched. + const isInspectorVisible = isNodeConfigOpen && !isInspectorCollapsed; + const previousInspectorVisibleRef = useRef(isInspectorVisible); + useEffect(() => { + if (previousInspectorVisibleRef.current === isInspectorVisible) return; + previousInspectorVisibleRef.current = isInspectorVisible; + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const timeoutHandle = setTimeout(() => { + void fitView({ + padding: 0.25, + maxZoom: WORKFLOW_CANVAS_MAX_ZOOM, + minZoom: 0.4, + duration: prefersReducedMotion ? 0 : INSPECTOR_REFIT_PAN_MS, + }); + }, INSPECTOR_RESIZE_SETTLE_MS / 2); + return () => clearTimeout(timeoutHandle); + }, [isInspectorVisible, fitView]); + + const handleAutoLayout = useCallback(() => { + if (!canMutate) return; + setDefinition((currentDefinition) => + currentDefinition ? reorganizeWorkflowDefinition(currentDefinition) : currentDefinition + ); + // Skip the animated recenter for users who ask for reduced motion (read at call time — + // no reactivity needed for a click handler). + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + // Defer one frame so the new node positions render before RF recenters the viewport. + requestAnimationFrame(() => + fitView({ + padding: 0.25, + maxZoom: WORKFLOW_CANVAS_MAX_ZOOM, + minZoom: 0.4, + duration: prefersReducedMotion ? 0 : 300, + }) + ); + }, [canMutate, setDefinition, fitView]); + + // Pan mode is the pan/browse tool: nodes are fully inert (no click, selection, or drag), so + // switching into it also clears any leftover selection. Pointer mode is the select/inspect + // tool and is available to everyone — mutations stay gated by status/permissions (canMutate). + const handlePanMode = () => { + setLocked(true); + setFlowNodes((currentNodes) => + currentNodes.map((node) => (node.selected ? { ...node, selected: false } : node)) + ); + }; + + const handlePointerMode = () => setLocked(false); + + return ( +
+ {/* The inspector only ever shows a node's config now, so the collapse toggle is only + offered while one is open. */} + {isNodeConfigOpen ? ( +
+ +
+ ) : null} + handleNodeDragStop(node)} + onNodeClick={(_event, node) => { + if (!isLocked) openNodeConfigModal(node.id); + }} + // Clicking empty canvas deselects (ReactFlow) and dismisses the node inspector — with + // Settings gone this is the natural way out of a node's config view. + onPaneClick={() => closeNodeConfig()} + // Mode-dependent cursor + hit-testing rules live in workflow-canvas.css, keyed off + // these classes (Tailwind can't express `.react-flow__node` — underscores in arbitrary + // selectors turn into spaces). + className={cn("workflow-canvas bg-slate-50", isLocked && "pan-mode")} + fitView + fitViewOptions={{ padding: 0.25, maxZoom: WORKFLOW_CANVAS_MAX_ZOOM, minZoom: 0.4 }} + defaultViewport={{ x: 0, y: 0, zoom: WORKFLOW_CANVAS_MAX_ZOOM }} + nodesDraggable={canMutate} + nodesConnectable={false} + snapGrid={WORKFLOW_CANVAS_SNAP_GRID} + snapToGrid={isSnapToCanvasEnabled} + proOptions={{ hideAttribution: true }} + elementsSelectable={!isLocked} + nodesFocusable={!isLocked}> + {/* Same dot grid the pre-ReactFlow mockup used: radial-gradient(#cbd5e1 1px) on an 18px grid. */} + + + {/* Empty drafts start with no nodes: the centered picker is how the trigger gets added. + Gated on status-based editability (not the layout lock) — adding nodes is a content + edit, same as the node config forms. */} + {isTriggerPickerVisible && ( +
+
+ +
+
+ )} + + {/* Always-on live validation state (replaces the former manual "Validate" dry-run button): + a passive badge while valid, a button listing the problems while not. Suppressed while + the add-trigger picker is up so a fresh draft isn't greeted with a problem count. */} + {!isTriggerPickerVisible && } +
+ ); +}; + +export const WorkflowCanvas = (props: Readonly) => ( + + + +); diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx new file mode 100644 index 000000000000..64bf21132d5f --- /dev/null +++ b/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { CheckIcon, TriangleAlertIcon } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; +import { + deriveTriggerEndingProblems, + workflowAtom, + workflowDefinitionAtom, + workflowValidationProblemsAtom, +} from "@/modules/ee/workflows/state/editor"; +import { WorkflowValidationProblemsDialog } from "../workflow-validation-problems-dialog"; + +// Same pill geometry as the Badge component (which only renders plain text, hence hand-rolled +// here: both states carry an icon and the invalid one is a real button). +const PILL_CLASS_NAME = + "inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium"; + +/** + * Always-on validation state for the loaded workflow, floating in the canvas's bottom-right + * corner (the canvas controls own bottom-center). Valid renders as a passive badge; problems + * render as a real button that opens the problems dialog. There is no manual trigger — the + * problem list recomputes live on every edit via workflowValidationProblemsAtom. + */ +export const WorkflowValidationStatus = () => { + const { t } = useTranslation(); + const workflow = useAtomValue(workflowAtom); + const definition = useAtomValue(workflowDefinitionAtom); + const atomProblems = useAtomValue(workflowValidationProblemsAtom); + const [isProblemsDialogOpen, setIsProblemsDialogOpen] = useState(false); + + // The ending-cards check is the one readiness rule that needs server data (the bound survey's + // current endings), so it is merged here at the component level — query results stay in the + // TanStack cache, never mirrored into Jotai. Consequence: the header's Enable gate + // (workflowValidityAtom.isReady) deliberately excludes ending problems; the server's enable + // pre-flight still rejects stale endings. + const trigger = definition?.trigger ?? null; + const endingsQuery = useWorkflowSurveyEndings(trigger?.config.surveyId ?? null); + // Only a successfully RESOLVED endings list may flag a problem. While the query is loading, + // disabled (no trigger/survey), or errored (e.g. a read-only viewer's 403, an unbound survey's + // 404), the check is skipped silently — unknown must never render as an error. + const endingProblems = + trigger && endingsQuery.isSuccess + ? deriveTriggerEndingProblems( + trigger.config.endingCardIds, + endingsQuery.endings.map((ending) => ending.id) + ) + : []; + const problems = [...atomProblems, ...endingProblems]; + + // Nothing to report until the editor is hydrated. + if (!workflow) return null; + + return ( + // Polite live region so validity flips are announced without interrupting the user's editing. +
+ {problems.length === 0 ? ( + // Deliberately quiet: valid is the steady state, so the pill matches the neutral canvas + // chrome and only the check carries a soft green accent — attention belongs to problems. + + + ) : ( + // Problems while drafting are unfinished setup, not failure — amber (the Badge "warning" + // tokens), never red, and "problems" (the dialog's own word), never "errors". + + )} + +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx new file mode 100644 index 000000000000..06cbecb27fdc --- /dev/null +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { ArrowRightIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + type TWorkflowSendEmailActionNode, + type TWorkflowSendEmailContentField, + getBlankSendEmailContentFields, +} from "@formbricks/workflows"; +import { WorkflowEmailRecipientField } from "@/modules/ee/workflows/components/inspector/workflow-email-recipient-field"; +import { + WorkflowFieldError, + WorkflowFieldLabel, +} from "@/modules/ee/workflows/components/inspector/workflow-field"; +import { useWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/components/workflow-email-authoring-context"; +import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus"; +import { resolveBoundTriggerSurvey } from "@/modules/ee/workflows/lib/bound-survey"; +import { openWorkflowNodeConfigModalAtom, workflowDefinitionAtom } from "@/modules/ee/workflows/state/editor"; +import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input"; +import { + type EmailSendToOption, + buildEmailSendToOptions, +} from "@/modules/survey/follow-ups/lib/email-send-to-options"; +import { Button } from "@/modules/ui/components/button"; +import { Editor } from "@/modules/ui/components/editor"; +import { Input } from "@/modules/ui/components/input"; +import { Label } from "@/modules/ui/components/label"; +import { Switch } from "@/modules/ui/components/switch"; + +interface WorkflowEmailActionFormProps { + node: TWorkflowSendEmailActionNode; + isEditable: boolean; + onChange: (next: TWorkflowSendEmailActionNode) => void; +} + +// The internal "default language" slot recall/headline resolution uses when no language is selected. +const DEFAULT_LANGUAGE_CODE = "default"; + +const HTML_TAG_PATTERN = /<[a-z][\s\S]*>/i; + +const escapeHtml = (value: string): string => + value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + +// The recall Editor loads its initial value as HTML (root can only hold block nodes). Recall-token +// bodies are already `

` HTML and pass through untouched; legacy plain-text bodies (e.g. the +// seed's "Hi there…") are escaped and wrapped in paragraphs so Lexical doesn't crash on a bare text +// node ("Only element or decorator nodes can be inserted to the root node"). +const toEditorHtml = (body: string): string => { + if (!body) return ""; + if (HTML_TAG_PATTERN.test(body)) return body; + return body + .split(/\n{2,}/) + .map((paragraph) => `

${escapeHtml(paragraph).replaceAll("\n", "
")}

`) + .join(""); +}; + +// The DOM ids the focus jump targets. `body` has no input of its own — the Lexical editor owns a +// contenteditable inside its wrapper — so it is focused through a ref instead. +const FIELD_INPUT_IDS: Record, string> = { + to: "workflow-email-to", + subject: "workflow-email-subject", +}; + +export const WorkflowEmailActionForm = ({ + node, + isEditable, + onChange, +}: Readonly) => { + const { t } = useTranslation(); + const authoringContext = useWorkflowEmailAuthoringContext(); + const definition = useAtomValue(workflowDefinitionAtom); + const openNodeConfigModal = useSetAtom(openWorkflowNodeConfigModalAtom); + const [firstRender, setFirstRender] = useState(true); + + // Which required fields may show their error. A freshly added node stays clean until the user + // has actually engaged with a field and left it empty (or arrived here from the problems + // dialog); flagging an untouched brand-new node would paint three errors on open. + const [touchedFields, setTouchedFields] = useState>>( + {} + ); + const markTouched = useCallback( + (...fields: TWorkflowSendEmailContentField[]) => + setTouchedFields((current) => + fields.every((field) => current[field]) + ? current + : { ...current, ...Object.fromEntries(fields.map((field) => [field, true as const])) } + ), + [] + ); + + const bodyWrapperRef = useRef(null); + + // Stable identity: EditorContentChecker re-registers its update listener whenever this changes. + // Non-empty is what earns the body its "touched" flag, so typing-then-clearing shows the error + // while a node that arrived empty stays quiet. + const handleBodyEmptyChange = useCallback( + (isEmpty: boolean) => { + if (!isEmpty) markTouched("body"); + }, + [markTouched] + ); + + const updateConfig = (next: Partial) => + onChange({ ...node, config: { ...node.config, ...next } }); + + const invalidFields = new Set( + getBlankSendEmailContentFields(node.config).filter((field) => touchedFields[field]) + ); + + // Arriving from the validation problems dialog: reveal every missing field on this node (the + // point of the jump is to answer "which field is wrong") and focus the one it pointed at. + useWorkflowNodeFieldFocus({ + nodeId: node.id, + onRequest: () => markTouched(...getBlankSendEmailContentFields(node.config)), + resolveElement: (field) => + field === "body" + ? bodyWrapperRef.current?.querySelector('[contenteditable="true"]') + : document.getElementById(FIELD_INPUT_IDS[field as keyof typeof FIELD_INPUT_IDS]), + }); + + const triggerSurveyId = definition?.trigger?.type === "trigger" ? definition.trigger.config.surveyId : null; + const survey = resolveBoundTriggerSurvey(authoringContext, definition); + + // Clear the recipient + body when the trigger's bound survey changes: `config.to` is an element/ + // hidden-field id and `config.body` holds recall tokens, both of which dangle against the previous + // survey's elements. Mirrors how the trigger form clears `endingCardIds` on survey change. Skips the + // initial mount so loading an existing node never wipes its saved values. + const previousTriggerSurveyId = useRef(triggerSurveyId); + useEffect(() => { + if (previousTriggerSurveyId.current === triggerSurveyId) return; + previousTriggerSurveyId.current = triggerSurveyId; + if (node.config.to === "" && node.config.body === "") return; + updateConfig({ to: "", body: "" }); + // These two were wiped out from under the user, so their errors are exactly what they need to + // see — no interaction required to earn them. + markTouched("to", "body"); + // updateConfig/node are intentionally omitted: this reacts to the survey id changing, not to each + // keystroke in to/body (which would clear them mid-edit). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [triggerSurveyId]); + + const emailSendToOptions: EmailSendToOption[] = useMemo(() => { + if (!survey || !authoringContext) return []; + return buildEmailSendToOptions({ + survey, + teamMemberDetails: authoringContext.teamMemberDetails, + userEmail: authoringContext.userEmail, + selectedLanguageCode: DEFAULT_LANGUAGE_CODE, + t, + }); + }, [survey, authoringContext, t]); + + // Without a resolvable bound survey there is nothing meaningful to author — the recipient + // options and recall body both come from the survey. Point the user at the trigger instead of + // rendering degraded plain inputs (and the seed's placeholder values). + if (!survey) { + return ( +
+

{t("workspace.workflows.email_needs_survey")}

+ {definition?.trigger ? ( + + ) : null} +
+ ); + } + + return ( +
+ updateConfig({ to: value })} + // A picked recipient can't be un-picked, so closing the dropdown without choosing is this + // control's only "touched while still empty" moment. + onClose={() => markTouched("to")} + /> + + {/* From (read-only) */} +
+ +

+ {t("workspace.surveys.edit.follow_ups_modal_action_from_description")} +

+
+ {/* The real deployment sender (MAIL_FROM), not `config.from` — parity with Follow-Ups, which + always sends from MAIL_FROM. `config.from` is a vestigial seed value, never the send sender. */} + {authoringContext?.mailFrom ?? node.config.from} +
+
+ + {/* Reply To */} +
+ +

+ {t("workspace.surveys.edit.follow_ups_modal_action_replyTo_description")} +

+ { + const nextReplyTo = typeof update === "function" ? update(node.config.replyTo) : update; + updateConfig({ replyTo: nextReplyTo }); + }} + /> +
+ + {/* Subject */} +
+ + {t("workspace.workflows.email_subject_label")} + + markTouched("subject")} + onChange={(event) => updateConfig({ subject: event.target.value })} + /> + {invalidFields.has("subject") ? ( + + {t("workspace.workflows.email_subject_required")} + + ) : null} +
+ + {/* Body (recall editor) */} + {/* The editor defaults to a 2-line min-height (48px); a 4-line body (24px line-height) + better matches the amount of content an email body usually holds. */} +
+ + {t("workspace.workflows.email_body_label")} + + toEditorHtml(node.config.body)} + setText={(value: string) => updateConfig({ body: value })} + // The editor is the authority on its own emptiness: its serialized value keeps the + // enclosing `

` even when blank, so "had content at some point" is the only reliable + // signal that the user has engaged with this field. + onEmptyChange={handleBodyEmptyChange} + isInvalid={invalidFields.has("body")} + ariaDescribedBy={invalidFields.has("body") ? "workflow-email-body-error" : undefined} + firstRender={firstRender} + setFirstRender={setFirstRender} + editable={isEditable} + placeholder={t("workspace.workflows.email_body_placeholder")} + localSurvey={survey} + elementId={node.id} + selectedLanguageCode={DEFAULT_LANGUAGE_CODE} + /> + {invalidFields.has("body") ? ( + + {t("workspace.workflows.email_body_required")} + + ) : null} +

+ +
+
+ +

+ {t("workspace.workflows.email_attach_response_data_description")} +

+
+ updateConfig({ attachResponseData: checked })} + /> +
+ + {node.config.attachResponseData ? ( +
+
+ + updateConfig({ includeVariables: checked })} + /> +
+
+ + updateConfig({ includeHiddenFields: checked })} + /> +
+
+ ) : null} +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx new file mode 100644 index 000000000000..0919b253512f --- /dev/null +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-email-recipient-field.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { EyeOffIcon, MailIcon, TriangleAlertIcon, UserIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { cn } from "@/lib/cn"; +import { + WorkflowFieldError, + WorkflowFieldLabel, +} from "@/modules/ee/workflows/components/inspector/workflow-field"; +import type { EmailSendToOption } from "@/modules/survey/follow-ups/lib/email-send-to-options"; +import { getElementIconMap } from "@/modules/survey/lib/elements"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; + +const FIELD_ID = "workflow-email-to"; +const ERROR_ID = "workflow-email-to-error"; + +type TElementIconMap = ReturnType; + +// Module scope so each render reuses one function instead of rebuilding a closure per option. +const getSelectItemIcon = (type: EmailSendToOption["type"], elementIconMap: TElementIconMap): ReactNode => { + switch (type) { + case "verifiedEmail": + return ; + case "hiddenField": + return ; + case "user": + return ; + case "openTextElement": + return
{elementIconMap[TSurveyElementTypeEnum.OpenText]}
; + case "contactInfoElement": + return
{elementIconMap[TSurveyElementTypeEnum.ContactInfo]}
; + } +}; + +const renderSelectItem = (option: EmailSendToOption, elementIconMap: TElementIconMap) => ( + +
+ {getSelectItemIcon(option.type, elementIconMap)} + {option.label} +
+
+); + +interface WorkflowEmailRecipientFieldProps { + /** Selectable recipients derived from the trigger's bound survey. Empty = nothing to pick. */ + options: EmailSendToOption[]; + /** The stored `config.to`: an element id, hidden-field id, email, or "verifiedEmail". */ + value: string; + isEditable: boolean; + isInvalid: boolean; + onChange: (value: string) => void; + /** Called when the dropdown closes — this control's only "touched" moment (see the form). */ + onClose: () => void; +} + +/** + * The `send_email` recipient picker: a grouped Select over the bound survey's email-bearing + * elements, hidden fields and the team roster. Split out of `WorkflowEmailActionForm` because it is + * the one field there with real internal structure (option grouping, per-type icons, a no-options + * fallback), and it owns the only icon map and item renderer in that form. + */ +export const WorkflowEmailRecipientField = ({ + options, + value, + isEditable, + isInvalid, + onChange, + onClose, +}: Readonly) => { + const { t } = useTranslation(); + const elementIconMap = getElementIconMap(t); + + const optionsOfType = (...types: EmailSendToOption["type"][]) => + options.filter((option) => types.includes(option.type)); + + // Declared as data so the three groups share one renderer. Verified email and question elements + // deliberately sit under the same "Questions" heading, as in the survey Follow-Ups picker. + const groups = [ + { + label: t("common.questions"), + options: optionsOfType("verifiedEmail", "openTextElement", "contactInfoElement"), + }, + { label: t("common.hidden_fields"), options: optionsOfType("hiddenField") }, + { label: t("common.members"), options: optionsOfType("user") }, + ].filter((group) => group.options.length > 0); + + return ( +
+ + {t("workspace.workflows.email_to_label")} + +

+ {t("workspace.surveys.edit.follow_ups_modal_action_to_description")} +

+ {groups.length > 0 ? ( + + ) : ( +
+
+ )} + {isInvalid ? ( + {t("workspace.workflows.email_to_required")} + ) : null} +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-field.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-field.tsx new file mode 100644 index 000000000000..fedf09a733e9 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-field.tsx @@ -0,0 +1,62 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { Label } from "@/modules/ui/components/label"; + +/** + * Label + inline error pair for the inspector's config forms. The dashboard's `Form` set + * (`FormLabel`/`FormError`) is bound to a react-hook-form context, but the inspector has no form + * submit at all — validity is derived from the workflow definition and autosaved — so these mirror + * its visual contract (`text-red-500` label, `text-error` message) without the RHF dependency. + */ + +interface WorkflowFieldLabelProps { + htmlFor?: string; + /** + * Set when the control can't be reached by `htmlFor` — the rich-text editor owns a + * contenteditable, so it points its own `aria-labelledby` at this id instead. + */ + id?: string; + /** Renders the required marker; the accessible name carries the word, not the glyph. */ + isRequired?: boolean; + isInvalid?: boolean; + children: ReactNode; +} + +export const WorkflowFieldLabel = ({ + htmlFor, + id, + isRequired, + isInvalid, + children, +}: Readonly) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +interface WorkflowFieldErrorProps { + /** Referenced by the control's `aria-describedby` so the message is announced with it. */ + id: string; + children: ReactNode; +} + +export const WorkflowFieldError = ({ id, children }: Readonly) => ( +

+ {children} +

+); diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-inspector-panel.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-inspector-panel.tsx new file mode 100644 index 000000000000..c573d102fab7 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-inspector-panel.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { cn } from "@/lib/cn"; +import { WorkflowNodeConfigPanel } from "@/modules/ee/workflows/components/inspector/workflow-node-config-panel"; +import { + isWorkflowInspectorCollapsedAtom, + isWorkflowNodeConfigModalOpenAtom, +} from "@/modules/ee/workflows/state/editor"; + +interface WorkflowInspectorPanelProps { + isEditingNode: boolean; +} + +// The inspector's only content is the selected node's config: workflow name lives in the +// editable page title and lifecycle actions in the header dropdown, so with no node open the +// column collapses away entirely. +export const WorkflowInspectorPanel = ({ isEditingNode }: Readonly) => { + const isCollapsed = useAtomValue(isWorkflowInspectorCollapsedAtom); + const isNodeConfigOpen = useAtomValue(isWorkflowNodeConfigModalOpenAtom); + const isVisible = isNodeConfigOpen && !isCollapsed; + + return ( + // Stretches to the editor row's height (no `self-start`) so the panel inside can fill it and + // scroll its own content. No bottom padding either: both would make this column taller than the + // canvas beside it and push the page into overflow. +
+ {/* Only mount the fixed-width content while the panel is open. Left mounted when collapsed, + this 360px block lays out off-screen to the right and — even inside the `w-0` + `overflow-hidden` column — inflates the document's scroll width, producing a phantom + horizontal scrollbar (most visible after collapsing the main nav). The content is already + hidden (opacity-0) while collapsed, so gating the mount changes nothing visible. */} + {isVisible && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx new file mode 100644 index 000000000000..62183dd2cc7b --- /dev/null +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import type { TWorkflowDefinition, TWorkflowNode } from "@formbricks/workflows"; +import { getNodeRegistryEntry } from "@/modules/ee/workflows/lib/node-registry"; +import { + selectedWorkflowNodeIdAtom, + setWorkflowDefinitionAtom, + workflowDefinitionAtom, +} from "@/modules/ee/workflows/state/editor"; +import { Alert, AlertDescription } from "@/modules/ui/components/alert"; + +interface WorkflowNodeConfigPanelProps { + isEditable: boolean; +} + +const findSelectedNode = ( + definition: TWorkflowDefinition | null, + selectedNodeId: string | null +): TWorkflowNode | null => { + if (!definition || !selectedNodeId) return null; + if (definition.trigger?.id === selectedNodeId) return definition.trigger; + return definition.nodes.find((node) => node.id === selectedNodeId) ?? null; +}; + +const replaceNode = (definition: TWorkflowDefinition, node: TWorkflowNode): TWorkflowDefinition => { + if (node.type === "trigger" && node.id === definition.trigger?.id) { + return { ...definition, trigger: node }; + } + + if (node.type === "trigger") { + return definition; + } + + return { + ...definition, + nodes: definition.nodes.map((existingNode) => (existingNode.id === node.id ? node : existingNode)), + }; +}; + +/** + * Renders inside the inspector aside (replaces the workflow-level sections while a node is being + * configured). Every form change writes straight into the definition atom, so the canvas node + * (title, summary, issue flag) and the whole-workflow validity update live; persistence is owned + * by the page-level autosave. The workflow Settings view is reached via the canvas cog, not a + * Back arrow — the two views are siblings, not a hierarchy. + */ +export const WorkflowNodeConfigPanel = ({ isEditable }: Readonly) => { + const { t } = useTranslation(); + const definition = useAtomValue(workflowDefinitionAtom); + const selectedNodeId = useAtomValue(selectedWorkflowNodeIdAtom); + const setDefinition = useSetAtom(setWorkflowDefinitionAtom); + + const selectedNode = findSelectedNode(definition, selectedNodeId); + if (!selectedNode || !definition) return null; + + const registryEntry = getNodeRegistryEntry(selectedNode); + const ConfigForm = registryEntry.ConfigForm; + + const handleChange = (nextNode: TWorkflowNode) => { + if (!isEditable) return; + setDefinition((currentDefinition) => + currentDefinition ? replaceNode(currentDefinition, nextNode) : currentDefinition + ); + }; + + return ( + // Fills the editor row's height and scrolls its fields internally, so a config form longer than + // the row can't grow the page. A page-level scrollbar would be useless here anyway: the canvas + // beside it clips rather than scrolls, so scrolling the page only reveals blank space. `min-h-0` + // is what allows the shrink; without it the flex item would be floored at its content height. + // The header sits outside the scrolling area and stays put. + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx new file mode 100644 index 000000000000..a04823ce73ec --- /dev/null +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { TWorkflowResponseCompletedTriggerNode } from "@formbricks/workflows"; +import { cn } from "@/lib/cn"; +import { + WorkflowFieldError, + WorkflowFieldLabel, +} from "@/modules/ee/workflows/components/inspector/workflow-field"; +import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus"; +import { reconcileEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards"; +import { + useWorkflowSurveyEndings, + useWorkflowSurveyOptions, +} from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; +import { + hasBoundTriggerSurveyAtom, + prunedTriggerEndingCardIdsAtom, +} from "@/modules/ee/workflows/state/editor"; +import { Checkbox } from "@/modules/ui/components/checkbox"; +import { Label } from "@/modules/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; + +interface WorkflowTriggerFormProps { + node: TWorkflowResponseCompletedTriggerNode; + isEditable: boolean; + onChange: (next: TWorkflowResponseCompletedTriggerNode) => void; +} + +// "all" = empty `endingCardIds` (match any ending, including future ones); "specific" = checkbox list. +type TEndingScope = "all" | "specific"; + +export const WorkflowTriggerForm = ({ node, isEditable, onChange }: Readonly) => { + const { t } = useTranslation(); + const params = useParams<{ workspaceId: string }>(); + const workspaceId = params?.workspaceId ?? ""; + const surveyOptionsQuery = useWorkflowSurveyOptions(workspaceId); + const triggerSurveyId = node.config.surveyId || null; + const endingsQuery = useWorkflowSurveyEndings(triggerSurveyId); + const hasBoundSurvey = useAtomValue(hasBoundTriggerSurveyAtom); + + // An unbound survey restated inline, so a jump from the problems dialog lands on a control that + // visibly says what's wrong instead of a picker that looks fine. No "touched" gating (unlike the + // email step's blank fields): the stored config points at a survey that cannot be resolved, which + // is never a normal mid-edit state. + // + // Stale ending ids deliberately get NO inline error here: they are reconciled out of the config + // before this form can observe them (see useReconcileTriggerEndingCards), and the widening that + // prune causes is already reported by the every-ending notice below. + const isSurveyInvalid = !hasBoundSurvey; + + // Jump target for the trigger's problems, raised by the validation problems dialog: an unbound + // survey goes to the picker, an ending problem to the scope select it belongs to. + useWorkflowNodeFieldFocus({ + nodeId: node.id, + resolveElement: (field) => + document.getElementById( + field === "endingCardIds" ? "workflow-trigger-ending-scope" : "workflow-trigger-survey" + ), + }); + + // Only an authority once the query has SETTLED for the current survey; null until then, so a + // pending fetch never reads as "no endings" and the stored ids are taken at face value. + const surveyEndingIds = + endingsQuery.isSuccess && endingsQuery.resolvedSurveyId === triggerSurveyId + ? endingsQuery.endings.map((ending) => ending.id) + : null; + // Open in "specific" scope when ids are set OR the builder page pruned this trigger's picks: the + // user chose specific endings that are now gone, so ask for a fresh pick instead of showing the + // widened "all endings" state. Atom (not a prop) since it's trigger-specific. + const prunedEndingCardIds = useAtomValue(prunedTriggerEndingCardIdsAtom); + const setPrunedEndingCardIds = useSetAtom(prunedTriggerEndingCardIdsAtom); + const [endingScope, setEndingScope] = useState( + node.config.endingCardIds.length > 0 || prunedEndingCardIds.length > 0 ? "specific" : "all" + ); + + const handleSurveyChange = (surveyId: string) => { + // Clear ending selection when survey changes — ids belong to the previous survey's endings. + setEndingScope("all"); + // Pruned ids belonged to the previous survey, so the prompt they raised is moot. + setPrunedEndingCardIds([]); + onChange({ + ...node, + config: { ...node.config, surveyId, endingCardIds: [] }, + }); + }; + + const handleScopeChange = (scope: TEndingScope) => { + setEndingScope(scope); + // Any deliberate scope pick answers the "your endings were pruned, choose again" prompt, so + // stop replaying it. Cleared for BOTH directions and unconditionally: after a full prune + // `endingCardIds` is already empty, so picking "all" writes nothing — leaving the atom set + // would make the form snap back to "specific" on its next remount and discard that choice. + setPrunedEndingCardIds([]); + if (scope === "all" && node.config.endingCardIds.length > 0) { + onChange({ ...node, config: { ...node.config, endingCardIds: [] } }); + } + }; + + const toggleEnding = (endingId: string, checked: boolean) => { + // Reconcile before applying the click so ids from deleted endings can't ride along (that + // appending is what produced the phantom "trigger on 2 ending cards" after picking one). + const current = surveyEndingIds + ? reconcileEndingCardIds(node.config.endingCardIds, surveyEndingIds).endingCardIds + : node.config.endingCardIds; + const next = checked + ? Array.from(new Set([...current, endingId])) + : current.filter((id) => id !== endingId); + onChange({ ...node, config: { ...node.config, endingCardIds: next } }); + }; + + // An empty `endingCardIds` means "every ending fires this workflow", which is a widening whenever + // the user's intent is — or was, before the prune — a specific set. Keyed off the pruned atom too + // so it still shows when the prune emptied the list and the survey has no endings left to check. + const showFiresOnEveryEndingNotice = + node.config.endingCardIds.length === 0 && (endingScope === "specific" || prunedEndingCardIds.length > 0); + + const renderEndingChoices = () => { + if (!node.config.surveyId) { + return ( +

{t("workspace.workflows.trigger_ending_cards_pick_survey")}

+ ); + } + if (endingsQuery.isLoading) { + return

{t("common.loading")}

; + } + // No endings on the survey: say so in place of the checkbox list, but keep the scope select and + // the every-ending notice below reachable. Returning early here hid both, so a prune that + // emptied the selection widened the trigger to every response with nothing on screen saying so + // (the validity pill has nothing to flag either — the stale ids are gone by then). + const renderEndingSelection = () => { + if (endingsQuery.endings.length === 0) { + return

{t("workspace.workflows.trigger_ending_cards_none")}

; + } + if (endingScope !== "specific") return null; + return ( +
+ {endingsQuery.endings.map((ending) => { + const checked = node.config.endingCardIds.includes(ending.id); + return ( + + ); + })} +
+ ); + }; + + return ( + <> + + {renderEndingSelection()} + {showFiresOnEveryEndingNotice ? ( +

+ {t("workspace.workflows.trigger_ending_cards_select_at_least_one")} +

+ ) : null} + + ); + }; + + return ( +
+
+ + {t("workspace.workflows.trigger_survey_label")} + + + {isSurveyInvalid ? ( + + {t("workspace.workflows.validation_problem_trigger_survey_unbound")} + + ) : null} +

{t("workspace.workflows.trigger_survey_description")}

+
+ +
+ + {renderEndingChoices()} +
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/runs/run-json-code.tsx b/apps/web/modules/ee/workflows/components/runs/run-json-code.tsx new file mode 100644 index 000000000000..ea5292739400 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/runs/run-json-code.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useMemo } from "react"; +import { stringifyRunJson } from "@/modules/ee/workflows/lib/run-display"; +import { CodeBlock } from "@/modules/ui/components/code-block"; + +interface RunJsonCodeProps { + value: unknown; +} + +// Memoized, size-bounded JSON block for run payloads. Stringifying once per `value` (not per +// render) keeps the string reference stable, so CodeBlock's `Prism.highlightAll()` effect doesn't +// re-run on unrelated re-renders; `stringifyRunJson` caps the length so an unbounded run payload +// can't produce a multi-MB DOM node. +export const RunJsonCode = ({ value }: Readonly) => { + const json = useMemo(() => stringifyRunJson(value), [value]); + + return ( + + {json} + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/runs/run-json-section.tsx b/apps/web/modules/ee/workflows/components/runs/run-json-section.tsx new file mode 100644 index 000000000000..5e7ab643f84a --- /dev/null +++ b/apps/web/modules/ee/workflows/components/runs/run-json-section.tsx @@ -0,0 +1,14 @@ +import { RunJsonCode } from "./run-json-code"; + +interface RunJsonSectionProps { + title: string; + value: unknown; +} + +// Pretty-printed JSON block (trigger payload / run data) in the standard run-detail section frame. +export const RunJsonSection = ({ title, value }: Readonly) => ( +
+

{title}

+ +
+); diff --git a/apps/web/modules/ee/workflows/components/runs/run-steps-body.tsx b/apps/web/modules/ee/workflows/components/runs/run-steps-body.tsx new file mode 100644 index 000000000000..22e35de9b728 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/runs/run-steps-body.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; +import { type TWorkflowRunDetail } from "@/modules/ee/workflows/types"; +import { Skeleton } from "@/modules/ui/components/skeleton"; +import { WorkflowRunSteps } from "./workflow-run-steps"; + +interface RunStepsBodyProps { + isLoading: boolean; + isError: boolean; + error: unknown; + detail?: TWorkflowRunDetail; +} + +// Step-logs section body: one early return per fetch state keeps this flat (no nested ternary). +export const RunStepsBody = ({ isLoading, isError, error, detail }: Readonly) => { + const { t } = useTranslation(); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (isError) { + return ( +

+ {getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))} +

+ ); + } + + if (detail) { + return ; + } + + return null; +}; diff --git a/apps/web/modules/ee/workflows/components/runs/run-summary-section.tsx b/apps/web/modules/ee/workflows/components/runs/run-summary-section.tsx new file mode 100644 index 000000000000..207c09adb027 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/runs/run-summary-section.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { timeSince } from "@/lib/time"; +import { getWorkflowRunStatusBadge } from "@/modules/ee/workflows/lib/display"; +import { type TWorkflowRunSummary } from "@/modules/ee/workflows/types"; +import { Badge } from "@/modules/ui/components/badge"; +import { IdBadge } from "@/modules/ui/components/id-badge"; + +interface RunSummarySectionProps { + run: TWorkflowRunSummary; + locale: string; +} + +// Renders from the list row for an instant paint, then from fetched detail once available (the +// drawer passes detail ?? run); both share the summary shape so every field below resolves. +export const RunSummarySection = ({ run, locale }: Readonly) => { + const { t } = useTranslation(); + const statusBadge = getWorkflowRunStatusBadge(run.status, t); + + return ( +
+

{t("common.summary")}

+
+
+
{t("common.id")}
+
+ +
+
+
+
{t("common.status")}
+
+ +
+
+
+
{t("common.started_at")}
+
+ {run.startedAt ? timeSince(run.startedAt, locale) : t("common.not_set")} +
+
+
+
{t("common.finished_at")}
+
+ {run.finishedAt ? timeSince(run.finishedAt, locale) : t("common.not_set")} +
+
+
+
{t("common.attempt")}
+
{run.attempt}
+
+ {run.error ? ( +
+
{t("common.error")}
+
{run.error}
+
+ ) : null} +
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/runs/workflow-run-detail-drawer.tsx b/apps/web/modules/ee/workflows/components/runs/workflow-run-detail-drawer.tsx new file mode 100644 index 000000000000..ac5bcb2a8274 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/runs/workflow-run-detail-drawer.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { SquareArrowOutUpRight } from "lucide-react"; +import Link from "next/link"; +import { useTranslation } from "react-i18next"; +import { getWorkflowTriggerTypeLabel } from "@/modules/ee/workflows/lib/display"; +import { type TWorkflowRunListItem } from "@/modules/ee/workflows/types"; +import { Button } from "@/modules/ui/components/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/modules/ui/components/sheet"; +import { useWorkflowRun } from "../../hooks/use-workflow-run"; +import { RunJsonSection } from "./run-json-section"; +import { RunStepsBody } from "./run-steps-body"; +import { RunSummarySection } from "./run-summary-section"; + +interface WorkflowRunDetailDrawerProps { + run: TWorkflowRunListItem | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const WorkflowRunDetailDrawer = ({ + run, + open, + onOpenChange, +}: Readonly) => { + const { t, i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US"; + + // The list row gives an instant header + summary; the full run (step logs, trigger payload, run + // data) is fetched on demand and only while the drawer is open. + const { + data: detail, + isLoading, + isError, + error, + } = useWorkflowRun({ runId: run?.id ?? null, enabled: open }); + + return ( + + + + {run?.workflowName ?? t("common.workflow_runs")} + {run ? getWorkflowTriggerTypeLabel(run.triggerType, t) : null} + {run ? ( + + ) : null} + + + {run ? ( +
+ {/* Prefer fetched detail once loaded so the summary can't show stale list values while + the step timeline below shows fresh data; fall back to the list row before it resolves. */} + + +
+

{t("common.steps")}

+ +
+ + {detail ? ( + <> + + + + ) : null} +
+ ) : null} +
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/runs/workflow-run-steps.tsx b/apps/web/modules/ee/workflows/components/runs/workflow-run-steps.tsx new file mode 100644 index 000000000000..ba275090da03 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/runs/workflow-run-steps.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { getWorkflowRunLogStatusBadge } from "@/modules/ee/workflows/lib/display"; +import { type TWorkflowRunLog, formatStepDuration, hasKeys } from "@/modules/ee/workflows/lib/run-display"; +import { Badge } from "@/modules/ui/components/badge"; +import { RunJsonCode } from "./run-json-code"; + +interface WorkflowRunStepsProps { + logs: TWorkflowRunLog[]; +} + +export const WorkflowRunSteps = ({ logs }: Readonly) => { + const { t } = useTranslation(); + + if (logs.length === 0) { + return

{t("common.no_results")}

; + } + + return ( +
    + {logs.map((log) => { + const statusBadge = getWorkflowRunLogStatusBadge(log.status, t); + const duration = formatStepDuration(log.startedAt, log.finishedAt); + + return ( +
  1. +
    +
    + + {log.sequence + 1} + + {log.stepType} +
    +
    + {duration ? {duration} : null} + +
    +
    + + {log.error ? ( +

    {log.error}

    + ) : null} + + {hasKeys(log.input) ? ( +
    +

    {t("common.input")}

    + +
    + ) : null} + + {hasKeys(log.output) ? ( +
    +

    {t("common.output")}

    + +
    + ) : null} +
  2. + ); + })} +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/runs/workflow-runs-table.tsx b/apps/web/modules/ee/workflows/components/runs/workflow-runs-table.tsx new file mode 100644 index 000000000000..9a576f14ccba --- /dev/null +++ b/apps/web/modules/ee/workflows/components/runs/workflow-runs-table.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { timeSince } from "@/lib/time"; +import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; +import { getWorkflowRunStatusBadge, getWorkflowTriggerTypeLabel } from "@/modules/ee/workflows/lib/display"; +import { type TWorkflowRunListItem } from "@/modules/ee/workflows/types"; +import { Badge } from "@/modules/ui/components/badge"; +import { Button } from "@/modules/ui/components/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table"; +import { RunsTableSkeleton } from "../../loading"; +import { WorkflowRunDetailDrawer } from "./workflow-run-detail-drawer"; + +interface WorkflowRunsTableProps { + runs: TWorkflowRunListItem[]; + showWorkflowColumn?: boolean; + isLoading?: boolean; + isError?: boolean; + error?: unknown; + onRetry?: () => void; + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + isFetchNextPageError?: boolean; + onLoadMore?: () => void; +} + +export const WorkflowRunsTable = ({ + runs, + showWorkflowColumn = false, + isLoading = false, + isError = false, + error, + onRetry, + hasNextPage = false, + isFetchingNextPage = false, + isFetchNextPageError = false, + onLoadMore, +}: Readonly) => { + const { t, i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US"; + const [selectedRunId, setSelectedRunId] = useState(null); + const selectedRun = runs.find((run) => run.id === selectedRunId) ?? null; + + // Initial fetch with nothing yet to show: render the skeleton instead of an empty table. + if (isLoading && runs.length === 0) { + return ; + } + + // Hard error with no rows to fall back on: surface the message and a retry affordance. + if (isError && runs.length === 0) { + return ( +
+

{getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))}

+ {onRetry ? ( + + ) : null} +
+ ); + } + + return ( + <> +
+ + + + + {showWorkflowColumn ? t("common.workflow_name") : t("common.trigger")} + + {t("common.status")} + {t("common.started_at")} + {t("common.finished_at")} + + + + {runs.map((run) => { + const statusBadge = getWorkflowRunStatusBadge(run.status, t); + const triggerLabel = getWorkflowTriggerTypeLabel(run.triggerType, t); + + return ( + setSelectedRunId(run.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedRunId(run.id); + } + }} + className="cursor-pointer focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:outline-hidden"> + + {showWorkflowColumn ? ( + <> +

{run.workflowName}

+

{triggerLabel}

+ + ) : ( +

{triggerLabel}

+ )} +
+ + + + + {run.startedAt ? timeSince(run.startedAt, locale) : t("common.not_set")} + + + {run.finishedAt ? timeSince(run.finishedAt, locale) : t("common.not_set")} + +
+ ); + })} + {runs.length === 0 && ( + + +

{t("common.no_results")}

+
+
+ )} +
+
+
+ + {hasNextPage ? ( +
+ {/* A failed load-more keeps the already-loaded rows; surface the error inline so it + isn't swallowed, and let the same button retry the next page. */} + {isFetchNextPageError ? ( +

+ {getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))} +

+ ) : null} + +
+ ) : null} + + { + if (!nextOpen) { + setSelectedRunId(null); + } + }} + /> + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-auto-save-indicator.tsx b/apps/web/modules/ee/workflows/components/workflow-auto-save-indicator.tsx new file mode 100644 index 000000000000..250bbcfd9c18 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-auto-save-indicator.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + hasWorkflowSaveFailedAtom, + isWorkflowDirtyAtom, + isWorkflowSavingAtom, + workflowLastSavedAtAtom, + workflowSaveErrorAtom, +} from "@/modules/ee/workflows/state/editor"; +import { TooltipRenderer } from "@/modules/ui/components/tooltip"; + +const SAVED_FLASH_MS = 3000; + +// Same palette as Badge's "success" / "error" / "gray" types, which is what the survey editor's +// equivalent pill matches. +const PILL_CLASSES = { + failed: "border-red-200 bg-red-100 text-red-800", + saved: "border-green-600 bg-green-50 text-green-800", + idle: "border-slate-200 bg-slate-100 text-slate-600", +} as const; + +/** + * Autosave status pill. Reports the draft's actual state rather than the fact that autosave is + * armed: "Saving…" from the first keystroke until the debounced PATCH lands, then "All changes + * saved" — flashing green for a moment to acknowledge the save. It turns red for as long as a save + * is outstanding: autosaves are silent — they never toast — so the failed state is the only report + * the user gets, and it has to persist until a save actually lands rather than fade like a toast + * (ENG-1970). The caller hides the pill when autosave can't act at all (read-only, archived). + */ +export const WorkflowAutoSaveIndicator = () => { + const { t } = useTranslation(); + const lastSavedAt = useAtomValue(workflowLastSavedAtAtom); + const hasFailed = useAtomValue(hasWorkflowSaveFailedAtom); + const saveError = useAtomValue(workflowSaveErrorAtom); + const isDirty = useAtomValue(isWorkflowDirtyAtom); + const isSaving = useAtomValue(isWorkflowSavingAtom); + const [showSaved, setShowSaved] = useState(false); + + useEffect(() => { + if (!lastSavedAt) return; + setShowSaved(true); + const timer = setTimeout(() => setShowSaved(false), SAVED_FLASH_MS); + return () => clearTimeout(timer); + }, [lastSavedAt]); + + // An outstanding failure outranks everything else: a save that succeeded three seconds ago is not + // the headline when the one after it didn't, and a draft waiting out the debounce behind a failed + // one has no business claiming it is on its way. Below that, dirty covers the debounce window + // before the request goes out, so the pill never reads "saved" while the user is still typing. + let state: keyof typeof PILL_CLASSES = "idle"; + let label = t("workspace.workflows.all_changes_saved"); + if (hasFailed) { + state = "failed"; + label = t("workspace.workflows.autosave_failed"); + } else if (isDirty || isSaving) { + label = t("workspace.workflows.saving_changes"); + } else if (showSaved) { + state = "saved"; + } + + // A rejected draft has a reason worth quoting; an unreachable one has nothing to quote. The + // generic copy is deliberately not "we'll retry when you're back online": that only holds for a + // genuine disconnect, and the same "unreachable" bucket also catches 5xx, DNS and the mutation + // timeout, where no `online` event is ever coming and the promise would sit there unfulfilled + // (raised in review of ENG-1970). + + const tooltipContent = saveError?.detail + ? t("workspace.workflows.autosave_failed_tooltip_rejected", { detail: saveError.detail }) + : t("workspace.workflows.autosave_failed_tooltip"); + + return ( + + {/* A live region because this pill is the whole report: autosave never toasts, so a failure + that is only a colour change is a failure nobody is told about. Polite, not assertive — + it should not interrupt someone mid-edit. The detail rides along as screen-reader-only + text rather than an aria-label, so the announcement carries it too; the tooltip that + shows it visually is hover-only. */} + + {label} + {hasFailed ? . {tooltipContent} : null} + + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-editor-provider.tsx b/apps/web/modules/ee/workflows/components/workflow-editor-provider.tsx new file mode 100644 index 000000000000..0317ce4c3e84 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-editor-provider.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Provider as JotaiProvider } from "jotai"; +import type { ReactNode } from "react"; + +interface WorkflowEditorProviderProps { + children: ReactNode; +} + +export const WorkflowEditorProvider = ({ children }: Readonly) => ( + {children} +); diff --git a/apps/web/modules/ee/workflows/components/workflow-email-authoring-context.tsx b/apps/web/modules/ee/workflows/components/workflow-email-authoring-context.tsx new file mode 100644 index 000000000000..24f1984abd4f --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-email-authoring-context.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { type ReactNode, createContext, useContext } from "react"; +import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context"; + +const WorkflowEmailAuthoringCtx = createContext(null); + +/** + * Provides the server-resolved survey/team/sender context to the workflow node inspector so the + * `send_email` form can render Follow-Ups-parity controls (recall body, recipient options) without + * re-fetching. Wraps the builder body; the trigger `surveyId` inside the definition atom is matched + * against `survey.id` by the consumer so stale (survey-switched) context degrades gracefully. + */ +export const WorkflowEmailAuthoringProvider = ({ + value, + children, +}: Readonly<{ value: TWorkflowEmailAuthoringContext; children: ReactNode }>) => ( + {children} +); + +/** Returns the workflow email authoring context, or `null` when rendered outside the provider. */ +export const useWorkflowEmailAuthoringContext = (): TWorkflowEmailAuthoringContext | null => + useContext(WorkflowEmailAuthoringCtx); diff --git a/apps/web/modules/ee/workflows/components/workflow-filter-dropdown.tsx b/apps/web/modules/ee/workflows/components/workflow-filter-dropdown.tsx new file mode 100644 index 000000000000..b36b3accacfc --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-filter-dropdown.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { ChevronDownIcon } from "lucide-react"; +import { Fragment } from "react"; +import type { TWorkflowStatus } from "@formbricks/workflows"; +import { Checkbox } from "@/modules/ui/components/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; + +export interface TWorkflowStatusFilterOption { + label: string; + value: TWorkflowStatus; + /** Render a divider above this option, to set it apart from the ones before it. */ + separatorBefore?: boolean; +} + +interface WorkflowFilterDropdownProps { + title: string; + options: TWorkflowStatusFilterOption[]; + selectedOptions: TWorkflowStatus[]; + onToggleOption: (value: TWorkflowStatus) => void; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +export const WorkflowFilterDropdown = ({ + title, + options, + selectedOptions, + onToggleOption, + isOpen, + onOpenChange, +}: Readonly) => { + const triggerClasses = `workflowFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none + ${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900 hover:text-white"}`; + + return ( + + + + + + {options.map((option) => ( + + {option.separatorBefore ? : null} + { + e.preventDefault(); + onToggleOption(option.value); + }}> +
+ +

{option.label}

+
+
+
+ ))} +
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-header-cta.tsx b/apps/web/modules/ee/workflows/components/workflow-header-cta.tsx new file mode 100644 index 000000000000..20c932c7d0e2 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-header-cta.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { + ArchiveIcon, + ArchiveRestoreIcon, + ChevronDownIcon, + CirclePauseIcon, + CirclePlayIcon, + TrashIcon, +} from "lucide-react"; +import { useRouter, useSelectedLayoutSegment } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; +import { WorkflowAutoSaveIndicator } from "@/modules/ee/workflows/components/workflow-auto-save-indicator"; +import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder"; +import { deleteWorkflow } from "@/modules/ee/workflows/lib/api-client"; +import { getWorkflowStatusBadge } from "@/modules/ee/workflows/lib/display"; +import { + hasWorkflowSaveFailedAtom, + workflowAtom, + workflowValidityAtom, +} from "@/modules/ee/workflows/state/editor"; +import { Button } from "@/modules/ui/components/button"; +import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; +import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; + +interface WorkflowHeaderCtaProps { + workflowId: string; + isReadOnly: boolean; +} + +export const WorkflowHeaderCta = ({ workflowId, isReadOnly }: Readonly) => { + const { t } = useTranslation(); + const router = useRouter(); + const segment = useSelectedLayoutSegment(); + const workflow = useAtomValue(workflowAtom); + const validity = useAtomValue(workflowValidityAtom); + const hasSaveFailed = useAtomValue(hasWorkflowSaveFailedAtom); + const builder = useWorkflowBuilder({ workflowId, isReadOnly, loadOnMount: false }); + const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + if (!workflow) return null; + // Only the edit tab gets the lifecycle controls — the runs tab is read-only. An unresolved save + // failure is the exception: the unmount flush can fail during the very tab switch that lands the + // user here, and the editor is no longer mounted to report it, so the pill has to follow them. + const isEditTab = segment === null; + if (!isEditTab && !hasSaveFailed) return null; + + const isArchived = workflow.status === "archived"; + const isActive = workflow.status === "enabled"; + const isBusy = builder.isTransitioning || builder.isSaving || isDeleting; + + const handleArchiveConfirm = async () => { + await builder.archive(); + setIsArchiveModalOpen(false); + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + await deleteWorkflow(workflow.id); + toast.success(t("workspace.workflows.delete_success")); + router.push(`/workspaces/${workflow.workspaceId}/workflows`); + } catch (error) { + toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.delete_failed"))); + setIsDeleting(false); + setIsDeleteDialogOpen(false); + } + }; + + return ( +
+ {/* The definition is the workflow's real content: while it can't change (enabled, archived, + or a read-only member) the editor is effectively read-only, so say that instead of + advertising an autosave that has nothing to act on. An outstanding save failure still wins, + because name/description autosave keeps running while a workflow is enabled — a failed + rename would otherwise hide behind a "Read-only" pill. */} + {builder.canEditDefinition || hasSaveFailed ? ( + + ) : ( + + {t("workspace.workflows.read_only")} + + )} + {/* Lifecycle as a status dropdown (same shape as the surveys list "New survey" menu): the + button reads the current state, the menu holds the transitions available from it. Edit tab + only — on the runs tab the pill above is carrying a save failure and nothing else. */} + {isEditTab && ( + <> + + + + + + {isArchived ? ( + <> + } + onSelect={() => void builder.unarchive()}> + {t("common.unarchive")} + + } + className="text-red-600 focus:text-red-600" + onSelect={() => setIsDeleteDialogOpen(true)}> + {t("common.delete")} + + + ) : ( + <> + {isActive ? ( + } + onSelect={() => void builder.disable()}> + {t("common.disable")} + + ) : ( + // Enabling requires a workflow the server would accept; the readiness hint next + // to the Save button says what is still missing. + } + disabled={!validity.isReady} + onSelect={() => void builder.enable()}> + {t("common.enable")} + + )} + } + onSelect={() => setIsArchiveModalOpen(true)}> + {t("common.archive")} + + + )} + + + + + void handleDelete()} + isDeleting={isDeleting} + text={t("workspace.workflows.delete_workflow_confirmation", { name: workflow.name })} + /> + + )} +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-list-actions.tsx b/apps/web/modules/ee/workflows/components/workflow-list-actions.tsx new file mode 100644 index 000000000000..fc6c3b302b47 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-list-actions.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { + ArchiveIcon, + ArchiveRestoreIcon, + CopyIcon, + MoreVertical, + SquarePenIcon, + TrashIcon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import type { TWorkflowStatus } from "@formbricks/workflows"; +import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; +import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; +import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; +import { useArchiveWorkflow } from "../hooks/use-archive-workflow"; +import { useDeleteWorkflow } from "../hooks/use-delete-workflow"; +import { useDuplicateWorkflow } from "../hooks/use-duplicate-workflow"; +import { useUnarchiveWorkflow } from "../hooks/use-unarchive-workflow"; +import type { workflowKeys } from "../lib/query"; + +interface WorkflowListActionsProps { + workflowId: string; + workflowName: string; + status: TWorkflowStatus; + workspaceId: string; + isReadOnly: boolean; + queryKey: ReturnType; +} + +export const WorkflowListActions = ({ + workflowId, + workflowName, + status, + workspaceId, + isReadOnly, + queryKey, +}: Readonly) => { + const { t } = useTranslation(); + const router = useRouter(); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isArchiveDialogOpen, setIsArchiveDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const archiveWorkflowMutation = useArchiveWorkflow({ queryKey }); + const unarchiveWorkflowMutation = useUnarchiveWorkflow(); + const deleteWorkflowMutation = useDeleteWorkflow({ queryKey }); + const duplicateWorkflowMutation = useDuplicateWorkflow(); + + // Every action in this menu mutates the workflow, so read-only users get no menu at all (they can + // still open the workflow by clicking the row). + if (isReadOnly) { + return null; + } + + const isArchived = status === "archived"; + const editHref = `/workspaces/${workspaceId}/workflows/${workflowId}`; + + const handleDuplicate = () => { + duplicateWorkflowMutation.mutate( + { workflowId }, + { + onSuccess: () => { + toast.success(t("workspace.workflows.duplicate_success")); + }, + onError: (error) => { + toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.duplicate_failed"))); + }, + } + ); + }; + + const handleArchive = () => { + archiveWorkflowMutation.mutate( + { workflowId }, + { + onSuccess: () => { + toast.success(t("workspace.workflows.archive_success")); + setIsArchiveDialogOpen(false); + }, + onError: (error) => { + toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.archive_failed"))); + }, + } + ); + }; + + const handleUnarchive = () => { + unarchiveWorkflowMutation.mutate( + { workflowId }, + { + onSuccess: () => { + toast.success(t("workspace.workflows.unarchive_success")); + }, + onError: (error) => { + toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.unarchive_failed"))); + }, + } + ); + }; + + const handleDelete = () => { + deleteWorkflowMutation.mutate( + { workflowId }, + { + onSuccess: () => { + toast.success(t("workspace.workflows.delete_success")); + setIsDeleteDialogOpen(false); + }, + onError: (error) => { + toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.delete_failed"))); + }, + } + ); + }; + + return ( +
+ + + + + + + } + onSelect={() => router.push(editHref)}> + {t("common.edit")} + + } + disabled={duplicateWorkflowMutation.isPending} + onSelect={(event) => { + event.preventDefault(); + setIsMenuOpen(false); + handleDuplicate(); + }}> + {t("common.duplicate")} + + {isArchived ? ( + } + disabled={unarchiveWorkflowMutation.isPending} + onSelect={(event) => { + event.preventDefault(); + setIsMenuOpen(false); + handleUnarchive(); + }}> + {t("workspace.workflows.unarchive")} + + ) : ( + } + onSelect={(event) => { + event.preventDefault(); + setIsMenuOpen(false); + setIsArchiveDialogOpen(true); + }}> + {t("common.archive")} + + )} + {/* Delete is gated behind archiving: a workflow must be archived first, an extra guard against + accidental hard deletes. */} + {isArchived ? ( + } + onSelect={(event) => { + event.preventDefault(); + setIsMenuOpen(false); + setIsDeleteDialogOpen(true); + }}> + {t("common.delete")} + + ) : null} + + + + + + +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-page-title.tsx b/apps/web/modules/ee/workflows/components/workflow-page-title.tsx new file mode 100644 index 000000000000..39948ebcff20 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-page-title.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue, useSetAtom } from "jotai"; +import { usePathname, useRouter, useSearchParams, useSelectedLayoutSegment } from "next/navigation"; +import { type KeyboardEvent, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { WorkflowStatusPill } from "@/modules/ee/workflows/components/workflow-status-pill"; +import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder"; +import { getWorkflow } from "@/modules/ee/workflows/lib/api-client"; +import { workflowKeys } from "@/modules/ee/workflows/lib/query"; +import { setWorkflowNameAtom, workflowAtom, workflowNameAtom } from "@/modules/ee/workflows/state/editor"; +import { Skeleton } from "@/modules/ui/components/skeleton"; + +interface WorkflowPageTitleProps { + workflowId: string; + isReadOnly: boolean; +} + +// The layout renders PageHeader server-side while the workflow is still being fetched client-side. +// Without a placeholder the h1 collapses to an empty row, so the title area reads as blank and the +// tabs below jump once the name arrives. h-9 matches the text-3xl line box the name will occupy. +const WorkflowPageTitleSkeleton = () => ( + + + + +); + +// Prefers the atom state hydrated by the builder. On a fresh load of a sub-route like /runs the +// builder never mounts to hydrate the atom, so fetch the name directly; the query stays disabled +// once the atom carries a name, so the builder page never double-fetches. +// +// On the edit tab the title doubles as the name editor: it binds to the draft atom and is +// persisted by the page-level autosave, with Enter committing it immediately. A workflow arriving +// from the dialog-less create flow (?new=1) gets the title focused and selected so the user names +// it immediately. +export const WorkflowPageTitle = ({ workflowId, isReadOnly }: Readonly) => { + const { t } = useTranslation(); + const segment = useSelectedLayoutSegment(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const workflow = useAtomValue(workflowAtom); + const workflowName = useAtomValue(workflowNameAtom); + const setWorkflowName = useSetAtom(setWorkflowNameAtom); + const inputRef = useRef(null); + const hasAutoFocusedRef = useRef(false); + const isNew = searchParams.get("new") === "1"; + // Actions and atom state only — the builder page owns the load and the debounced autosave. + const { save } = useWorkflowBuilder({ workflowId, isReadOnly, loadOnMount: false }); + + // Scoped to sub-routes like /runs, where no builder mounts to hydrate the atom. On the edit tab + // this used to race the builder's own load: whichever landed first won, and the query usually + // did — painting the plain-text title, then swapping in the editable one a moment later. + // Waiting for the single source keeps the header still and drops a duplicate GET. + const { data } = useQuery({ + queryKey: workflowKeys.detail(workflowId), + queryFn: ({ signal }) => getWorkflow(workflowId, signal), + enabled: segment !== null && !workflow?.name, + }); + + // Only the edit tab mounts the builder that hydrates (and saves) the draft name; metadata is + // editable in every status except archived — the same gate as canEditMetadata in the builder. + const isEditable = segment === null && Boolean(workflow) && !isReadOnly && workflow?.status !== "archived"; + + useEffect(() => { + if (!isNew || !isEditable || hasAutoFocusedRef.current) return; + hasAutoFocusedRef.current = true; + inputRef.current?.focus(); + inputRef.current?.select(); + // Consume the one-shot flag so a reload or shared link doesn't re-select the title. + const nextParams = new URLSearchParams(searchParams); + nextParams.delete("new"); + const query = nextParams.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, [isNew, isEditable, searchParams, router, pathname]); + + // Enter commits the rename instead of doing nothing: the field blurs so the title reads as + // settled, and the draft is flushed right away rather than waiting out the autosave debounce, + // which turns the header's "All changes saved" pill into the confirmation. + const handleKeyDown = (event: KeyboardEvent) => { + // An IME (Japanese/Chinese/Korean) uses Enter to accept the highlighted candidate, so + // committing there would blur mid-composition and persist a half-typed name. The synthetic + // event doesn't carry isComposing; the native one does. + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + // The PATCH contract requires a name, so an empty field keeps focus for an in-place fix; + // save() is still called either way because it owns both the error and the success feedback. + if (workflowName.trim()) inputRef.current?.blur(); + void save(); + }; + + const resolved = workflow ?? data; + if (!resolved) return ; + + // flex-wrap keeps the badge inline next to the name and pushes it below on narrow widths. + return ( + + {isEditable ? ( + setWorkflowName(event.target.value)} + onKeyDown={handleKeyDown} + aria-label={t("common.workflow_name")} + placeholder={t("common.workflow_name")} + // Approximates content sizing where field-sizing is unsupported (Firefox/Safari). + size={Math.max(workflowName.length, 12)} + className={cn( + "-mx-2 -my-1 min-w-0 rounded-md bg-transparent px-2 py-1", + "text-3xl font-bold text-slate-800 placeholder:text-slate-400", + // Sizes to its content where supported; the max keeps long names from pushing the CTA out. + "[field-sizing:content] max-w-[28rem]", + // Dashed hover/focus box, matching the dashboard's editable title + // (see dashboard-page-header.tsx): slate while hovered, brand while editing. + "border border-dashed border-transparent transition-colors", + "hover:border-slate-300 focus:border-brand-dark", + // Same specificity means source order decides, and Tailwind emits hover last — without + // this the border drops back to slate when the pointer rests on a focused field. + "focus:hover:border-brand-dark", + "focus:ring-0 focus:outline-none" + )} + /> + ) : ( + {resolved.name} + )} + + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-secondary-navigation.tsx b/apps/web/modules/ee/workflows/components/workflow-secondary-navigation.tsx new file mode 100644 index 000000000000..252cdf99ed7c --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-secondary-navigation.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useSelectedLayoutSegment } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; + +interface WorkflowSecondaryNavigationProps { + workflowId: string; + workspaceId: string; +} + +export const WorkflowSecondaryNavigation = ({ + workflowId, + workspaceId, +}: Readonly) => { + const { t } = useTranslation(); + // The layout renders this nav, so the active tab is derived from the child segment + // (null = the builder/edit index, "runs" = the runs tab) instead of a passed-in prop. + const segment = useSelectedLayoutSegment(); + const activeId = segment === "runs" ? "runs" : "builder"; + + const navigation = [ + { + id: "builder", + label: t("common.editor"), + href: `/workspaces/${workspaceId}/workflows/${workflowId}`, + }, + { + id: "runs", + label: t("common.runs"), + href: `/workspaces/${workspaceId}/workflows/${workflowId}/runs`, + }, + ]; + + return ; +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-sort-dropdown.tsx b/apps/web/modules/ee/workflows/components/workflow-sort-dropdown.tsx new file mode 100644 index 000000000000..181b17adfc1a --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-sort-dropdown.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { TFunction } from "i18next"; +import { ChevronDownIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { TWorkflowSortBy } from "@formbricks/workflows"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; + +interface TWorkflowSortOption { + label: string; + value: TWorkflowSortBy; +} + +const getSortOptions = (t: TFunction): TWorkflowSortOption[] => [ + { label: t("common.updated_at"), value: "updatedAt" }, + { label: t("common.created_at"), value: "createdAt" }, + { label: t("workspace.workflows.alphabetical"), value: "name" }, +]; + +interface WorkflowSortDropdownProps { + sortBy: TWorkflowSortBy; + onSortChange: (value: TWorkflowSortBy) => void; +} + +export const WorkflowSortDropdown = ({ sortBy, onSortChange }: Readonly) => { + const { t } = useTranslation(); + const options = getSortOptions(t); + const activeLabel = options.find((option) => option.value === sortBy)?.label ?? ""; + + return ( + + + + + + {options.map((option) => ( + onSortChange(option.value)}> +
+ +

{option.label}

+
+
+ ))} +
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-status-pill.tsx b/apps/web/modules/ee/workflows/components/workflow-status-pill.tsx new file mode 100644 index 000000000000..800ca04acb59 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-status-pill.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import type { TWorkflowStatus } from "@formbricks/workflows"; +import { Badge } from "@/modules/ui/components/badge"; +import { getWorkflowStatusBadge } from "../lib/display"; + +interface WorkflowStatusPillProps { + status: TWorkflowStatus; + size?: "tiny" | "normal" | "large"; +} + +export const WorkflowStatusPill = ({ status, size = "tiny" }: Readonly) => { + const { t } = useTranslation(); + const badge = getWorkflowStatusBadge(status, t); + + return ; +}; diff --git a/apps/web/modules/ee/workflows/components/workflow-validation-problems-dialog.tsx b/apps/web/modules/ee/workflows/components/workflow-validation-problems-dialog.tsx new file mode 100644 index 000000000000..c4fdd81c501f --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflow-validation-problems-dialog.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { ArrowRightIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + getWorkflowValidationProblemFocusTarget, + getWorkflowValidationProblemLocation, +} from "@/modules/ee/workflows/lib/display"; +import { + type TWorkflowValidationProblem, + type TWorkflowValidationProblemCode, + requestWorkflowNodeFieldFocusAtom, + workflowDefinitionAtom, +} from "@/modules/ee/workflows/state/editor"; +import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; + +interface WorkflowValidationProblemsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + problems: TWorkflowValidationProblem[]; +} + +/** + * Lists the live validation problems behind the canvas's "N errors" indicator. Only ever opened + * with a non-empty list (a valid workflow renders a passive badge instead). Each problem is + * localized by its `code`; the exhaustive map keeps a new code from shipping without copy. + * + * A problem that resolves to a single config field is a button: it closes the dialog, opens that + * step's config panel, and focuses the offending field with its inline error revealed. Problems + * fixed elsewhere (naming the workflow, adding a trigger, connecting the flow) stay passive rows — + * there is nothing to focus. + */ +export const WorkflowValidationProblemsDialog = ({ + open, + onOpenChange, + problems, +}: Readonly) => { + const { t } = useTranslation(); + // Read for display only: resolving each problem's `field` to the affected node's title. + const definition = useAtomValue(workflowDefinitionAtom); + const requestFieldFocus = useSetAtom(requestWorkflowNodeFieldFocusAtom); + + // Inline literal t() calls so the translation-key scanner detects the keys. + const problemMessages: Record = { + name_missing: t("workspace.workflows.validation_problem_name_missing"), + trigger_missing: t("workspace.workflows.validation_problem_trigger_missing"), + trigger_survey_unbound: t("workspace.workflows.validation_problem_trigger_survey_unbound"), + trigger_ending_not_found: t("workspace.workflows.validation_problem_trigger_ending_not_found"), + trigger_not_connected: t("workspace.workflows.validation_problem_trigger_not_connected"), + flow_invalid: t("workspace.workflows.validation_problem_flow_invalid"), + step_not_executable: t("workspace.workflows.validation_problem_step_not_executable"), + step_incomplete: t("workspace.workflows.validation_problem_step_incomplete"), + definition_invalid: t("workspace.workflows.validation_problem_generic"), + }; + + return ( + + + + {t("workspace.workflows.validation_problems_title")} + {t("workspace.workflows.validation_problems_description")} + + +
    + {problems.map((problem) => { + // The affected step's display title (as shown on its canvas card), when the + // problem points at one; whole-flow problems carry no locator row at all. + const location = getWorkflowValidationProblemLocation(problem, definition, t); + const focusTarget = getWorkflowValidationProblemFocusTarget(problem, definition); + const message = problemMessages[problem.code]; + + return ( +
  • + {focusTarget ? ( + + ) : ( +
    +

    {message}

    + {location ?

    {location}

    : null} +
    + )} +
  • + ); + })} +
+
+ + + +
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflows-empty-state.tsx b/apps/web/modules/ee/workflows/components/workflows-empty-state.tsx new file mode 100644 index 000000000000..246dce347af5 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflows-empty-state.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { WorkflowIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface WorkflowsEmptyStateProps { + /** True when a search term or status filter is active (no matches) vs a genuinely empty list. */ + filtered: boolean; +} + +export const WorkflowsEmptyState = ({ filtered }: Readonly) => { + const { t } = useTranslation(); + + return ( +
+ +
+

+ {filtered ? t("workspace.workflows.no_results_title") : t("workspace.workflows.no_workflows_title")} +

+

+ {filtered + ? t("workspace.workflows.no_results_description") + : t("workspace.workflows.no_workflows_description")} +

+
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/workflows-upgrade-prompt.tsx b/apps/web/modules/ee/workflows/components/workflows-upgrade-prompt.tsx new file mode 100644 index 000000000000..a0d8316029bf --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workflows-upgrade-prompt.tsx @@ -0,0 +1,38 @@ +import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getTranslate } from "@/lingodotdev/server"; +import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; + +interface WorkflowsUpgradePromptProps { + organizationId: string; +} + +/** + * Centered upsell rendered by the workflows route layouts when the organization lacks the + * workflows entitlement (Cloud Scale plan / self-hosted EE license). Mirrors the dashboards + * upgrade prompt: upgrade (Cloud) or request a trial license (self-hosted), plus docs. + */ +export const WorkflowsUpgradePrompt = async ({ organizationId }: Readonly) => { + const t = await getTranslate(); + + return ( +
+ +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/components/workspace-workflows-header-cta.tsx b/apps/web/modules/ee/workflows/components/workspace-workflows-header-cta.tsx new file mode 100644 index 000000000000..861148a0c42c --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workspace-workflows-header-cta.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import { useRouter, useSelectedLayoutSegment } from "next/navigation"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { ZCreateWorkflowInput } from "@formbricks/workflows"; +import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; +import { Button } from "@/modules/ui/components/button"; +import { useCreateWorkflow } from "../hooks/use-create-workflow"; +import { createEmptyWorkflowDefinition } from "../lib/default-workflow"; + +interface WorkspaceWorkflowsHeaderCtaProps { + workspaceId: string; + isReadOnly: boolean; +} + +export const WorkspaceWorkflowsHeaderCta = ({ + workspaceId, + isReadOnly, +}: Readonly) => { + const { t } = useTranslation(); + const router = useRouter(); + const segment = useSelectedLayoutSegment(); + const createWorkflowMutation = useCreateWorkflow(); + + if (segment !== null || isReadOnly) { + return null; + } + + // No dialog: create the draft immediately with a default name and an empty canvas, then land + // the user in the editor where the title is focused for renaming (the ?new=1 flag drives that). + const handleCreate = () => { + if (createWorkflowMutation.isPending) return; + + const parsed = ZCreateWorkflowInput.safeParse({ + workspaceId, + name: t("common.new_workflow"), + description: null, + status: "draft", + definition: createEmptyWorkflowDefinition(), + }); + + if (!parsed.success) { + toast.error(t("workspace.workflows.create_failed")); + return; + } + + createWorkflowMutation.mutate(parsed.data, { + onSuccess: (workflow) => { + router.push(`/workspaces/${workspaceId}/workflows/${workflow.id}?new=1`); + }, + onError: (error) => { + toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.create_failed"))); + }, + }); + }; + + return ( + + ); +}; diff --git a/apps/web/modules/ee/workflows/components/workspace-workflows-secondary-navigation.tsx b/apps/web/modules/ee/workflows/components/workspace-workflows-secondary-navigation.tsx new file mode 100644 index 000000000000..fc4301f846c8 --- /dev/null +++ b/apps/web/modules/ee/workflows/components/workspace-workflows-secondary-navigation.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useSelectedLayoutSegment } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; + +interface WorkspaceWorkflowsSecondaryNavigationProps { + workspaceId: string; +} + +export const WorkspaceWorkflowsSecondaryNavigation = ({ + workspaceId, +}: Readonly) => { + const { t } = useTranslation(); + // The layout renders this nav, so the active tab is derived from the child segment + // (null = the workflows list index, "runs" = the runs tab) instead of a passed-in prop. + const segment = useSelectedLayoutSegment(); + const activeId = segment === "runs" ? "runs" : "workflows"; + + const navigation = [ + { + id: "workflows", + label: t("common.workflows"), + href: `/workspaces/${workspaceId}/workflows`, + }, + { + id: "runs", + label: t("common.runs"), + href: `/workspaces/${workspaceId}/workflows/runs`, + }, + ]; + + return ; +}; diff --git a/apps/web/modules/ee/workflows/hooks/test-utils.ts b/apps/web/modules/ee/workflows/hooks/test-utils.ts new file mode 100644 index 000000000000..fecb961df098 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/test-utils.ts @@ -0,0 +1,17 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode, createElement } from "react"; + +/** + * Shared renderHook harness for the workflows hook tests: a provider wrapper bound to the given + * QueryClient, and a no-retry client factory so failure paths reject immediately instead of + * retrying into the test timeout. + */ +export function createWrapper(queryClient: QueryClient) { + const Wrapper = ({ children }: Readonly<{ children: ReactNode }>) => + createElement(QueryClientProvider, { client: queryClient }, children); + Wrapper.displayName = "WorkflowsHookTestWrapper"; + return Wrapper; +} + +export const newQueryClient = () => + new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false } } }); diff --git a/apps/web/modules/ee/workflows/hooks/use-archive-workflow.test.ts b/apps/web/modules/ee/workflows/hooks/use-archive-workflow.test.ts new file mode 100644 index 000000000000..16025271c782 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-archive-workflow.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { TWorkflowListItem } from "@formbricks/workflows"; +import type { TWorkflowListPage } from "../lib/api-client"; +import { workflowKeys } from "../lib/query"; +import { createWrapper, newQueryClient } from "./test-utils"; +import { useArchiveWorkflow } from "./use-archive-workflow"; + +const queryKey = workflowKeys.list({ workspaceId: "ws_1", limit: 12, nameContains: "" }); + +const workflowListItem = (id: string, name: string): TWorkflowListItem => ({ + id, + workspaceId: "ws_1", + name, + description: null, + status: "draft", + triggerType: "response.completed", + surveyId: "survey_1", + createdBy: null, + creator: null, + createdAt: "2026-06-11T09:30:00.000Z", + updatedAt: "2026-06-11T09:30:00.000Z", + lastRun: null, + runCount: 0, +}); + +const seedData = (): { pages: TWorkflowListPage[]; pageParams: (string | null)[] } => ({ + pages: [ + { + data: [workflowListItem("wf_1", "One"), workflowListItem("wf_2", "Two")], + meta: { limit: 12, nextCursor: null }, + }, + ], + pageParams: [null], +}); + +describe("useArchiveWorkflow", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("optimistically drops the archived workflow from the default list and invalidates on success", async () => { + let resolveFetch: ((value: Response) => void) | undefined; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + vi.mocked(global.fetch).mockReturnValue(fetchPromise as Promise); + + const queryClient = newQueryClient(); + queryClient.setQueryData(queryKey, seedData()); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useArchiveWorkflow({ queryKey }), { + wrapper: createWrapper(queryClient), + }); + + result.current.mutate({ workflowId: "wf_2" }); + + await waitFor(() => + expect( + queryClient.getQueryData<{ pages: TWorkflowListPage[] }>(queryKey)?.pages[0]?.data.map((w) => w.id) + ).toEqual(["wf_1"]) + ); + + resolveFetch?.( + new Response(JSON.stringify({ data: { id: "wf_2", status: "archived" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workflowKeys.lists() }); + }); + + test("rolls the cache back when archive fails", async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify({ + status: 422, + detail: "Workflow is already archived.", + code: "invalid_workflow_state", + }), + { status: 422, headers: { "Content-Type": "application/problem+json" } } + ) + ); + + const queryClient = newQueryClient(); + queryClient.setQueryData(queryKey, seedData()); + + const { result } = renderHook(() => useArchiveWorkflow({ queryKey }), { + wrapper: createWrapper(queryClient), + }); + + await expect( + act(async () => { + await result.current.mutateAsync({ workflowId: "wf_2" }); + }) + ).rejects.toThrow("Workflow is already archived."); + + expect( + queryClient.getQueryData<{ pages: TWorkflowListPage[] }>(queryKey)?.pages[0]?.data.map((w) => w.id) + ).toEqual(["wf_1", "wf_2"]); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-archive-workflow.ts b/apps/web/modules/ee/workflows/hooks/use-archive-workflow.ts new file mode 100644 index 000000000000..3fc33a0642ab --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-archive-workflow.ts @@ -0,0 +1,33 @@ +"use client"; + +import { type InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query"; +import { archiveWorkflow } from "../lib/api-client"; +import type { TWorkflowListPage } from "../lib/api-client"; +import { removeWorkflowFromInfiniteData, workflowKeys } from "../lib/query"; + +export const useArchiveWorkflow = ({ queryKey }: { queryKey: ReturnType }) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ workflowId }: { workflowId: string }) => archiveWorkflow(workflowId), + onMutate: async ({ workflowId }) => { + await queryClient.cancelQueries({ queryKey }); + + const previousData = queryClient.getQueryData>(queryKey); + + queryClient.setQueryData | undefined>(queryKey, (currentData) => + removeWorkflowFromInfiniteData(currentData, workflowId) + ); + + return { previousData }; + }, + onError: (_error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKey, context.previousData); + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }); + }, + }); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-create-workflow.test.ts b/apps/web/modules/ee/workflows/hooks/use-create-workflow.test.ts new file mode 100644 index 000000000000..f089deffb640 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-create-workflow.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { TCreateWorkflowInput } from "@formbricks/workflows"; +import { createEmptyWorkflowDefinition } from "../lib/default-workflow"; +import { workflowKeys } from "../lib/query"; +import { createWrapper, newQueryClient } from "./test-utils"; +import { useCreateWorkflow } from "./use-create-workflow"; + +const input: TCreateWorkflowInput = { + workspaceId: "ws_1", + name: "New", + description: null, + status: "draft", + definition: createEmptyWorkflowDefinition(), +}; + +describe("useCreateWorkflow", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("returns the created resource and invalidates list queries on success", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: "wf_new", name: "New", status: "draft" } }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }) + ); + + const queryClient = newQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useCreateWorkflow(), { wrapper: createWrapper(queryClient) }); + + let created: { id: string } | undefined; + await act(async () => { + created = await result.current.mutateAsync(input); + }); + + expect(created).toMatchObject({ id: "wf_new" }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workflowKeys.lists() }); + }); + + test("rejects with the parsed error and does not invalidate when create fails", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ status: 400, detail: "The request payload is invalid.", code: "bad_request" }), + { + status: 400, + headers: { "Content-Type": "application/problem+json" }, + } + ) + ); + + const queryClient = newQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useCreateWorkflow(), { wrapper: createWrapper(queryClient) }); + + await expect( + act(async () => { + await result.current.mutateAsync(input); + }) + ).rejects.toThrow("The request payload is invalid."); + + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-create-workflow.ts b/apps/web/modules/ee/workflows/hooks/use-create-workflow.ts new file mode 100644 index 000000000000..7949fa830034 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-create-workflow.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { TCreateWorkflowInput, TWorkflowResource } from "@formbricks/workflows"; +import { createWorkflow } from "../lib/api-client"; +import { workflowKeys } from "../lib/query"; + +export const useCreateWorkflow = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: TCreateWorkflowInput) => createWorkflow(input), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }); + }, + }); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-debounced-value.test.ts b/apps/web/modules/ee/workflows/hooks/use-debounced-value.test.ts new file mode 100644 index 000000000000..57a82bc68644 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-debounced-value.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useDebouncedValue } from "./use-debounced-value"; + +describe("useDebouncedValue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("returns the initial value immediately", () => { + const { result } = renderHook(() => useDebouncedValue("a", 300)); + expect(result.current).toBe("a"); + }); + + test("only updates after the delay elapses with no further changes", () => { + const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 300), { + initialProps: { value: "a" }, + }); + + rerender({ value: "ab" }); + rerender({ value: "abc" }); + expect(result.current).toBe("a"); + + act(() => { + vi.advanceTimersByTime(299); + }); + expect(result.current).toBe("a"); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current).toBe("abc"); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-debounced-value.ts b/apps/web/modules/ee/workflows/hooks/use-debounced-value.ts new file mode 100644 index 000000000000..3f87acfe9ae2 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-debounced-value.ts @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export const useDebouncedValue = (value: T, delayMs: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeoutId = setTimeout(() => setDebouncedValue(value), delayMs); + return () => clearTimeout(timeoutId); + }, [value, delayMs]); + + return debouncedValue; +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-delete-workflow.test.ts b/apps/web/modules/ee/workflows/hooks/use-delete-workflow.test.ts new file mode 100644 index 000000000000..e448b4b0a838 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-delete-workflow.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { TWorkflowListItem } from "@formbricks/workflows"; +import type { TWorkflowListPage } from "../lib/api-client"; +import { workflowKeys } from "../lib/query"; +import { createWrapper, newQueryClient } from "./test-utils"; +import { useDeleteWorkflow } from "./use-delete-workflow"; + +const queryKey = workflowKeys.list({ workspaceId: "ws_1", limit: 12, nameContains: "" }); + +const workflowListItem = (id: string, name: string): TWorkflowListItem => ({ + id, + workspaceId: "ws_1", + name, + description: null, + status: "draft", + triggerType: "response.completed", + surveyId: "survey_1", + createdBy: null, + creator: null, + createdAt: "2026-06-11T09:30:00.000Z", + updatedAt: "2026-06-11T09:30:00.000Z", + lastRun: null, + runCount: 0, +}); + +const seedData = (): { pages: TWorkflowListPage[]; pageParams: (string | null)[] } => ({ + pages: [ + { + data: [workflowListItem("wf_1", "One"), workflowListItem("wf_2", "Two")], + meta: { limit: 12, nextCursor: null }, + }, + ], + pageParams: [null], +}); + +describe("useDeleteWorkflow", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("optimistically removes the workflow and invalidates list queries on success", async () => { + let resolveFetch: ((value: Response) => void) | undefined; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + vi.mocked(global.fetch).mockReturnValue(fetchPromise as Promise); + + const queryClient = newQueryClient(); + queryClient.setQueryData(queryKey, seedData()); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useDeleteWorkflow({ queryKey }), { + wrapper: createWrapper(queryClient), + }); + + result.current.mutate({ workflowId: "wf_1" }); + + await waitFor(() => + expect( + queryClient.getQueryData<{ pages: TWorkflowListPage[] }>(queryKey)?.pages[0]?.data.map((w) => w.id) + ).toEqual(["wf_2"]) + ); + + resolveFetch?.(new Response(null, { status: 204 })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workflowKeys.lists() }); + }); + + test("rolls the cache back when delete fails", async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ status: 403, detail: "You are not authorized", code: "forbidden" }), { + status: 403, + headers: { "Content-Type": "application/problem+json" }, + }) + ); + + const queryClient = newQueryClient(); + queryClient.setQueryData(queryKey, seedData()); + + const { result } = renderHook(() => useDeleteWorkflow({ queryKey }), { + wrapper: createWrapper(queryClient), + }); + + await expect( + act(async () => { + await result.current.mutateAsync({ workflowId: "wf_1" }); + }) + ).rejects.toThrow("You are not authorized"); + + expect( + queryClient.getQueryData<{ pages: TWorkflowListPage[] }>(queryKey)?.pages[0]?.data.map((w) => w.id) + ).toEqual(["wf_1", "wf_2"]); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-delete-workflow.ts b/apps/web/modules/ee/workflows/hooks/use-delete-workflow.ts new file mode 100644 index 000000000000..539ae4500237 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-delete-workflow.ts @@ -0,0 +1,33 @@ +"use client"; + +import { type InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteWorkflow } from "../lib/api-client"; +import type { TWorkflowListPage } from "../lib/api-client"; +import { removeWorkflowFromInfiniteData, workflowKeys } from "../lib/query"; + +export const useDeleteWorkflow = ({ queryKey }: { queryKey: ReturnType }) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ workflowId }: { workflowId: string }) => deleteWorkflow(workflowId), + onMutate: async ({ workflowId }) => { + await queryClient.cancelQueries({ queryKey }); + + const previousData = queryClient.getQueryData>(queryKey); + + queryClient.setQueryData | undefined>(queryKey, (currentData) => + removeWorkflowFromInfiniteData(currentData, workflowId) + ); + + return { previousData }; + }, + onError: (_error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKey, context.previousData); + } + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }); + }, + }); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.test.ts b/apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.test.ts new file mode 100644 index 000000000000..85320bd47364 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { workflowKeys } from "../lib/query"; +import { createWrapper, newQueryClient } from "./test-utils"; +import { useDuplicateWorkflow } from "./use-duplicate-workflow"; + +describe("useDuplicateWorkflow", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("posts to the duplicate endpoint, returns the copy and invalidates list queries", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: "wf_copy", name: "One (copy)", status: "draft" } }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }) + ); + + const queryClient = newQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useDuplicateWorkflow(), { wrapper: createWrapper(queryClient) }); + + let copy: { id: string } | undefined; + await act(async () => { + copy = await result.current.mutateAsync({ workflowId: "wf_1" }); + }); + + expect(copy).toMatchObject({ id: "wf_copy" }); + expect(global.fetch).toHaveBeenCalledWith( + "/api/v3/workflows/wf_1/duplicate", + expect.objectContaining({ method: "POST" }) + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workflowKeys.lists() }); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.ts b/apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.ts new file mode 100644 index 000000000000..2175b302283a --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-duplicate-workflow.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { TWorkflowResource } from "@formbricks/workflows"; +import { duplicateWorkflow } from "../lib/api-client"; +import { workflowKeys } from "../lib/query"; + +export const useDuplicateWorkflow = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ workflowId }: { workflowId: string }) => duplicateWorkflow(workflowId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }); + }, + }); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.test.ts b/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.test.ts new file mode 100644 index 000000000000..f21901573625 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { Provider, createStore, useAtomValue } from "jotai"; +import { type ReactNode, createElement } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import { + prunedTriggerEndingCardIdsAtom, + setWorkflowDefinitionAtom, + workflowDefinitionAtom, +} from "@/modules/ee/workflows/state/editor"; +import { createWrapper, newQueryClient } from "./test-utils"; +import { useReconcileTriggerEndingCards } from "./use-reconcile-trigger-ending-cards"; + +const SURVEY_ID = "survey_1"; +const endingsQueryKey = (surveyId: string) => ["workflow-trigger", "survey-endings", surveyId]; + +const buildDefinition = (endingCardIds: string[]): TWorkflowDefinition => + ({ + schemaVersion: 1, + trigger: { + id: "trigger_1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: SURVEY_ID, endingCardIds }, + }, + nodes: [], + edges: [], + entryNodeId: "trigger_1", + }) as unknown as TWorkflowDefinition; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +const surveyWithEndings = (endingIds: string[]) => + jsonResponse({ + data: { + defaultLanguage: "en", + endings: endingIds.map((id) => ({ id, type: "endScreen", headline: { en: id } })), + }, + }); + +// Renders the hook against a live editor store, feeding the atom's definition back in so the +// reconcile's own write re-enters the effect — a reconcile that never settles would loop here. +const renderReconcile = ({ + definition, + isEditable = true, + queryClient = newQueryClient(), +}: { + definition: TWorkflowDefinition | null; + isEditable?: boolean; + queryClient?: QueryClient; +}) => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, definition); + + const QueryWrapper = createWrapper(queryClient); + const wrapper = ({ children }: Readonly<{ children: ReactNode }>) => + createElement(Provider, { store }, createElement(QueryWrapper, null, children)); + + const view = renderHook( + () => { + const current = useAtomValue(workflowDefinitionAtom); + useReconcileTriggerEndingCards({ definition: current, isEditable }); + return { + endingCardIds: current?.trigger?.config.endingCardIds, + pruned: useAtomValue(prunedTriggerEndingCardIdsAtom), + }; + }, + { wrapper } + ); + return { store, ...view }; +}; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useReconcileTriggerEndingCards", () => { + test("prunes ids whose ending was deleted from the survey and records them", async () => { + vi.mocked(global.fetch).mockResolvedValue(surveyWithEndings(["end_new"])); + + const { result } = renderReconcile({ definition: buildDefinition(["end_deleted", "end_new"]) }); + + await waitFor(() => expect(result.current.endingCardIds).toEqual(["end_new"])); + expect(result.current.pruned).toEqual(["end_deleted"]); + }); + + test("records the drop when every referenced ending is gone", async () => { + vi.mocked(global.fetch).mockResolvedValue(surveyWithEndings(["end_other"])); + + const { result } = renderReconcile({ definition: buildDefinition(["end_deleted"]) }); + + // An empty list means "all endings", so clearing the last id widens the trigger. Publishing + // the dropped id is what lets the form keep asking for a specific pick. + await waitFor(() => expect(result.current.endingCardIds).toEqual([])); + expect(result.current.pruned).toEqual(["end_deleted"]); + }); + + test("drops duplicate ids without claiming an ending went missing", async () => { + vi.mocked(global.fetch).mockResolvedValue(surveyWithEndings(["end_1"])); + + const { result } = renderReconcile({ definition: buildDefinition(["end_1", "end_1"]) }); + + await waitFor(() => expect(result.current.endingCardIds).toEqual(["end_1"])); + expect(result.current.pruned).toEqual([]); + }); + + test("leaves a clean selection untouched and reports no drift", async () => { + vi.mocked(global.fetch).mockResolvedValue(surveyWithEndings(["end_1", "end_2"])); + + const { result } = renderReconcile({ definition: buildDefinition(["end_1"]) }); + + await waitFor(() => expect(vi.mocked(global.fetch)).toHaveBeenCalled()); + expect(result.current.endingCardIds).toEqual(["end_1"]); + expect(result.current.pruned).toEqual([]); + }); + + test("keeps stale ids when the definition cannot be edited", async () => { + vi.mocked(global.fetch).mockResolvedValue(surveyWithEndings(["end_new"])); + + const { result } = renderReconcile({ + definition: buildDefinition(["end_deleted"]), + isEditable: false, + }); + + await waitFor(() => expect(vi.mocked(global.fetch)).toHaveBeenCalled()); + expect(result.current.endingCardIds).toEqual(["end_deleted"]); + expect(result.current.pruned).toEqual([]); + }); + + test("keeps stale ids when the survey lookup fails — a failed fetch is not 'no endings'", async () => { + vi.mocked(global.fetch).mockResolvedValue(jsonResponse({ status: 500, detail: "boom" }, 500)); + + const { result } = renderReconcile({ definition: buildDefinition(["end_deleted"]) }); + + await waitFor(() => expect(vi.mocked(global.fetch)).toHaveBeenCalled()); + expect(result.current.endingCardIds).toEqual(["end_deleted"]); + expect(result.current.pruned).toEqual([]); + }); + + test("keeps stale ids when the endings response shape is malformed — not 'no endings'", async () => { + vi.mocked(global.fetch).mockResolvedValue( + jsonResponse({ data: { defaultLanguage: "en", endings: "not-an-array" } }) + ); + + const { result } = renderReconcile({ definition: buildDefinition(["end_deleted"]) }); + + await waitFor(() => expect(vi.mocked(global.fetch)).toHaveBeenCalled()); + expect(result.current.endingCardIds).toEqual(["end_deleted"]); + expect(result.current.pruned).toEqual([]); + }); + + test("does not fetch when the trigger targets all endings", async () => { + const { result } = renderReconcile({ definition: buildDefinition([]) }); + + await waitFor(() => expect(result.current.endingCardIds).toEqual([])); + expect(vi.mocked(global.fetch)).not.toHaveBeenCalled(); + expect(result.current.pruned).toEqual([]); + }); + + test("ignores endings that resolved for a different survey", async () => { + const queryClient = newQueryClient(); + // Cached under this survey's key but carrying another survey's id — the guard must refuse it + // rather than prune the selection against the wrong survey's endings. + queryClient.setQueryData(endingsQueryKey(SURVEY_ID), { surveyId: "survey_other", endings: [] }); + vi.mocked(global.fetch).mockReturnValue(new Promise(() => undefined) as never); + + const { result } = renderReconcile({ definition: buildDefinition(["end_deleted"]), queryClient }); + + await waitFor(() => expect(vi.mocked(global.fetch)).toHaveBeenCalled()); + expect(result.current.endingCardIds).toEqual(["end_deleted"]); + expect(result.current.pruned).toEqual([]); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts b/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts new file mode 100644 index 000000000000..8b3f5888e568 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useSetAtom } from "jotai"; +import { useEffect } from "react"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import { reconcileDefinitionEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards"; +import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; +import { + prunedTriggerEndingCardIdsAtom, + setWorkflowDefinitionAtom, +} from "@/modules/ee/workflows/state/editor"; + +interface UseReconcileTriggerEndingCardsProps { + definition: TWorkflowDefinition | null; + /** Definition edits are allowed (draft/disabled status, writable member). */ + isEditable: boolean; +} + +/** + * Drops trigger `endingCardIds` whose endings no longer exist on the bound survey, keeping the + * canvas summary honest and the enable gate unblocked by phantom ids; the page's autosave persists + * the repair. Dropped ids are published to `prunedTriggerEndingCardIdsAtom` because emptying the + * last id reads as "all endings" (a widening) — the trigger form uses that to keep asking for a + * fresh pick. Read-only / enabled / archived workflows are skipped (definition can't be PATCHed). + */ +export const useReconcileTriggerEndingCards = ({ + definition, + isEditable, +}: Readonly): void => { + const setDefinition = useSetAtom(setWorkflowDefinitionAtom); + const setPrunedEndingCardIds = useSetAtom(prunedTriggerEndingCardIdsAtom); + + const trigger = definition?.trigger ?? null; + const surveyId = trigger?.config.surveyId ?? null; + const hasEndingCardIds = (trigger?.config.endingCardIds.length ?? 0) > 0; + + // "All endings" (empty list) has nothing to reconcile — skip the fetch. With a selection, the + // trigger form's query shares this cache entry, so an open inspector costs no extra request. + const endingsQuery = useWorkflowSurveyEndings(hasEndingCardIds ? surveyId : null); + const { isSuccess, resolvedSurveyId, endings } = endingsQuery; + + useEffect(() => { + if (!isEditable) return; + // Only act on a settled response for THIS survey — a pending/failed query must never read as + // "no endings" and wipe a valid selection. + if (!isSuccess || resolvedSurveyId === null || resolvedSurveyId !== surveyId) return; + + const reconciled = reconcileDefinitionEndingCardIds( + definition, + endings.map((ending) => ending.id) + ); + if (!reconciled) return; + + setDefinition(reconciled.definition); + // Empty for a de-duplication-only fix — the selection still means what the user picked, no prompt. + if (reconciled.removedEndingCardIds.length > 0) { + setPrunedEndingCardIds(reconciled.removedEndingCardIds); + } + }, [ + definition, + endings, + isEditable, + isSuccess, + resolvedSurveyId, + surveyId, + setDefinition, + setPrunedEndingCardIds, + ]); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.test.ts b/apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.test.ts new file mode 100644 index 000000000000..bb8d136159f7 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment jsdom + */ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { workflowKeys } from "../lib/query"; +import { createWrapper, newQueryClient } from "./test-utils"; +import { useUnarchiveWorkflow } from "./use-unarchive-workflow"; + +describe("useUnarchiveWorkflow", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("posts to the unarchive endpoint and invalidates the lists on success", async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ data: { id: "wf_1", status: "disabled" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const queryClient = newQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useUnarchiveWorkflow(), { + wrapper: createWrapper(queryClient), + }); + + result.current.mutate({ workflowId: "wf_1" }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(global.fetch).toHaveBeenCalledWith( + "/api/v3/workflows/wf_1/unarchive", + expect.objectContaining({ method: "POST", cache: "no-store" }) + ); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workflowKeys.lists() }); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.ts b/apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.ts new file mode 100644 index 000000000000..31cb3adda83c --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-unarchive-workflow.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { unarchiveWorkflow } from "../lib/api-client"; +import { workflowKeys } from "../lib/query"; + +/** + * Restore an archived workflow. Unlike archive/delete, this does not optimistically remove the row: + * the workflow stays in the list with a new (non-archived) status, and whether it remains visible + * depends on the active filters — so the list query is invalidated and refetched for the truth. + */ +export const useUnarchiveWorkflow = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ workflowId }: { workflowId: string }) => unarchiveWorkflow(workflowId), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }); + }, + }); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-builder.test.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-builder.test.ts new file mode 100644 index 000000000000..484db0e55951 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-builder.test.ts @@ -0,0 +1,734 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { Provider, createStore } from "jotai"; +import { type ReactNode, createElement } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { TWorkflowResource } from "@formbricks/workflows"; +import { V3ApiError } from "@/modules/api/lib/v3-client"; +import { + hasWorkflowSaveFailedAtom, + hydrateWorkflowEditorAtom, + isWorkflowDirtyAtom, + setWorkflowNameAtom, + workflowNameAtom, + workflowSaveErrorAtom, +} from "@/modules/ee/workflows/state/editor"; +import { useWorkflowBuilder } from "./use-workflow-builder"; + +// What a dropped connection actually throws — the string this must never put in front of a user. +const offlineError = () => new TypeError("NetworkError when attempting to fetch resource."); + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +vi.mock("react-hot-toast", () => ({ + default: { success: (msg: string) => toastSuccess(msg), error: (msg: string) => toastError(msg) }, +})); +vi.mock("react-i18next", () => { + // Stable across renders: the load effect depends on `t`, and a per-render identity would + // re-fetch + re-hydrate on every render, wiping the dirty draft the autosave tests rely on. + const translation = { t: (key: string) => key }; + return { useTranslation: () => translation }; +}); + +const routerRefresh = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: routerRefresh }), +})); + +const getWorkflow = vi.fn(); +const updateWorkflow = vi.fn(); +const enableWorkflow = vi.fn(); +const disableWorkflow = vi.fn(); +const archiveWorkflow = vi.fn(); +const unarchiveWorkflow = vi.fn(); +vi.mock("@/modules/ee/workflows/lib/api-client", () => ({ + // Real value, not a stub: the deferred-flush hard stop is derived from it, and an undefined here + // would make that setTimeout fire immediately and cancel the wait under test. + MUTATION_TIMEOUT_MS: 15_000, + getWorkflow: (...args: unknown[]) => getWorkflow(...args), + updateWorkflow: (...args: unknown[]) => updateWorkflow(...args), + enableWorkflow: (...args: unknown[]) => enableWorkflow(...args), + disableWorkflow: (...args: unknown[]) => disableWorkflow(...args), + archiveWorkflow: (...args: unknown[]) => archiveWorkflow(...args), + unarchiveWorkflow: (...args: unknown[]) => unarchiveWorkflow(...args), +})); + +vi.mock("@/modules/ee/workflows/lib/definition-to-flow", () => ({ + workflowDefinitionToFlowNodes: () => [], +})); + +// safeParse mimics real zod normalization: it returns a REBUILT object (defaults applied, keys in +// schema order), never the input reference. The autosave dirty-tracking must stay immune to that — +// see "a normalizing schema parse..." below. +vi.mock("@formbricks/workflows", () => ({ + ZWorkflowDefinition: { + safeParse: (value: unknown) => ({ + success: true, + data: { schemaVersion: 1, ...(value as Record) }, + }), + }, +})); + +const apiWorkflow = { + id: "wf-api", + name: "From API", + description: "Desc", + status: "draft", + definition: { trigger: { id: "trigger-1" }, nodes: [], edges: [] }, +} as unknown as TWorkflowResource; + +// Pass `injectedStore` to simulate a remount: the real store lives in the (detail) layout, so it +// survives the editor page unmounting on an edit ↔ runs tab switch. +const renderBuilder = ( + args: Parameters[0], + injectedStore?: ReturnType +) => { + const store = injectedStore ?? createStore(); + const wrapper = ({ children }: { children: ReactNode }) => createElement(Provider, { store }, children); + return { store, ...renderHook(() => useWorkflowBuilder(args), { wrapper }) }; +}; + +beforeEach(() => { + toastSuccess.mockClear(); + toastError.mockClear(); + routerRefresh.mockClear(); + getWorkflow.mockReset(); + updateWorkflow.mockReset(); + enableWorkflow.mockReset(); + disableWorkflow.mockReset(); + archiveWorkflow.mockReset(); + unarchiveWorkflow.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("load", () => { + test("fetches via API and hydrates the editor", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + expect(getWorkflow).toHaveBeenCalledWith("wf-api", expect.any(AbortSignal)); + }); + + test("surfaces load error via toast and loadError, without leaking the raw message", async () => { + getWorkflow.mockRejectedValue(offlineError()); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + + await waitFor(() => expect(result.current.loadError).toBe("workspace.workflows.load_failed")); + expect(toastError).toHaveBeenCalledWith("workspace.workflows.load_failed"); + }); + + test("skips load when loadOnMount is false", () => { + renderBuilder({ workflowId: "wf-api", isReadOnly: false, loadOnMount: false }); + + expect(getWorkflow).not.toHaveBeenCalled(); + }); +}); + +describe("canEdit flags", () => { + test.each([ + ["draft", false, true, true], + ["disabled", false, true, true], + ["enabled", false, false, true], + ["archived", false, false, false], + ["draft", true, false, false], + ] as const)( + "status=%s isReadOnly=%s → canEditDefinition=%s canEditMetadata=%s", + async (status, isReadOnly, expectedDef, expectedMeta) => { + getWorkflow.mockResolvedValue({ ...apiWorkflow, status }); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly }); + + await waitFor(() => expect(result.current.workflow).toBeTruthy()); + expect(result.current.canEditDefinition).toBe(expectedDef); + expect(result.current.canEditMetadata).toBe(expectedMeta); + } + ); +}); + +describe("save", () => { + test("rejects empty name with a toast", async () => { + getWorkflow.mockResolvedValue({ ...apiWorkflow, name: " " }); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow).toBeTruthy()); + + await act(async () => { + await result.current.save(); + }); + + expect(toastError).toHaveBeenCalledWith("workspace.workflows.name_required"); + expect(updateWorkflow).not.toHaveBeenCalled(); + }); + + test("reports a missing name even while another save is in flight", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + // Never settles, so the first save stays in flight and the overlap guard stays armed. + updateWorkflow.mockReturnValue(new Promise(() => undefined)); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(async () => { + void result.current.save(); + }); + await waitFor(() => expect(result.current.isSaving).toBe(true)); + expect(updateWorkflow).toHaveBeenCalledTimes(1); + + await act(async () => { + store.set(setWorkflowNameAtom, " "); + }); + await act(async () => { + await expect(result.current.save()).resolves.toBe(false); + }); + + // The overlap guard must not swallow the validation message: the title field commits with + // Enter, so a silent refusal here reads as "Enter did nothing" all over again. + expect(toastError).toHaveBeenCalledWith("workspace.workflows.name_required"); + // Still no second PATCH — the guard's actual job is intact. + expect(updateWorkflow).toHaveBeenCalledTimes(1); + }); + + test("PATCHes via API", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockResolvedValue(apiWorkflow); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(async () => { + await result.current.save(); + }); + + expect(updateWorkflow).toHaveBeenCalledWith("wf-api", expect.objectContaining({ name: "From API" })); + expect(toastSuccess).toHaveBeenCalledWith("workspace.workflows.save_success"); + // Server-resolved props (email authoring context) must catch up with the saved definition. + expect(routerRefresh).toHaveBeenCalled(); + }); + + test("reports a transport failure with friendly copy, not the browser's message", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(offlineError()); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(async () => { + await result.current.save(); + }); + + expect(toastError).toHaveBeenCalledWith("workspace.workflows.save_failed"); + expect(routerRefresh).not.toHaveBeenCalled(); + }); + + test("surfaces the server's detail when the API rejects the draft", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(new V3ApiError({ status: 422, detail: "Definition is invalid." })); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(async () => { + await result.current.save(); + }); + + expect(toastError).toHaveBeenCalledWith("Definition is invalid."); + }); + + test("does not flag a failed state for a draft that never left the client", async () => { + getWorkflow.mockResolvedValue({ ...apiWorkflow, name: " " }); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow).toBeTruthy()); + + await act(async () => { + await result.current.save({ silent: true }); + }); + + // "Save failed" means the server refused it. An unfinished draft is the validation UI's job. + expect(updateWorkflow).not.toHaveBeenCalled(); + expect(store.get(workflowSaveErrorAtom)).toBeNull(); + }); +}); + +describe("silent save (autosave mode)", () => { + test("saves without a success toast", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockResolvedValue(apiWorkflow); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(async () => { + await result.current.save({ silent: true }); + }); + + expect(updateWorkflow).toHaveBeenCalledWith("wf-api", expect.objectContaining({ name: "From API" })); + expect(toastSuccess).not.toHaveBeenCalled(); + }); + + test("skips an invalid draft quietly instead of toasting", async () => { + getWorkflow.mockResolvedValue({ ...apiWorkflow, name: " " }); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow).toBeTruthy()); + + await act(async () => { + await result.current.save({ silent: true }); + }); + + expect(updateWorkflow).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); + + test("records a silent failure as persistent state instead of a toast", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(offlineError()); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(async () => { + await result.current.save({ silent: true }); + }); + + // A toast here would fade before the user noticed it; the header pill reads the state instead + // and stays failed until a save lands. + expect(toastError).not.toHaveBeenCalled(); + expect(store.get(workflowSaveErrorAtom)).toEqual( + expect.objectContaining({ kind: "unreachable", detail: null }) + ); + }); +}); + +describe("autosave", () => { + test("persists a dirty draft after the debounce window without toasting", { timeout: 10000 }, async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockResolvedValue(apiWorkflow); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Renamed by autosave"); + }); + + // Debounced: nothing is sent synchronously with the edit. + expect(updateWorkflow).not.toHaveBeenCalled(); + await waitFor( + () => + expect(updateWorkflow).toHaveBeenCalledWith( + "wf-api", + expect.objectContaining({ name: "Renamed by autosave" }) + ), + { timeout: 4000 } + ); + expect(toastSuccess).not.toHaveBeenCalled(); + await waitFor(() => expect(result.current.isDirty).toBe(false)); + }); + + test("collapses a typing burst into one save carrying the final value", { timeout: 15000 }, async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockResolvedValue(apiWorkflow); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + // Keystrokes spaced under the debounce window: each one must restart the timer rather than + // queue its own PATCH. + for (const name of ["N", "Na", "Nam", "Name"]) { + act(() => { + store.set(setWorkflowNameAtom, name); + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + expect(updateWorkflow).not.toHaveBeenCalled(); + + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1), { timeout: 4000 }); + // The first PATCH carries "Name" either way — save() reads the store, not the timer's closure — + // so settle past a full debounce window: an uncancelled timer would land its own call here. + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(updateWorkflow).toHaveBeenCalledTimes(1); + expect(updateWorkflow).toHaveBeenCalledWith("wf-api", expect.objectContaining({ name: "Name" })); + }); + + test("does not autosave for read-only viewers", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: true }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Renamed"); + }); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(updateWorkflow).not.toHaveBeenCalled(); + }); + + test( + "a normalizing schema parse does not leave the draft permanently dirty (no autosave loop)", + { timeout: 15000 }, + async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockResolvedValue(apiWorkflow); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Edited once"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1), { timeout: 4000 }); + + // The parsed payload differs structurally from the editor state (schemaVersion default), + // but the saved snapshot must be the editor state itself — clean, no repeat saves. + await waitFor(() => expect(result.current.isDirty).toBe(false)); + await new Promise((resolve) => setTimeout(resolve, 2600)); + expect(updateWorkflow).toHaveBeenCalledTimes(1); + } + ); + + test("does not retry a failed autosave until the draft changes again", { timeout: 15000 }, async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(new Error("persistent 500")); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Doomed edit"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1), { timeout: 4000 }); + + // The same draft is not retried on the next debounce window (no PATCH/toast loop)… + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(updateWorkflow).toHaveBeenCalledTimes(1); + + // …but a further edit produces a fresh attempt. + act(() => { + store.set(setWorkflowNameAtom, "Doomed edit, take two"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(2), { timeout: 4000 }); + }); + + test("retries the failed draft when connectivity returns", { timeout: 15000 }, async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(offlineError()); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Edited while offline"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1), { timeout: 4000 }); + await waitFor(() => expect(store.get(workflowSaveErrorAtom)?.kind).toBe("unreachable")); + + // The guard holds the identical draft back while nothing has changed… + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(updateWorkflow).toHaveBeenCalledTimes(1); + + // …until the browser reports it is back online, which clears the guard and re-arms the debounce + // without the user having to touch anything. + updateWorkflow.mockResolvedValue(apiWorkflow); + act(() => { + window.dispatchEvent(new Event("online")); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(2), { timeout: 4000 }); + await waitFor(() => expect(store.get(workflowSaveErrorAtom)).toBeNull()); + expect(result.current.isDirty).toBe(false); + }); + + test("does not retry a rejected draft when connectivity returns", { timeout: 15000 }, async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(new V3ApiError({ status: 422, detail: "Definition is invalid." })); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Rejected edit"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1), { timeout: 4000 }); + await waitFor(() => expect(store.get(workflowSaveErrorAtom)?.kind).toBe("rejected")); + + // Being back online says nothing about a draft the API actively refused; re-sending it would + // just fail again on every network flap. + act(() => { + window.dispatchEvent(new Event("online")); + }); + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(updateWorkflow).toHaveBeenCalledTimes(1); + expect(store.get(workflowSaveErrorAtom)?.detail).toBe("Definition is invalid."); + }); + + test("clears the failed state once a save succeeds", { timeout: 15000 }, async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValueOnce(offlineError()).mockResolvedValue(apiWorkflow); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "First attempt"); + }); + await waitFor(() => expect(store.get(hasWorkflowSaveFailedAtom)).toBe(true), { timeout: 4000 }); + + act(() => { + store.set(setWorkflowNameAtom, "Second attempt"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(2), { timeout: 4000 }); + + await waitFor(() => expect(store.get(workflowSaveErrorAtom)).toBeNull()); + expect(store.get(hasWorkflowSaveFailedAtom)).toBe(false); + expect(result.current.isDirty).toBe(false); + }); + + test("flushes a dirty draft on unmount instead of dropping the debounce window", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockResolvedValue(apiWorkflow); + + const { result, store, unmount } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Edited just before leaving"); + }); + expect(updateWorkflow).not.toHaveBeenCalled(); + + unmount(); + + expect(updateWorkflow).toHaveBeenCalledWith( + "wf-api", + expect.objectContaining({ name: "Edited just before leaving" }) + ); + }); + + test("flushes edits typed during an in-flight save instead of dropping them", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + let releaseFirstSave: () => void = () => undefined; + updateWorkflow + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstSave = () => resolve(apiWorkflow); + }) + ) + .mockResolvedValue(apiWorkflow); + + const { result, store, unmount } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + // Autosave fires for the first edit and its PATCH stays open (a slow network, up to the 15s + // mutation timeout). + act(() => { + store.set(setWorkflowNameAtom, "In flight"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1), { timeout: 4000 }); + + // The user keeps typing while that write is open, then navigates away. + act(() => { + store.set(setWorkflowNameAtom, "Typed during the save"); + }); + unmount(); + + // Still only the open write: save() refuses to overlap it, and this is the window where the + // newer edits used to be dropped with no error at all, fully online. + expect(updateWorkflow).toHaveBeenCalledTimes(1); + + await act(async () => { + releaseFirstSave(); + }); + + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(2)); + }); + + // Pins the guarantee, not one mechanism: today the deferred flush self-terminates because + // hydrating another workflow clears isSaving and yields a clean draft. If that ever stops being + // true, a flush armed for one workflow could PATCH another — this test is the fence. + test("never lets a deferred flush PATCH a workflow it was not armed for", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + let releaseFirstSave: () => void = () => undefined; + updateWorkflow + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstSave = () => resolve(apiWorkflow); + }) + ) + .mockResolvedValue(apiWorkflow); + + const store = createStore(); + const { result, unmount } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }, store); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "In flight"); + }); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1), { timeout: 4000 }); + + act(() => { + store.set(setWorkflowNameAtom, "Typed during the save"); + }); + unmount(); + + // The layout (and this store) outlive the editor page, so a different workflow can land here + // before the pending write settles. + act(() => { + store.set(hydrateWorkflowEditorAtom, { + workflow: { ...apiWorkflow, id: "wf-other", name: "Another workflow" }, + flowNodes: [], + }); + store.set(setWorkflowNameAtom, "Edited on the other workflow"); + }); + + await act(async () => { + releaseFirstSave(); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(updateWorkflow).toHaveBeenCalledTimes(1); + expect(updateWorkflow).not.toHaveBeenCalledWith("wf-other", expect.anything()); + }); + + test("keeps an unsaved draft when the same workflow re-mounts", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(offlineError()); + const store = createStore(); + + const first = renderBuilder({ workflowId: "wf-api", isReadOnly: false }, store); + await waitFor(() => expect(first.result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Survives the tab switch"); + }); + await waitFor(() => expect(store.get(hasWorkflowSaveFailedAtom)).toBe(true), { timeout: 4000 }); + first.unmount(); + + // Coming back to the edit tab: same workflow, same store, a fresh page instance that refetches. + const second = renderBuilder({ workflowId: "wf-api", isReadOnly: false }, store); + + // No skeleton over work that never left — the editor already holds this workflow. + expect(second.result.current.isLoading).toBe(false); + + await waitFor(() => expect(getWorkflow).toHaveBeenCalledTimes(2)); + // Hydrating here would rebuild from initialWorkflowEditorState and re-seed lastSavedDraft from + // the server, destroying the draft AND making it read clean. + expect(store.get(workflowNameAtom)).toBe("Survives the tab switch"); + expect(store.get(isWorkflowDirtyAtom)).toBe(true); + second.unmount(); + }); + + test("re-hydrates on remount when the draft is clean", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + const store = createStore(); + + const first = renderBuilder({ workflowId: "wf-api", isReadOnly: false }, store); + await waitFor(() => expect(first.result.current.workflow?.id).toBe("wf-api")); + first.unmount(); + + // Nothing local to protect, so a remount must still pick up changes made elsewhere. + getWorkflow.mockResolvedValue({ ...apiWorkflow, name: "Renamed on the server", status: "enabled" }); + const second = renderBuilder({ workflowId: "wf-api", isReadOnly: false }, store); + + await waitFor(() => expect(store.get(workflowNameAtom)).toBe("Renamed on the server")); + expect(second.result.current.isEnabled).toBe(true); + expect(store.get(isWorkflowDirtyAtom)).toBe(false); + second.unmount(); + }); +}); + +describe("transition", () => { + test("flushes a dirty draft before enabling", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockResolvedValue(apiWorkflow); + enableWorkflow.mockResolvedValue({ ...apiWorkflow, status: "enabled" }); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Renamed before enable"); + }); + await act(() => result.current.enable()); + + expect(updateWorkflow).toHaveBeenCalledWith( + "wf-api", + expect.objectContaining({ name: "Renamed before enable" }) + ); + expect(enableWorkflow).toHaveBeenCalledWith("wf-api"); + expect(updateWorkflow.mock.invocationCallOrder[0]).toBeLessThan( + enableWorkflow.mock.invocationCallOrder[0] + ); + }); + + test("enable calls the API + success toast", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + enableWorkflow.mockResolvedValue(apiWorkflow); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(() => result.current.enable()); + + await waitFor(() => expect(enableWorkflow).toHaveBeenCalledWith("wf-api")); + expect(toastSuccess).toHaveBeenCalledWith("workspace.workflows.enable_success"); + }); + + test.each([ + ["disable", disableWorkflow, "disable_success"], + ["archive", archiveWorkflow, "archive_success"], + ["unarchive", unarchiveWorkflow, "unarchive_success"], + ] as const)("%s calls the API + success toast", async (op, mock, successKey) => { + getWorkflow.mockResolvedValue(apiWorkflow); + mock.mockResolvedValue(apiWorkflow); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(() => result.current[op]()); + + await waitFor(() => expect(mock).toHaveBeenCalledWith("wf-api")); + expect(toastSuccess).toHaveBeenCalledWith(`workspace.workflows.${successKey}`); + }); + + test("API failure surfaces a failure toast", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + archiveWorkflow.mockRejectedValue(offlineError()); + + const { result } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + await act(async () => { + await result.current.archive(); + }); + + expect(toastError).toHaveBeenCalledWith("workspace.workflows.archive_failed"); + }); + + test("blocks enable with a toast when the pre-flight flush fails", async () => { + getWorkflow.mockResolvedValue(apiWorkflow); + updateWorkflow.mockRejectedValue(offlineError()); + + const { result, store } = renderBuilder({ workflowId: "wf-api", isReadOnly: false }); + await waitFor(() => expect(result.current.workflow?.id).toBe("wf-api")); + + act(() => { + store.set(setWorkflowNameAtom, "Renamed but unsaveable"); + }); + await act(() => result.current.enable()); + + // Enabling would publish a definition other than the one on screen. The flush is silent, so + // without this toast the click would look like it did nothing at all. + expect(enableWorkflow).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith("workspace.workflows.enable_blocked_unsaved_changes"); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-builder.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-builder.ts new file mode 100644 index 000000000000..65038e94ac60 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-builder.ts @@ -0,0 +1,466 @@ +"use client"; + +import { useAtomValue, useSetAtom, useStore } from "jotai"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { + type TPatchWorkflowInput, + type TWorkflowDefinition, + type TWorkflowResource, + ZWorkflowDefinition, +} from "@formbricks/workflows"; +import { V3ApiError } from "@/modules/api/lib/v3-client"; +import { + MUTATION_TIMEOUT_MS, + archiveWorkflow, + disableWorkflow, + enableWorkflow, + getWorkflow, + unarchiveWorkflow, + updateWorkflow, +} from "@/modules/ee/workflows/lib/api-client"; +import { classifyWorkflowSaveError, getWorkflowApiErrorMessage } from "@/modules/ee/workflows/lib/api-error"; +import { workflowDefinitionToFlowNodes } from "@/modules/ee/workflows/lib/definition-to-flow"; +import { + hydrateWorkflowEditorAtom, + isWorkflowDirtyAtom, + isWorkflowSavingAtom, + isWorkflowTransitioningAtom, + markWorkflowDraftSavedAtom, + setWorkflowAtom, + setWorkflowSaveErrorAtom, + setWorkflowSavingAtom, + setWorkflowTransitioningAtom, + workflowAtom, + workflowDefinitionAtom, + workflowDraftSignatureAtom, + workflowEditorAtom, + workflowSaveErrorAtom, +} from "@/modules/ee/workflows/state/editor"; + +interface UseWorkflowBuilderArgs { + workflowId: string; + isReadOnly: boolean; + /** + * URL workspace. Used to assert the loaded workflow belongs to the workspace in the URL — + * the API authorizes by the workflow's own workspaceId, so without this check a workflow + * URL on the wrong workspace would still render. Optional for callers that only need + * actions + atom state (e.g. the header CTA passes loadOnMount: false and skips this). + */ + workspaceId?: string; + /** + * When true, the hook fetches the workflow on mount. The page-level builder owns the load; + * components that only need actions + atom state (e.g. the layout header CTA) pass false. + */ + loadOnMount?: boolean; +} + +// Long enough to batch a typing burst into one PATCH, short enough that edits are on the server +// before the user reaches for Test or navigates away. +const WORKFLOW_AUTOSAVE_DELAY_MS = 2000; + +/** Why a draft can't be sent at all. Distinct from a save the API refused — nothing was attempted. */ +type TInvalidWorkflowDraft = { code: "name_required" | "definition"; detail?: string }; + +/** + * Builds the PATCH body for the current draft, or reports why it can't be sent. Lifted out of save() + * so the wire-format rules sit in one place and save() stays inside the cognitive-complexity budget. + */ +const buildWorkflowPatch = (state: { + workflow: TWorkflowResource; + workflowName: string; + workflowDescription: string; + definition: TWorkflowDefinition; +}): + | { patch: TPatchWorkflowInput; trimmedName: string; trimmedDescription: string | null } + | { invalid: TInvalidWorkflowDraft } => { + const trimmedName = state.workflowName.trim(); + if (!trimmedName) return { invalid: { code: "name_required" } }; + + const trimmedDescription = state.workflowDescription.trim() || null; + const patch: TPatchWorkflowInput = { name: trimmedName, description: trimmedDescription }; + + // Only include the definition in the PATCH when the API will accept it. Sending it while the + // workflow is enabled would return a 422; disable first. + if (state.workflow.status !== "enabled") { + const parsedDefinition = ZWorkflowDefinition.safeParse(state.definition); + if (!parsedDefinition.success) { + return { invalid: { code: "definition", detail: parsedDefinition.error.issues[0]?.message } }; + } + patch.definition = parsedDefinition.data; + } + + return { patch, trimmedName, trimmedDescription }; +}; + +const describeInvalidDraft = (invalid: TInvalidWorkflowDraft, t: (key: string) => string): string => { + if (invalid.code === "name_required") return t("workspace.workflows.name_required"); + // Zod's first issue is the most specific thing we can say; fall back when it carries no message. + return invalid.detail ?? t("workspace.workflows.validation_failed"); +}; + +export const useWorkflowBuilder = ({ + workspaceId, + workflowId, + isReadOnly, + loadOnMount = true, +}: UseWorkflowBuilderArgs) => { + const { t } = useTranslation(); + const router = useRouter(); + const store = useStore(); + const workflow = useAtomValue(workflowAtom); + const definition = useAtomValue(workflowDefinitionAtom); + const isDirty = useAtomValue(isWorkflowDirtyAtom); + const hydrateEditor = useSetAtom(hydrateWorkflowEditorAtom); + const setWorkflow = useSetAtom(setWorkflowAtom); + const markDraftSaved = useSetAtom(markWorkflowDraftSavedAtom); + const isSaving = useAtomValue(isWorkflowSavingAtom); + const isTransitioning = useAtomValue(isWorkflowTransitioningAtom); + const setIsSaving = useSetAtom(setWorkflowSavingAtom); + const setIsTransitioning = useSetAtom(setWorkflowTransitioningAtom); + // Subscribed (not read through the store) on purpose: clearing the save error has to re-run the + // autosave effect below, which is what lets the `online` listener re-arm a retry. + const saveError = useAtomValue(workflowSaveErrorAtom); + const draftSignature = useAtomValue(workflowDraftSignatureAtom); + const setSaveError = useSetAtom(setWorkflowSaveErrorAtom); + + const [isFetching, setIsFetching] = useState(loadOnMount); + const [loadError, setLoadError] = useState(null); + // "Loading" means there is nothing correct to render yet, not merely that a request is in flight. + // On a remount for the same workflow (the edit ↔ runs tab round-trip) the store still holds it, so + // the editor stays on screen rather than flashing a skeleton over work that never left. + const isLoading = isFetching && workflow?.id !== workflowId; + + // Reload on workflowId change; abort in-flight fetches when the page navigates away. + useEffect(() => { + if (!loadOnMount) return; + const controller = new AbortController(); + setIsFetching(true); + setLoadError(null); + + /** + * Whether the editor already holds unsaved edits for the workflow being fetched. Re-read at each + * use rather than captured: a draft can be saved (or newly edited) while the fetch is in flight. + */ + const holdsUnsavedDraft = () => + store.get(workflowEditorAtom).workflow?.id === workflowId && store.get(isWorkflowDirtyAtom); + + getWorkflow(workflowId, controller.signal) + .then((loadedWorkflow) => { + // A fetch that resolved just before the effect aborted (fast workflowId nav) would + // otherwise hydrate the stale workflow over the one now loading. .catch/.finally + // already guard on aborted; mirror that here. + if (controller.signal.aborted) return; + // The API authorizes against the workflow's own workspaceId; reject if the URL + // workspace doesn't match so we don't render a workflow under the wrong shell. + if (workspaceId && loadedWorkflow.workspaceId !== workspaceId) { + const message = t("workspace.workflows.load_failed"); + setLoadError(message); + toast.error(message); + return; + } + // Remounting the editor over an unsaved draft would destroy it: hydrate rebuilds from + // initialWorkflowEditorState and re-seeds lastSavedDraft from the server, so the edits + // vanish AND stop reading dirty — no toast, no pill, nothing to notice. Refresh only the + // server-owned snapshot (setWorkflowAtom leaves the editable draft alone by design) and let + // the autosave effect retry the draft it can still see. + if (holdsUnsavedDraft()) { + setWorkflow(loadedWorkflow); + return; + } + hydrateEditor({ + workflow: loadedWorkflow, + flowNodes: workflowDefinitionToFlowNodes(loadedWorkflow.definition, t), + }); + }) + .catch((error) => { + if (controller.signal.aborted) return; + const message = getWorkflowApiErrorMessage(error, t("workspace.workflows.load_failed")); + setLoadError(message); + toast.error(message); + }) + .finally(() => { + if (controller.signal.aborted) return; + setIsFetching(false); + }); + + return () => controller.abort(); + }, [workspaceId, workflowId, hydrateEditor, setWorkflow, store, t, loadOnMount]); + + const isArchived = workflow?.status === "archived"; + const isEnabled = workflow?.status === "enabled"; + // Definition edits are blocked by the API while the workflow is enabled or archived + // (see workflows.handlers.ts:patch). Metadata (name/description) edits are still allowed + // while enabled, so we expose two flags so the inspector/header gate correctly. + const canEditDefinition = Boolean(workflow && !isReadOnly && !isEnabled && !isArchived); + const canEditMetadata = Boolean(workflow && !isReadOnly && !isArchived); + + // Reads atom values via the store so callers can save() immediately after an atom write; + // useCallback closures otherwise pin the stale definition until the next render, which would + // drop the just-edited node from the PATCH payload. + // `silent` is the autosave mode: validation problems skip the save quietly (the editor already + // surfaces them live via workflowValidityAtom) and success produces no toast. + // Resolves true only when the draft was actually persisted. + const save = useCallback( + async ({ silent = false }: { silent?: boolean } = {}): Promise => { + const state = store.get(workflowEditorAtom); + const currentWorkflow = state.workflow; + const currentDefinition = state.definition; + if (!currentWorkflow || !currentDefinition) return false; + + // Built ahead of the overlap guard below so an explicit save always explains why it did + // nothing. Behind it, clearing the name while a save or transition was in flight returned + // silently — and a title rename committed with Enter would look like it did nothing. + const built = buildWorkflowPatch({ + workflow: currentWorkflow, + workflowName: state.workflowName, + workflowDescription: state.workflowDescription, + definition: currentDefinition, + }); + // An unsendable draft is not a failed save: nothing was attempted, so no saveError is + // recorded, and a silent autosave stays quiet because the editor already surfaces validation + // problems live via workflowValidityAtom. + if ("invalid" in built) { + if (!silent) toast.error(describeInvalidDraft(built.invalid, t)); + return false; + } + const { patch, trimmedName, trimmedDescription } = built; + + // Don't overlap with an in-flight save or lifecycle transition; a save landing during an + // enable/disable can clobber the transitioned status (and vice versa). + if (store.get(isWorkflowSavingAtom) || store.get(isWorkflowTransitioningAtom)) return false; + + // Read before the await so a failure records the draft that was actually sent, not whatever + // the user has typed by the time the request comes back. + const attemptedSignature = store.get(workflowDraftSignatureAtom); + + setIsSaving(true); + try { + const savedWorkflow = await updateWorkflow(currentWorkflow.id, patch); + setWorkflow(savedWorkflow); + // Snapshot the EDITOR STATE captured at send time (not re-read, so edits that landed + // while the PATCH was in flight still count as dirty). Deliberately the raw + // currentDefinition rather than the parsed payload: dirty tracking asks "did the user + // change anything since the last save", and zod normalization (defaults, stripped legacy + // keys, shape-ordered keys) would make a parsed snapshot never compare equal to the + // state it was parsed from — leaving the editor permanently dirty and autosave looping. + markDraftSaved({ + workflowName: trimmedName, + workflowDescription: trimmedDescription ?? "", + definition: currentDefinition, + }); + // Re-run the server loaders so server-resolved props (e.g. the email authoring context, + // which resolves the trigger's bound survey) catch up with the just-saved definition. + // Client state (atoms, form drafts) survives a refresh. + router.refresh(); + if (!silent) toast.success(t("workspace.workflows.save_success")); + return true; + } catch (error) { + // Persistent state instead of a toast that fades before the user notices (ENG-1970): the + // header pill reads this and stays failed until a save lands. Written here rather than from + // the autosave effect's callback so it batches with the setIsSaving(false) below — the + // effect then re-runs seeing both, with no reliance on microtask-vs-render ordering. + // Doubles as the effect's no-retry guard, keeping a broken draft from looping one PATCH per + // debounce window. + setSaveError({ + draftSignature: attemptedSignature, + kind: classifyWorkflowSaveError(error), + detail: error instanceof V3ApiError ? error.detail : null, + }); + if (!silent) toast.error(getWorkflowApiErrorMessage(error, t("workspace.workflows.save_failed"))); + return false; + } finally { + setIsSaving(false); + } + }, + [store, setWorkflow, markDraftSaved, setIsSaving, setSaveError, router, t] + ); + + // Autosave: the page-level instance (loadOnMount) persists any dirty draft shortly after the + // user stops editing. Effect deps include the draft signature so each keystroke resets the timer + // (debounce), and isSaving so a save finishing re-arms it when edits piled up mid-flight. + // Silent mode keeps validation noise out of the way while the user is mid-edit; a failure is + // reported by the header pill instead (see save()). + useEffect(() => { + if (!loadOnMount || isReadOnly) return; + if (!isDirty || isSaving || isTransitioning) return; + if (!workflow || workflow.status === "archived") return; + // Never re-send a draft the server already refused: without this a persistent API failure would + // loop one PATCH per debounce window forever. Any further edit changes the signature, and a + // reconnect clears the error outright (below) — either way the next attempt is a fresh one. + if (saveError?.draftSignature === draftSignature) return; + + const timeoutHandle = setTimeout(() => void save({ silent: true }), WORKFLOW_AUTOSAVE_DELAY_MS); + return () => clearTimeout(timeoutHandle); + }, [ + loadOnMount, + isReadOnly, + isDirty, + isSaving, + isTransitioning, + workflow, + draftSignature, + saveError, + save, + ]); + + // Reconnecting is the one event worth retrying on its own: the guard above deliberately refuses to + // re-send the same draft, so an offline failure would otherwise sit there until the user happened + // to type again. Clearing the error re-arms the debounced effect, which re-checks + // dirty/archived/transitioning itself — no retry logic lives here. Only "unreachable" qualifies; + // being back online says nothing about a draft the API actively rejected. navigator.onLine is a + // nudge, never what the pill reads: a wrong "online" costs exactly one PATCH, which then either + // succeeds or records the failure again. + useEffect(() => { + if (!loadOnMount || isReadOnly) return; + const handleOnline = () => { + if (store.get(workflowSaveErrorAtom)?.kind !== "unreachable") return; + setSaveError(null); + }; + window.addEventListener("online", handleOnline); + return () => window.removeEventListener("online", handleOnline); + }, [loadOnMount, isReadOnly, store, setSaveError]); + + // Flush on unmount: the debounce window above means the freshest edits may not be persisted + // yet when the user navigates away (tab switch, back navigation). Routed through a ref (kept + // current after every render) so the empty-dep cleanup runs at unmount only, never on + // re-renders. The result isn't awaited, but it isn't lost either: save() records a failure into + // the editor store, which outlives this page whenever the surrounding layout does. + const flushOnUnmountRef = useRef<() => void>(() => undefined); + // Deliberately dependency-less: refreshes the closure on every commit. Writing the ref here + // rather than during render keeps it out of the render phase. + useEffect(() => { + flushOnUnmountRef.current = () => { + if (!loadOnMount || isReadOnly) return; + const state = store.get(workflowEditorAtom); + if (!state.workflow || state.workflow.status === "archived") return; + if (!store.get(isWorkflowDirtyAtom)) return; + + if (!state.isSaving && !state.isTransitioning) { + void save({ silent: true }); + return; + } + // A write is already in flight, carrying the snapshot taken when it started — anything typed + // since is only in the draft. save() refuses to overlap it and there is no render left to + // re-arm the debounce, so firing now would drop those edits silently, even fully online. + // Wait for the write to settle instead, then send what is still unsaved. Subscribing to the + // store rather than the promise keeps save() untouched, and the store belongs to the + // surrounding layout so it outlives this page. + // + // This store survives navigating between workflows, but the wait cannot leak across one: the + // first write that clears isSaving/isTransitioning unsubscribes, and hydrating another + // workflow is itself such a write (hydrate rebuilds from initialWorkflowEditorState, so + // isSaving resets to false and the fresh draft reads clean). Hence no workflow-id guard here + // — it would be unreachable. + const unsubscribe = store.sub(workflowEditorAtom, () => { + const current = store.get(workflowEditorAtom); + if (current.isSaving || current.isTransitioning) return; + unsubscribe(); + // The in-flight write may have persisted exactly what was pending. + if (!store.get(isWorkflowDirtyAtom)) return; + void save({ silent: true }); + }); + // The mutation timeout bounds the request, not this listener: a write that somehow never + // settles would otherwise pin the subscription — and the unmounted page it closes over — for + // the rest of the session. Give up a little after the request itself would have. + setTimeout(unsubscribe, MUTATION_TIMEOUT_MS + WORKFLOW_AUTOSAVE_DELAY_MS); + }; + }); + useEffect(() => () => flushOnUnmountRef.current(), []); + + // Warn on hard refresh / tab close while edits are unsaved: unlike SPA navigation, a page + // unload kills the flush request above, so the native confirm is the only safety net. The + // browser shows its own generic copy; custom text is ignored by modern browsers. + useEffect(() => { + if (!loadOnMount || isReadOnly) return; + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!store.get(isWorkflowDirtyAtom)) return; + event.preventDefault(); + event.returnValue = ""; // NOSONAR(typescript:S1874) -- legacy Chromium still requires returnValue for the confirm dialog + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [loadOnMount, isReadOnly, store]); + + const transition = useCallback( + async (operation: "enable" | "disable" | "archive" | "unarchive") => { + if (!workflow) return; + // Serialize against a save or another transition in flight; overlapping lifecycle writes + // race and the last response to land wins, desyncing the displayed status from the server. + if (store.get(isWorkflowSavingAtom) || store.get(isWorkflowTransitioningAtom)) return; + + // Flush pending edits first so the lifecycle change acts on what the user sees; enable in + // particular validates the persisted definition, not the local draft. + if (store.get(isWorkflowDirtyAtom)) { + const flushed = await save({ silent: true }); + // Enabling after a failed flush would publish a definition other than the one on screen. + // Bail with a toast of its own — the flush is silent, so without this the click would look + // like it did nothing. The other transitions don't publish the definition, so a stale + // metadata draft doesn't block them. + if (!flushed && operation === "enable") { + toast.error(t("workspace.workflows.enable_blocked_unsaved_changes")); + return; + } + } + + // One dispatch table keeps the API call + i18n keys aligned per operation; the scanner can + // still see every literal `t("…")` key because they sit inline in the map below. + const lifecycleOps = { + enable: { + run: enableWorkflow, + success: () => t("workspace.workflows.enable_success"), + failure: () => t("workspace.workflows.enable_failed"), + }, + disable: { + run: disableWorkflow, + success: () => t("workspace.workflows.disable_success"), + failure: () => t("workspace.workflows.disable_failed"), + }, + archive: { + run: archiveWorkflow, + success: () => t("workspace.workflows.archive_success"), + failure: () => t("workspace.workflows.archive_failed"), + }, + unarchive: { + run: unarchiveWorkflow, + success: () => t("workspace.workflows.unarchive_success"), + failure: () => t("workspace.workflows.unarchive_failed"), + }, + } as const; + const op = lifecycleOps[operation]; + + setIsTransitioning(true); + try { + const transitioned = await op.run(workflow.id); + setWorkflow(transitioned); + toast.success(op.success()); + } catch (error) { + toast.error(getWorkflowApiErrorMessage(error, op.failure())); + } finally { + setIsTransitioning(false); + } + }, + [store, workflow, setWorkflow, setIsTransitioning, save, t] + ); + + return { + workflow, + definition, + isLoading, + loadError, + isSaving, + isDirty, + isTransitioning, + canEditDefinition, + canEditMetadata, + isArchived, + isEnabled, + save, + enable: () => transition("enable"), + disable: () => transition("disable"), + archive: () => transition("archive"), + unarchive: () => transition("unarchive"), + }; +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.test.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.test.ts new file mode 100644 index 000000000000..b9b3e372b928 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment jsdom + */ +import { renderHook, waitFor } from "@testing-library/react"; +import { Provider, createStore } from "jotai"; +import { type ReactNode, createElement } from "react"; +import { describe, expect, test, vi } from "vitest"; +import { + requestWorkflowNodeFieldFocusAtom, + workflowNodeFieldFocusRequestAtom, +} from "@/modules/ee/workflows/state/editor"; +import { useWorkflowNodeFieldFocus } from "./use-workflow-node-field-focus"; + +const renderWithStore = ( + store: ReturnType, + args: Parameters[0] +) => + renderHook(() => useWorkflowNodeFieldFocus(args), { + wrapper: ({ children }: { children: ReactNode }) => createElement(Provider, { store }, children), + }); + +/** A focusable stand-in for the control a jump targets. */ +const appendInput = (id: string) => { + const input = document.createElement("input"); + input.id = id; + input.scrollIntoView = vi.fn(); + document.body.appendChild(input); + return input; +}; + +describe("useWorkflowNodeFieldFocus", () => { + test("focuses the requested field, scrolls it into view, and clears the request", async () => { + const store = createStore(); + const input = appendInput("subject"); + const onRequest = vi.fn(); + + renderWithStore(store, { nodeId: "email-1", resolveElement: () => input, onRequest }); + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "email-1", field: "subject" }); + + await waitFor(() => expect(document.activeElement).toBe(input)); + expect(input.scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); + expect(onRequest).toHaveBeenCalledWith("subject"); + // Cleared only after focusing — otherwise the re-render would cancel the pending frame. + await waitFor(() => expect(store.get(workflowNodeFieldFocusRequestAtom)).toBeNull()); + }); + + test("ignores a request aimed at a different node", async () => { + const store = createStore(); + const input = appendInput("other"); + const resolveElement = vi.fn(() => input); + + renderWithStore(store, { nodeId: "email-1", resolveElement }); + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "trigger-1", field: "surveyId" }); + + await waitFor(() => expect(store.get(workflowNodeFieldFocusRequestAtom)).not.toBeNull()); + expect(resolveElement).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(input); + }); + + test("still clears the request when the field resolves to no element", async () => { + const store = createStore(); + renderWithStore(store, { nodeId: "email-1", resolveElement: () => null }); + + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "email-1", field: "gone" }); + + // A field whose control isn't mounted must not leave the request pending forever. + await waitFor(() => expect(store.get(workflowNodeFieldFocusRequestAtom)).toBeNull()); + }); + + test("uses the latest resolver without re-firing on unrelated re-renders", async () => { + const store = createStore(); + const first = appendInput("first"); + const second = appendInput("second"); + let target = first; + + const { rerender } = renderHook( + () => useWorkflowNodeFieldFocus({ nodeId: "email-1", resolveElement: () => target }), + { + wrapper: ({ children }: { children: ReactNode }) => createElement(Provider, { store }, children), + } + ); + + // Re-render with a new inline closure: the hook reads callbacks through a ref, so this must + // not queue a focus of its own. + target = second; + rerender(); + expect(document.activeElement).not.toBe(second); + + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "email-1", field: "any" }); + await waitFor(() => expect(document.activeElement).toBe(second)); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.ts new file mode 100644 index 000000000000..fe2f50881a52 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-node-field-focus.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useRef } from "react"; +import { + clearWorkflowNodeFieldFocusAtom, + workflowNodeFieldFocusRequestAtom, +} from "@/modules/ee/workflows/state/editor"; + +interface UseWorkflowNodeFieldFocusArgs { + /** Only requests aimed at this node are consumed. */ + nodeId: string; + /** Maps the requested field to the element to focus; null/undefined skips focusing. */ + resolveElement: (field: string) => HTMLElement | null | undefined; + /** + * Runs before the focus, with the requested field. Config forms use it to reveal the field-level + * errors the jump is meant to explain (the email step marks its blank fields touched). + */ + onRequest?: (field: string) => void; +} + +/** + * Consumes a pending "take me to the field that's wrong" jump for one node: focuses the field the + * validation problems dialog pointed at, scrolls it into view, then clears the request. + * + * Shared by every config form so the lifecycle exists once. Two details are easy to get wrong and + * are the reason this is a hook rather than copied code: + * + * 1. The focus runs one frame late, so the inspector's width transition has laid the panel out + * before we scroll — otherwise `scrollIntoView` measures a collapsing column. + * 2. The request is cleared INSIDE that frame, never before it. Clearing is what re-runs the + * effect, and clearing first would let the re-run's cleanup cancel the frame before it fired. + * + * `resolveElement`/`onRequest` are read through a ref, so callers may pass inline closures without + * the effect re-running (and re-focusing) on every render. + */ +export const useWorkflowNodeFieldFocus = ({ + nodeId, + resolveElement, + onRequest, +}: UseWorkflowNodeFieldFocusArgs): void => { + const focusRequest = useAtomValue(workflowNodeFieldFocusRequestAtom); + const clearFocusRequest = useSetAtom(clearWorkflowNodeFieldFocusAtom); + + const callbacksRef = useRef({ resolveElement, onRequest }); + // Refreshed in an effect rather than during render (writing a ref while rendering is not allowed). + // No dep array, so it runs after every render — and it is declared before the focus effect below, + // so within a commit the callbacks are already current by the time that one reads them. + useEffect(() => { + callbacksRef.current = { resolveElement, onRequest }; + }); + + useEffect(() => { + if (focusRequest?.nodeId !== nodeId) return; + const { field } = focusRequest; + callbacksRef.current.onRequest?.(field); + + // requestAnimationFrame does not fire in a backgrounded tab, so a jump triggered just before + // the tab was hidden lands when it is next shown. That is the desired behaviour here — the + // point is to move the user's focus, which is meaningless while they are looking elsewhere. + const frame = requestAnimationFrame(() => { + const target = callbacksRef.current.resolveElement(field); + target?.focus(); + target?.scrollIntoView({ block: "nearest" }); + clearFocusRequest(); + }); + return () => cancelAnimationFrame(frame); + }, [focusRequest, nodeId, clearFocusRequest]); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.test.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.test.ts new file mode 100644 index 000000000000..53ac30ea1e1c --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { Provider, createStore } from "jotai"; +import { type ReactNode, createElement } from "react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TWorkflowResource } from "@formbricks/workflows"; +import { + closeWorkflowNodeConfigModalAtom, + hydrateWorkflowEditorAtom, + isWorkflowNodeConfigModalOpenAtom, + openWorkflowNodeConfigModalAtom, + selectedWorkflowNodeIdAtom, + workflowFlowNodesAtom, +} from "@/modules/ee/workflows/state/editor"; +import { useWorkflowNodeUrlSync } from "./use-workflow-node-url-sync"; + +let mockSearchParams = new URLSearchParams(); +vi.mock("next/navigation", () => ({ + useSearchParams: () => mockSearchParams, +})); + +const definition = { + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { id: "trigger-1", type: "trigger" }, + nodes: [{ id: "email-1", type: "action", actionType: "send_email" }], + edges: [], +} as unknown as TWorkflowResource["definition"]; + +const workflow = { + id: "wf1", + name: "Hello", + description: null, + status: "draft", + definition, +} as unknown as TWorkflowResource; + +const flowNode = (id: string) => ({ id, position: { x: 0, y: 0 }, data: {} }) as never; + +const renderSync = (isEnabled = true) => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { + workflow, + flowNodes: [flowNode("trigger-1"), flowNode("email-1")], + }); + const wrapper = ({ children }: { children: ReactNode }) => createElement(Provider, { store }, children); + return { store, ...renderHook(() => useWorkflowNodeUrlSync({ isEnabled }), { wrapper }) }; +}; + +beforeEach(() => { + mockSearchParams = new URLSearchParams(); + window.history.replaceState(null, "", "/workspaces/ws1/workflows/wf1"); +}); + +describe("useWorkflowNodeUrlSync", () => { + test("opens the node config for a valid ?node= on mount and marks the canvas node selected", async () => { + mockSearchParams = new URLSearchParams("node=email-1"); + window.history.replaceState(null, "", "/workspaces/ws1/workflows/wf1?node=email-1"); + + const { store } = renderSync(); + + await waitFor(() => expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true)); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("email-1"); + expect(store.get(workflowFlowNodesAtom).map((node) => [node.id, node.selected])).toEqual([ + ["trigger-1", false], + ["email-1", true], + ]); + // The param survives the initial sync round-trip. + expect(new URL(window.location.href).searchParams.get("node")).toBe("email-1"); + }); + + test("ignores an unknown ?node= id and removes it from the URL", async () => { + mockSearchParams = new URLSearchParams("node=deleted-node"); + window.history.replaceState(null, "", "/workspaces/ws1/workflows/wf1?node=deleted-node"); + + const { store } = renderSync(); + + await waitFor(() => expect(new URL(window.location.href).searchParams.get("node")).toBeNull()); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(false); + // The default selection from hydrate is untouched. + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("trigger-1"); + }); + + test("mirrors opening and closing a node into the URL without touching other params", async () => { + window.history.replaceState(null, "", "/workspaces/ws1/workflows/wf1?new=1"); + + const { store } = renderSync(); + + act(() => { + store.set(openWorkflowNodeConfigModalAtom, "email-1"); + }); + await waitFor(() => expect(new URL(window.location.href).searchParams.get("node")).toBe("email-1")); + expect(new URL(window.location.href).searchParams.get("new")).toBe("1"); + + // Closing the config view (e.g. via the canvas Settings cog) clears the param. + act(() => { + store.set(closeWorkflowNodeConfigModalAtom); + }); + await waitFor(() => expect(new URL(window.location.href).searchParams.get("node")).toBeNull()); + expect(new URL(window.location.href).searchParams.get("new")).toBe("1"); + }); + + test("does nothing while disabled", () => { + mockSearchParams = new URLSearchParams("node=email-1"); + window.history.replaceState(null, "", "/workspaces/ws1/workflows/wf1?node=email-1"); + + const { store } = renderSync(false); + + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(false); + expect(new URL(window.location.href).searchParams.get("node")).toBe("email-1"); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.ts new file mode 100644 index 000000000000..d41161ec3915 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-node-url-sync.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { + isWorkflowNodeConfigModalOpenAtom, + openWorkflowNodeConfigModalAtom, + selectedWorkflowNodeIdAtom, + workflowDefinitionAtom, + workflowFlowNodesAtom, +} from "@/modules/ee/workflows/state/editor"; + +export const WORKFLOW_NODE_SEARCH_PARAM = "node"; + +/** + * Two-way sync between the inspected canvas node and the `?node=` search param, so a node's + * config view is deep-linkable and survives a reload. + * + * - On first hydrate, a valid `?node=` opens that node's config panel (and marks the canvas + * node selected, like a click would); unknown ids are ignored and cleaned up. + * - Afterwards, selection changes are mirrored into the URL with the native + * `history.replaceState`, which the Next App Router supports for shallow updates — the URL + * changes without any navigation, reload, or server round-trip. replace (not push) because + * browsing nodes is not a history the Back button should walk through. + * + * `isEnabled` gates everything until the caller has hydrated the editor atoms. + */ +export const useWorkflowNodeUrlSync = ({ isEnabled }: Readonly<{ isEnabled: boolean }>) => { + const searchParams = useSearchParams(); + const definition = useAtomValue(workflowDefinitionAtom); + const selectedNodeId = useAtomValue(selectedWorkflowNodeIdAtom); + const isNodeConfigOpen = useAtomValue(isWorkflowNodeConfigModalOpenAtom); + const openNodeConfigModal = useSetAtom(openWorkflowNodeConfigModalAtom); + const setFlowNodes = useSetAtom(workflowFlowNodesAtom); + + // State (not a ref) so the write-back effect below stays inert during the same render pass + // that applies the initial param — otherwise it would briefly wipe the param before the + // config panel's open state lands. + const [hasAppliedInitialNode, setHasAppliedInitialNode] = useState(false); + + useEffect(() => { + if (!isEnabled || hasAppliedInitialNode || !definition) return; + setHasAppliedInitialNode(true); + + const nodeId = searchParams.get(WORKFLOW_NODE_SEARCH_PARAM); + if (!nodeId) return; + const nodeExists = + definition.trigger?.id === nodeId || definition.nodes.some((node) => node.id === nodeId); + if (!nodeExists) return; + + openNodeConfigModal(nodeId); + // Mirror what a canvas click does: ReactFlow marks the clicked node selected. + setFlowNodes((currentNodes) => + currentNodes.map((node) => + node.selected === (node.id === nodeId) ? node : { ...node, selected: node.id === nodeId } + ) + ); + }, [isEnabled, hasAppliedInitialNode, definition, searchParams, openNodeConfigModal, setFlowNodes]); + + useEffect(() => { + if (!isEnabled || !hasAppliedInitialNode) return; + + const url = new URL(window.location.href); + const currentParam = url.searchParams.get(WORKFLOW_NODE_SEARCH_PARAM); + const nextParam = isNodeConfigOpen && selectedNodeId ? selectedNodeId : null; + if (currentParam === nextParam) return; + + if (nextParam) { + url.searchParams.set(WORKFLOW_NODE_SEARCH_PARAM, nextParam); + } else { + url.searchParams.delete(WORKFLOW_NODE_SEARCH_PARAM); + } + // Preserve the router's history state — Next piggybacks internal data on the entry. + window.history.replaceState(window.history.state, "", url.toString()); + }, [isEnabled, hasAppliedInitialNode, isNodeConfigOpen, selectedNodeId]); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-run.test.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-run.test.ts new file mode 100644 index 000000000000..6c21bc3a0b7e --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-run.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { type ReactNode, createElement } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useWorkflowRun } from "./use-workflow-run"; + +function createWrapper(queryClient: QueryClient) { + const Wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + Wrapper.displayName = "UseWorkflowRunTestWrapper"; + return Wrapper; +} + +const detailResponse = (id: string): Response => + new Response(JSON.stringify({ data: { id, logs: [], triggerPayload: {}, data: {} } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + +const newQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +describe("useWorkflowRun", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("fetches the run detail by id and unwraps the data envelope", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock.mockResolvedValueOnce(detailResponse("run_1")); + + const { result } = renderHook(() => useWorkflowRun({ runId: "run_1" }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.id).toBe("run_1"); + expect(fetchMock).toHaveBeenCalledWith( + "/api/v3/workflows/runs/run_1", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); + + test("stays idle and issues no request when runId is null", async () => { + const fetchMock = vi.mocked(global.fetch); + + const { result } = renderHook(() => useWorkflowRun({ runId: null }), { + wrapper: createWrapper(newQueryClient()), + }); + + expect(result.current.fetchStatus).toBe("idle"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("does not fetch while disabled even with a runId", async () => { + const fetchMock = vi.mocked(global.fetch); + + renderHook(() => useWorkflowRun({ runId: "run_1", enabled: false }), { + wrapper: createWrapper(newQueryClient()), + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("surfaces the error state when the request fails", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ status: 403, detail: "forbidden", code: "forbidden" }), { + status: 403, + headers: { "Content-Type": "application/problem+json" }, + }) + ); + + const { result } = renderHook(() => useWorkflowRun({ runId: "run_1" }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-run.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-run.ts new file mode 100644 index 000000000000..99ee4d94cada --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-run.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getWorkflowRun } from "../lib/api-client"; +import { workflowRunKeys } from "../lib/query"; + +// Single run detail (step logs, trigger payload, run data) fetched on demand — the drawer passes the +// selected run's id and only fetches while open. Static for v1: no refetchInterval, the user reopens +// or refetches to see in-flight runs progress. +export const useWorkflowRun = ({ runId, enabled = true }: { runId: string | null; enabled?: boolean }) => { + return useQuery({ + queryKey: workflowRunKeys.detail(runId ?? ""), + enabled: enabled && runId !== null, + queryFn: ({ signal }) => getWorkflowRun(runId as string, signal), + }); +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-runs.test.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-runs.test.ts new file mode 100644 index 000000000000..7cffda5f6fec --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-runs.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { type ReactNode, createElement } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useWorkflowRuns } from "./use-workflow-runs"; + +function createWrapper(queryClient: QueryClient) { + const Wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + Wrapper.displayName = "UseWorkflowRunsTestWrapper"; + return Wrapper; +} + +const listResponse = (data: { id: string; workflowName: string }[], nextCursor: string | null): Response => + new Response(JSON.stringify({ data, meta: { limit: 20, nextCursor } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + +const newQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +describe("useWorkflowRuns", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("fetches the initial page and appends the next cursor page on load-more", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock + .mockResolvedValueOnce(listResponse([{ id: "run_1", workflowName: "WF" }], "cursor_1")) + .mockResolvedValueOnce(listResponse([{ id: "run_2", workflowName: "WF" }], null)); + + const { result } = renderHook(() => useWorkflowRuns({ workspaceId: "ws_1", limit: 20 }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.runs).toHaveLength(1); + expect(result.current.hasNextPage).toBe(true); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/v3/workflows/runs?workspaceId=ws_1&limit=20", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + + await act(async () => { + await result.current.fetchNextPage(); + }); + + await waitFor(() => expect(result.current.runs).toHaveLength(2)); + expect(result.current.hasNextPage).toBe(false); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/v3/workflows/runs?workspaceId=ws_1&limit=20&cursor=cursor_1", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); + + test("hides load-more when the first page has no next cursor", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(listResponse([{ id: "run_1", workflowName: "WF" }], null)); + + const { result } = renderHook(() => useWorkflowRuns({ workspaceId: "ws_1", limit: 20 }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + test("forwards the workflowId filter (per-workflow runs view)", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock.mockResolvedValueOnce(listResponse([{ id: "run_1", workflowName: "WF" }], null)); + + const { result } = renderHook( + () => useWorkflowRuns({ workspaceId: "ws_1", limit: 20, filters: { workflowId: "wf_1" } }), + { wrapper: createWrapper(newQueryClient()) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fetchMock).toHaveBeenLastCalledWith( + "/api/v3/workflows/runs?workspaceId=ws_1&limit=20&workflowId=wf_1", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); + + test("stays idle and issues no request when disabled", () => { + const fetchMock = vi.mocked(global.fetch); + + const { result } = renderHook(() => useWorkflowRuns({ workspaceId: "ws_1", limit: 20, enabled: false }), { + wrapper: createWrapper(newQueryClient()), + }); + + expect(result.current.fetchStatus).toBe("idle"); + expect(result.current.runs).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("surfaces the error state when the request fails", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ status: 500, detail: "boom", code: "internal_server_error" }), { + status: 500, + headers: { "Content-Type": "application/problem+json" }, + }) + ); + + const { result } = renderHook(() => useWorkflowRuns({ workspaceId: "ws_1", limit: 20 }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.runs).toHaveLength(0); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-workflow-runs.ts b/apps/web/modules/ee/workflows/hooks/use-workflow-runs.ts new file mode 100644 index 000000000000..208b790d8444 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflow-runs.ts @@ -0,0 +1,40 @@ +"use client"; + +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { type TWorkflowRunListFilters, listWorkflowRuns } from "../lib/api-client"; +import { flattenWorkflowRunPages, workflowRunKeys } from "../lib/query"; + +// Keyset-paginated run list, mirroring use-workflows. The runs list is fixed newest-first; filters +// (workflowId / responseId / statusIn / isDryRun) are part of the query key so a filter change +// starts a fresh page sequence while keepPreviousData avoids a flash of empty state. +export const useWorkflowRuns = ({ + workspaceId, + limit, + filters, + enabled = true, +}: { + workspaceId: string; + limit: number; + filters?: TWorkflowRunListFilters; + enabled?: boolean; +}) => { + const queryKey = workflowRunKeys.list({ workspaceId, limit, filters }); + + const query = useInfiniteQuery({ + queryKey, + initialPageParam: null as string | null, + enabled, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + listWorkflowRuns({ workspaceId, limit, cursor: pageParam, filters, signal }), + getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined, + }); + + const runs = flattenWorkflowRunPages(query.data); + + return { + ...query, + queryKey, + runs, + }; +}; diff --git a/apps/web/modules/ee/workflows/hooks/use-workflows.test.ts b/apps/web/modules/ee/workflows/hooks/use-workflows.test.ts new file mode 100644 index 000000000000..b0196402a6a7 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflows.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createWrapper, newQueryClient } from "./test-utils"; +import { useWorkflows } from "./use-workflows"; + +const listResponse = (data: { id: string; name: string }[], nextCursor: string | null): Response => + new Response(JSON.stringify({ data, meta: { limit: 12, nextCursor } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + +describe("useWorkflows", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("fetches the initial page and appends the next cursor page on load-more", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock + .mockResolvedValueOnce(listResponse([{ id: "wf_1", name: "Workflow 1" }], "cursor_1")) + .mockResolvedValueOnce(listResponse([{ id: "wf_2", name: "Workflow 2" }], null)); + + const { result } = renderHook(() => useWorkflows({ workspaceId: "ws_1", limit: 12, nameContains: "" }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.workflows).toHaveLength(1); + expect(result.current.hasNextPage).toBe(true); + + await act(async () => { + await result.current.fetchNextPage(); + }); + + await waitFor(() => expect(result.current.workflows).toHaveLength(2)); + expect(result.current.hasNextPage).toBe(false); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/v3/workflows?workspaceId=ws_1&limit=12&cursor=cursor_1", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); + + test("hides load-more when the first page has no next cursor", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(listResponse([{ id: "wf_1", name: "Workflow 1" }], null)); + + const { result } = renderHook(() => useWorkflows({ workspaceId: "ws_1", limit: 12, nameContains: "" }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + test("a new search term refetches from page 1 with the nameContains filter", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock + .mockResolvedValueOnce(listResponse([{ id: "wf_1", name: "Workflow 1" }], null)) + .mockResolvedValueOnce(listResponse([{ id: "wf_2", name: "Onboarding" }], null)); + + const { result, rerender } = renderHook( + ({ nameContains }: { nameContains: string }) => + useWorkflows({ workspaceId: "ws_1", limit: 12, nameContains }), + { initialProps: { nameContains: "" }, wrapper: createWrapper(newQueryClient()) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.workflows[0]?.name).toBe("Workflow 1"); + + rerender({ nameContains: "onboarding" }); + + await waitFor(() => expect(result.current.workflows[0]?.name).toBe("Onboarding")); + expect(fetchMock).toHaveBeenLastCalledWith( + "/api/v3/workflows?workspaceId=ws_1&limit=12&filter%5Bname%5D%5Bcontains%5D=onboarding", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); + + test("surfaces the error state when the request fails", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ status: 500, detail: "boom", code: "internal_server_error" }), { + status: 500, + headers: { "Content-Type": "application/problem+json" }, + }) + ); + + const { result } = renderHook(() => useWorkflows({ workspaceId: "ws_1", limit: 12, nameContains: "" }), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.workflows).toHaveLength(0); + }); +}); diff --git a/apps/web/modules/ee/workflows/hooks/use-workflows.ts b/apps/web/modules/ee/workflows/hooks/use-workflows.ts new file mode 100644 index 000000000000..9310eb2bb144 --- /dev/null +++ b/apps/web/modules/ee/workflows/hooks/use-workflows.ts @@ -0,0 +1,48 @@ +"use client"; + +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import type { TWorkflowSortBy, TWorkflowStatus } from "@formbricks/workflows"; +import { listWorkflows } from "../lib/api-client"; +import { flattenWorkflowPages, workflowKeys } from "../lib/query"; + +export const useWorkflows = ({ + workspaceId, + limit, + nameContains, + statusIn, + sortBy, + enabled = true, +}: { + workspaceId: string; + limit: number; + nameContains: string; + statusIn?: TWorkflowStatus[]; + sortBy?: TWorkflowSortBy; + enabled?: boolean; +}) => { + const queryKey = workflowKeys.list({ workspaceId, limit, nameContains, statusIn, sortBy }); + + const query = useInfiniteQuery({ + queryKey, + initialPageParam: null as string | null, + enabled, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + listWorkflows({ + workspaceId, + limit, + cursor: pageParam, + filters: { nameContains, statusIn, sortBy }, + signal, + }), + getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined, + }); + + const workflows = flattenWorkflowPages(query.data); + + return { + ...query, + queryKey, + workflows, + }; +}; diff --git a/apps/web/modules/ee/workflows/lib/api-client.test.ts b/apps/web/modules/ee/workflows/lib/api-client.test.ts new file mode 100644 index 000000000000..7bd64b1d7515 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/api-client.test.ts @@ -0,0 +1,386 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { TCreateWorkflowInput } from "@formbricks/workflows"; +import { V3ApiError } from "@/modules/api/lib/v3-client"; +import { + archiveWorkflow, + buildWorkflowListSearchParams, + buildWorkflowRunListSearchParams, + createWorkflow, + deleteWorkflow, + disableWorkflow, + duplicateWorkflow, + enableWorkflow, + getWorkflow, + getWorkflowRun, + listWorkflowRuns, + listWorkflows, + unarchiveWorkflow, + updateWorkflow, +} from "./api-client"; +import { createEmptyWorkflowDefinition } from "./default-workflow"; + +const workflow = { id: "wf1", name: "x" }; +const okResponse = (data: unknown) => ({ ok: true, json: async () => ({ data }) }) as unknown as Response; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("getWorkflow", () => { + test("GETs the workflow endpoint and returns data", async () => { + fetchMock.mockResolvedValueOnce(okResponse(workflow)); + const signal = new AbortController().signal; + + const result = await getWorkflow("wf1", signal); + + expect(fetchMock).toHaveBeenCalledWith("/api/v3/workflows/wf1", { + method: "GET", + cache: "no-store", + signal, + }); + expect(result).toEqual(workflow); + }); + + test("throws V3ApiError when response is not ok", async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + headers: { get: () => null }, + json: async () => ({ detail: "missing" }), + } as unknown as Response); + + await expect(getWorkflow("wf1")).rejects.toBeInstanceOf(V3ApiError); + }); +}); + +describe("updateWorkflow", () => { + test("PATCHes with JSON body", async () => { + fetchMock.mockResolvedValueOnce(okResponse(workflow)); + + await updateWorkflow("wf1", { name: "new" }); + + expect(fetchMock).toHaveBeenCalledWith("/api/v3/workflows/wf1", { + method: "PATCH", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "new" }), + signal: expect.any(AbortSignal), + }); + }); +}); + +describe("lifecycle endpoints", () => { + test.each([ + [enableWorkflow, "enable"], + [disableWorkflow, "disable"], + [archiveWorkflow, "archive"], + [unarchiveWorkflow, "unarchive"], + ] as const)("POSTs /:id/%s", async (fn, action) => { + fetchMock.mockResolvedValueOnce(okResponse(workflow)); + + await fn("wf1"); + + expect(fetchMock).toHaveBeenCalledWith(`/api/v3/workflows/wf1/${action}`, { + method: "POST", + cache: "no-store", + signal: expect.any(AbortSignal), + }); + }); +}); + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +const problemResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/problem+json" } }); + +describe("buildWorkflowListSearchParams", () => { + test("encodes workspace, limit, cursor, sort and the filter[...] family", () => { + const params = buildWorkflowListSearchParams({ + workspaceId: "ws_1", + limit: 12, + cursor: "cursor_1", + filters: { nameContains: " onboarding ", statusIn: ["draft", "enabled"], sortBy: "name" }, + }); + + expect(params.get("workspaceId")).toBe("ws_1"); + expect(params.get("limit")).toBe("12"); + expect(params.get("cursor")).toBe("cursor_1"); + expect(params.get("sortBy")).toBe("name"); + expect(params.get("filter[name][contains]")).toBe("onboarding"); + expect(params.getAll("filter[status][in]")).toEqual(["draft", "enabled"]); + }); + + test("omits cursor, sort and filters when not provided", () => { + const params = buildWorkflowListSearchParams({ workspaceId: "ws_1", limit: 12 }); + + expect(params.has("cursor")).toBe(false); + expect(params.has("sortBy")).toBe(false); + expect(params.has("filter[name][contains]")).toBe(false); + expect(params.has("filter[status][in]")).toBe(false); + }); + + test("omits a blank or whitespace-only name filter", () => { + const params = buildWorkflowListSearchParams({ + workspaceId: "ws_1", + limit: 12, + filters: { nameContains: " " }, + }); + + expect(params.has("filter[name][contains]")).toBe(false); + }); +}); + +describe("workflows api-client requests", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("listWorkflows requests the list endpoint and returns the cursor page unchanged", async () => { + const page = { + data: [{ id: "wf_1", name: "Workflow 1" }], + meta: { limit: 12, nextCursor: "cursor_1" }, + }; + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse(page)); + + const result = await listWorkflows({ workspaceId: "ws_1", limit: 12, filters: { nameContains: "abc" } }); + + expect(result).toEqual(page); + expect(global.fetch).toHaveBeenCalledWith( + "/api/v3/workflows?workspaceId=ws_1&limit=12&filter%5Bname%5D%5Bcontains%5D=abc", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); + + test("createWorkflow posts the input and unwraps the resource", async () => { + const resource = { id: "wf_new", name: "New", status: "draft" }; + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse({ data: resource }, 201)); + + const input: TCreateWorkflowInput = { + workspaceId: "ws_1", + name: "New", + description: null, + status: "draft" as const, + definition: createEmptyWorkflowDefinition(), + }; + const result = await createWorkflow(input); + + expect(result).toEqual(resource); + const [url, init] = vi.mocked(global.fetch).mock.calls[0]!; + expect(url).toBe("/api/v3/workflows"); + expect(init).toMatchObject({ method: "POST", cache: "no-store" }); + // Mutations are timeout-bounded so a stalled request can't hang the editor spinner. + expect((init as RequestInit).signal).toBeInstanceOf(AbortSignal); + expect(JSON.parse(String((init as RequestInit).body))).toMatchObject({ + workspaceId: "ws_1", + name: "New", + }); + }); + + test("duplicateWorkflow posts an empty body when no name is given", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse({ data: { id: "wf_copy" } }, 201)); + + const result = await duplicateWorkflow("wf_1"); + + expect(result).toEqual({ id: "wf_copy" }); + const [url, init] = vi.mocked(global.fetch).mock.calls[0]!; + expect(url).toBe("/api/v3/workflows/wf_1/duplicate"); + expect((init as RequestInit).signal).toBeInstanceOf(AbortSignal); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({}); + }); + + test("archiveWorkflow posts to the archive endpoint and unwraps the resource", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse({ data: { id: "wf_1", status: "archived" } })); + + const result = await archiveWorkflow("wf_1"); + + expect(result).toEqual({ id: "wf_1", status: "archived" }); + expect(global.fetch).toHaveBeenCalledWith( + "/api/v3/workflows/wf_1/archive", + expect.objectContaining({ method: "POST", cache: "no-store" }) + ); + }); + + test("unarchiveWorkflow posts to the unarchive endpoint and unwraps the resource", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse({ data: { id: "wf_1", status: "disabled" } })); + + const result = await unarchiveWorkflow("wf_1"); + + expect(result).toEqual({ id: "wf_1", status: "disabled" }); + expect(global.fetch).toHaveBeenCalledWith( + "/api/v3/workflows/wf_1/unarchive", + expect.objectContaining({ method: "POST", cache: "no-store" }) + ); + }); + + test("deleteWorkflow resolves on a 204 with no body", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(deleteWorkflow("wf_1")).resolves.toBeUndefined(); + expect(global.fetch).toHaveBeenCalledWith( + "/api/v3/workflows/wf_1", + expect.objectContaining({ method: "DELETE", cache: "no-store", signal: expect.any(AbortSignal) }) + ); + }); + + test("maps a problem+json error response to a V3ApiError carrying the detail", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + problemResponse(403, { + title: "Forbidden", + status: 403, + detail: "You are not authorized to access this resource", + code: "forbidden", + requestId: "req_1", + }) + ); + + await expect(listWorkflows({ workspaceId: "ws_1", limit: 12 })).rejects.toBeInstanceOf(V3ApiError); + }); + + test("propagates the parsed problem detail and code on a failed mutation", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + problemResponse(400, { + title: "Bad Request", + status: 400, + detail: "The request payload is invalid.", + code: "bad_request", + requestId: "req_2", + }) + ); + + await expect(archiveWorkflow("wf_1")).rejects.toMatchObject({ + status: 400, + code: "bad_request", + message: "The request payload is invalid.", + }); + }); +}); + +describe("buildWorkflowRunListSearchParams", () => { + test("encodes workspace, limit, cursor and the run filter family", () => { + const params = buildWorkflowRunListSearchParams({ + workspaceId: "ws_1", + limit: 25, + cursor: "cursor_1", + filters: { + workflowId: "wf_1", + responseId: "rsp_1", + statusIn: ["failed", "completed"], + isDryRun: false, + }, + }); + + expect(params.get("workspaceId")).toBe("ws_1"); + expect(params.get("limit")).toBe("25"); + expect(params.get("cursor")).toBe("cursor_1"); + expect(params.get("workflowId")).toBe("wf_1"); + expect(params.get("responseId")).toBe("rsp_1"); + expect(params.get("filter[isDryRun][eq]")).toBe("false"); + expect(params.getAll("filter[status][in]")).toEqual(["failed", "completed"]); + }); + + test("omits cursor and filters when not provided", () => { + const params = buildWorkflowRunListSearchParams({ workspaceId: "ws_1", limit: 20 }); + + expect(params.has("cursor")).toBe(false); + expect(params.has("workflowId")).toBe(false); + expect(params.has("responseId")).toBe(false); + expect(params.has("filter[isDryRun][eq]")).toBe(false); + expect(params.has("filter[status][in]")).toBe(false); + }); +}); + +describe("workflow runs api-client requests", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("listWorkflowRuns requests the runs endpoint and returns the cursor page unchanged", async () => { + const page = { data: [{ id: "run_1" }], meta: { limit: 20, nextCursor: null } }; + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse(page)); + + const result = await listWorkflowRuns({ + workspaceId: "ws_1", + limit: 20, + filters: { workflowId: "wf_1" }, + }); + + expect(result).toEqual(page); + const [url, init] = vi.mocked(global.fetch).mock.calls[0]!; + expect(url).toBe("/api/v3/workflows/runs?workspaceId=ws_1&limit=20&workflowId=wf_1"); + expect(init).toMatchObject({ method: "GET", cache: "no-store" }); + }); + + test("getWorkflowRun GETs the run-detail endpoint and unwraps the resource", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse({ data: { id: "run_1", logs: [] } })); + + const result = await getWorkflowRun("run_1"); + + expect(result).toEqual({ id: "run_1", logs: [] }); + expect(global.fetch).toHaveBeenCalledWith( + "/api/v3/workflows/runs/run_1", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); + + test("getWorkflowRun preserves nullable timestamp fields through the round-trip", async () => { + const resource = { + id: "run_1", + logs: [], + nextAttemptAt: "2026-06-12T11:00:00.000Z", + lastErrorAt: null, + }; + vi.mocked(global.fetch).mockResolvedValueOnce(jsonResponse({ data: resource })); + + const result = await getWorkflowRun("run_1"); + + expect(result).toEqual(resource); + }); + + test("listWorkflowRuns maps a problem response to a V3ApiError", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + problemResponse(403, { + title: "Forbidden", + status: 403, + detail: "denied", + code: "forbidden", + requestId: "req_1", + }) + ); + + await expect(listWorkflowRuns({ workspaceId: "ws_1", limit: 20 })).rejects.toBeInstanceOf(V3ApiError); + }); + + test("getWorkflowRun maps a 403 problem response to a V3ApiError", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + problemResponse(403, { + title: "Forbidden", + status: 403, + detail: "denied", + code: "forbidden", + requestId: "req_1", + }) + ); + + await expect(getWorkflowRun("run_x")).rejects.toBeInstanceOf(V3ApiError); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/api-client.ts b/apps/web/modules/ee/workflows/lib/api-client.ts new file mode 100644 index 000000000000..bb907e88adff --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/api-client.ts @@ -0,0 +1,280 @@ +import type { + TCreateWorkflowInput, + TPatchWorkflowInput, + TWorkflowListItem, + TWorkflowResource, + TWorkflowRunListItem, + TWorkflowRunResource, + TWorkflowRunStatus, + TWorkflowSortBy, + TWorkflowStatus, +} from "@formbricks/workflows"; +import { parseV3ApiError } from "@/modules/api/lib/v3-client"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; + +// Bound mutation calls so a stalled request can't leave the editor's save/transition spinner +// stuck indefinitely; the rejection surfaces as the normal save/lifecycle error toast. +export const MUTATION_TIMEOUT_MS = 15_000; + +async function readWorkflowResponse(response: Response): Promise { + if (!response.ok) { + throw await parseV3ApiError(response); + } + + const body = (await response.json()) as { data: TWorkflowResource }; + return body.data; +} + +export interface TWorkflowListPage { + data: TWorkflowListItem[]; + meta: { + limit: number; + nextCursor: string | null; + }; +} + +export interface TWorkflowListFilters { + nameContains?: string; + statusIn?: TWorkflowStatus[]; + sortBy?: TWorkflowSortBy; +} + +export function buildWorkflowListSearchParams({ + workspaceId, + limit, + cursor, + filters, +}: { + workspaceId: string; + limit: number; + cursor?: string | null; + filters?: TWorkflowListFilters; +}): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set("workspaceId", workspaceId); + searchParams.set("limit", String(limit)); + + if (filters?.sortBy) { + searchParams.set("sortBy", filters.sortBy); + } + + if (cursor) { + searchParams.set("cursor", cursor); + } + + const trimmedName = filters?.nameContains?.trim(); + if (trimmedName) { + searchParams.set("filter[name][contains]", trimmedName); + } + + filters?.statusIn?.forEach((status) => { + searchParams.append("filter[status][in]", status); + }); + + return searchParams; +} + +export async function listWorkflows({ + workspaceId, + limit, + cursor, + filters, + signal, +}: { + workspaceId: string; + limit: number; + cursor?: string | null; + filters?: TWorkflowListFilters; + signal?: AbortSignal; +}): Promise { + const response = await fetch( + `/api/v3/workflows?${buildWorkflowListSearchParams({ workspaceId, limit, cursor, filters }).toString()}`, + { + method: "GET", + cache: "no-store", + signal, + } + ); + + if (!response.ok) { + throw await parseV3ApiError(response); + } + + return (await response.json()) as TWorkflowListPage; +} + +export async function createWorkflow(input: TCreateWorkflowInput): Promise { + const response = await fetch("/api/v3/workflows", { + method: "POST", + cache: "no-store", + headers: JSON_HEADERS, + body: JSON.stringify(input), + signal: AbortSignal.timeout(MUTATION_TIMEOUT_MS), + }); + + return readWorkflowResponse(response); +} + +export async function getWorkflow(workflowId: string, signal?: AbortSignal): Promise { + const response = await fetch(`/api/v3/workflows/${workflowId}`, { + method: "GET", + cache: "no-store", + signal, + }); + + return readWorkflowResponse(response); +} + +export async function updateWorkflow( + workflowId: string, + payload: TPatchWorkflowInput +): Promise { + const response = await fetch(`/api/v3/workflows/${workflowId}`, { + method: "PATCH", + cache: "no-store", + headers: JSON_HEADERS, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(MUTATION_TIMEOUT_MS), + }); + + return readWorkflowResponse(response); +} + +export async function duplicateWorkflow(workflowId: string, name?: string): Promise { + const response = await fetch(`/api/v3/workflows/${workflowId}/duplicate`, { + method: "POST", + cache: "no-store", + headers: JSON_HEADERS, + body: JSON.stringify(name ? { name } : {}), + signal: AbortSignal.timeout(MUTATION_TIMEOUT_MS), + }); + + return readWorkflowResponse(response); +} + +async function postLifecycle(workflowId: string, action: string): Promise { + const response = await fetch(`/api/v3/workflows/${workflowId}/${action}`, { + method: "POST", + cache: "no-store", + signal: AbortSignal.timeout(MUTATION_TIMEOUT_MS), + }); + + return readWorkflowResponse(response); +} + +export const enableWorkflow = (workflowId: string) => postLifecycle(workflowId, "enable"); +export const disableWorkflow = (workflowId: string) => postLifecycle(workflowId, "disable"); +export const archiveWorkflow = (workflowId: string) => postLifecycle(workflowId, "archive"); +export const unarchiveWorkflow = (workflowId: string) => postLifecycle(workflowId, "unarchive"); + +export async function deleteWorkflow(workflowId: string): Promise { + const response = await fetch(`/api/v3/workflows/${workflowId}`, { + method: "DELETE", + cache: "no-store", + signal: AbortSignal.timeout(MUTATION_TIMEOUT_MS), + }); + + if (!response.ok) { + throw await parseV3ApiError(response); + } +} + +export interface TWorkflowRunListPage { + data: TWorkflowRunListItem[]; + meta: { + limit: number; + nextCursor: string | null; + }; +} + +export interface TWorkflowRunListFilters { + workflowId?: string; + responseId?: string; + statusIn?: TWorkflowRunStatus[]; + isDryRun?: boolean; +} + +export function buildWorkflowRunListSearchParams({ + workspaceId, + limit, + cursor, + filters, +}: { + workspaceId: string; + limit: number; + cursor?: string | null; + filters?: TWorkflowRunListFilters; +}): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set("workspaceId", workspaceId); + searchParams.set("limit", String(limit)); + + if (cursor) { + searchParams.set("cursor", cursor); + } + + if (filters?.workflowId) { + searchParams.set("workflowId", filters.workflowId); + } + + if (filters?.responseId) { + searchParams.set("responseId", filters.responseId); + } + + if (filters?.isDryRun !== undefined) { + searchParams.set("filter[isDryRun][eq]", String(filters.isDryRun)); + } + + filters?.statusIn?.forEach((status) => { + searchParams.append("filter[status][in]", status); + }); + + return searchParams; +} + +export async function listWorkflowRuns({ + workspaceId, + limit, + cursor, + filters, + signal, +}: { + workspaceId: string; + limit: number; + cursor?: string | null; + filters?: TWorkflowRunListFilters; + signal?: AbortSignal; +}): Promise { + const response = await fetch( + `/api/v3/workflows/runs?${buildWorkflowRunListSearchParams({ workspaceId, limit, cursor, filters }).toString()}`, + { + method: "GET", + cache: "no-store", + signal, + } + ); + + if (!response.ok) { + throw await parseV3ApiError(response); + } + + return (await response.json()) as TWorkflowRunListPage; +} + +export async function getWorkflowRun(runId: string, signal?: AbortSignal): Promise { + const response = await fetch(`/api/v3/workflows/runs/${runId}`, { + method: "GET", + cache: "no-store", + signal, + }); + + if (!response.ok) { + throw await parseV3ApiError(response); + } + + const body = (await response.json()) as { data: TWorkflowRunResource }; + return body.data; +} diff --git a/apps/web/modules/ee/workflows/lib/api-error.test.ts b/apps/web/modules/ee/workflows/lib/api-error.test.ts new file mode 100644 index 000000000000..28a9a4a1ade5 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/api-error.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { V3ApiError } from "@/modules/api/lib/v3-client"; +import { classifyWorkflowSaveError, getWorkflowApiErrorMessage } from "@/modules/ee/workflows/lib/api-error"; + +const rejected = (detail: string) => new V3ApiError({ status: 422, detail }); +const gatewayError = (status: number) => new V3ApiError({ status, detail: "Bad Gateway" }); + +describe("getWorkflowApiErrorMessage", () => { + test("surfaces the server-authored detail of an API problem response", () => { + expect(getWorkflowApiErrorMessage(rejected("Definition is invalid."), "fallback")).toBe( + "Definition is invalid." + ); + }); + + test.each([ + // parseV3ApiError falls back to response.statusText, which is always "" over HTTP/2. + ["a V3ApiError with an empty detail", rejected("")], + ["a V3ApiError with a whitespace-only detail", rejected(" ")], + // The strings this helper exists to keep off the screen. + ["an offline fetch rejection", new TypeError("Failed to fetch")], + ["a Firefox offline fetch rejection", new TypeError("NetworkError when attempting to fetch resource.")], + ["the mutation timeout", new DOMException("The operation timed out.", "TimeoutError")], + ["an unexpected Error", new Error("kaboom")], + ["a non-Error throw", "kaboom"], + ["a thrown undefined", undefined], + // Gateway text is not copy we authored, so it must not reach the user. + ["a 500", gatewayError(500)], + ["a 502", gatewayError(502)], + ["a 503", gatewayError(503)], + ["a 504", gatewayError(504)], + ])("falls back to the caller's copy for %s", (_label, error) => { + expect(getWorkflowApiErrorMessage(error, "fallback")).toBe("fallback"); + }); +}); + +describe("classifyWorkflowSaveError", () => { + test("treats an API problem response as rejected", () => { + expect(classifyWorkflowSaveError(rejected("Definition is invalid."))).toBe("rejected"); + }); + + test.each([ + ["an offline fetch rejection", new TypeError("Failed to fetch")], + ["the mutation timeout", new DOMException("The operation timed out.", "TimeoutError")], + ["a non-Error throw", "kaboom"], + // A restarting server or a proxy hiccup says nothing about the draft, so it stays retryable. + ["a 500", gatewayError(500)], + ["a 502", gatewayError(502)], + ["a 503", gatewayError(503)], + ["a 504", gatewayError(504)], + ])("treats %s as unreachable", (_label, error) => { + expect(classifyWorkflowSaveError(error)).toBe("unreachable"); + }); + + test.each([ + ["a 400", 400], + ["a 404", 404], + ["a 422", 422], + ["a 499", 499], + ])("treats %s as a refusal that must not auto-retry", (_label, status) => { + expect(classifyWorkflowSaveError(new V3ApiError({ status, detail: "Nope." }))).toBe("rejected"); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/api-error.ts b/apps/web/modules/ee/workflows/lib/api-error.ts new file mode 100644 index 000000000000..32a4179a7e75 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/api-error.ts @@ -0,0 +1,34 @@ +import { V3ApiError } from "@/modules/api/lib/v3-client"; + +/** + * Deliberately stricter than the shared `getV3ApiErrorMessage`, which returns `error.message` for + * any Error and so surfaces raw browser strings to users — "NetworkError when attempting to fetch + * resource." (Firefox), "Failed to fetch" (Chrome), "The operation timed out." from the mutation + * AbortSignal.timeout. Those read as a broken app rather than a lost connection. + * + * Only a V3ApiError carries a `detail` we authored (RFC 9457, already user-facing); anything else + * never got an answer from the API and has nothing worth showing, so it collapses to the caller's + * copy. The shared helper is left alone — it has callers across the app whose behaviour is pinned by + * its own tests. + */ +export const getWorkflowApiErrorMessage = (error: unknown, fallbackMessage: string): string => + // `.trim()` guards the empty-detail case: parseV3ApiError falls back to `response.statusText`, + // which is always "" over HTTP/2, and `??` doesn't catch that — without this a non-JSON error + // response would render an empty toast. + isRefusal(error) && error.detail.trim() ? error.detail : fallbackMessage; + +/** + * A response in which the API deliberately refused this request, as opposed to one that never got an + * answer worth reading. 5xx is the latter: a 502/503/504 from a proxy or a restarting server says + * nothing about the request, and its `detail` is whatever the gateway happened to emit — not copy we + * authored. Anything that isn't a V3ApiError never reached the API at all, since parseV3ApiError is + * the only thing that constructs one (offline, DNS failure, the mutation timeout). + */ +const isRefusal = (error: unknown): error is V3ApiError => error instanceof V3ApiError && error.status < 500; + +/** + * Whether a failed save is worth retrying by itself once the connection is back. Only a deliberate + * refusal is not: re-sending an identical draft the API already rejected would just fail again. + */ +export const classifyWorkflowSaveError = (error: unknown): "unreachable" | "rejected" => + isRefusal(error) ? "rejected" : "unreachable"; diff --git a/apps/web/modules/ee/workflows/lib/auth.test.ts b/apps/web/modules/ee/workflows/lib/auth.test.ts new file mode 100644 index 000000000000..e7c307ad4926 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/auth.test.ts @@ -0,0 +1,96 @@ +import { notFound, redirect } from "next/navigation"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getBillingFallbackPath } from "@/lib/membership/navigation"; +import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; +import { getWorkflowsRouteAuth } from "./auth"; + +vi.mock("server-only", () => ({})); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(() => { + throw new Error("NEXT_REDIRECT"); + }), + notFound: vi.fn(() => { + throw new Error("NEXT_NOT_FOUND"); + }), +})); +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: true })); +vi.mock("@/lib/membership/navigation", () => ({ + getBillingFallbackPath: vi.fn(() => "/billing-fallback"), +})); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsWorkflowsEnabled: vi.fn(), +})); +vi.mock("@/modules/workspaces/lib/utils", () => ({ + getWorkspaceAuth: vi.fn(), +})); + +type TAuth = Awaited>; + +const baseAuth = { + isBilling: false, + isOwner: false, + isManager: false, + hasReadAccess: false, + hasReadWriteAccess: false, + hasManageAccess: false, + isReadOnly: false, + organization: { id: "org_123" }, +}; + +const buildAuth = (overrides: Partial): TAuth => + ({ ...baseAuth, ...overrides }) as unknown as TAuth; + +describe("getWorkflowsRouteAuth", () => { + const workspaceId = "ws_123"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getIsWorkflowsEnabled).mockResolvedValue(true); + }); + + test("redirects to the billing fallback when the workspace is in billing", async () => { + vi.mocked(getWorkspaceAuth).mockResolvedValue(buildAuth({ isBilling: true, isOwner: true })); + + await expect(getWorkflowsRouteAuth(workspaceId)).rejects.toThrow("NEXT_REDIRECT"); + expect(getBillingFallbackPath).toHaveBeenCalledWith("org_123", true); + expect(redirect).toHaveBeenCalledWith("/billing-fallback"); + expect(notFound).not.toHaveBeenCalled(); + }); + + test("calls notFound when the user has no workspace access", async () => { + vi.mocked(getWorkspaceAuth).mockResolvedValue(buildAuth({})); + + await expect(getWorkflowsRouteAuth(workspaceId)).rejects.toThrow("NEXT_NOT_FOUND"); + expect(notFound).toHaveBeenCalled(); + expect(redirect).not.toHaveBeenCalled(); + expect(getIsWorkflowsEnabled).not.toHaveBeenCalled(); + }); + + test.each(["isOwner", "isManager", "hasReadAccess", "hasReadWriteAccess", "hasManageAccess"] as const)( + "returns the narrow contract when %s grants access", + async (flag) => { + vi.mocked(getWorkspaceAuth).mockResolvedValue(buildAuth({ [flag]: true, isReadOnly: true })); + + await expect(getWorkflowsRouteAuth(workspaceId)).resolves.toEqual({ + isReadOnly: true, + isWorkflowsEnabled: true, + organizationId: "org_123", + }); + expect(getIsWorkflowsEnabled).toHaveBeenCalledWith("org_123"); + expect(redirect).not.toHaveBeenCalled(); + expect(notFound).not.toHaveBeenCalled(); + } + ); + + test("returns isWorkflowsEnabled false when the organization lacks the entitlement", async () => { + vi.mocked(getWorkspaceAuth).mockResolvedValue(buildAuth({ isOwner: true })); + vi.mocked(getIsWorkflowsEnabled).mockResolvedValue(false); + + await expect(getWorkflowsRouteAuth(workspaceId)).resolves.toEqual({ + isReadOnly: false, + isWorkflowsEnabled: false, + organizationId: "org_123", + }); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/auth.ts b/apps/web/modules/ee/workflows/lib/auth.ts new file mode 100644 index 000000000000..918536d7dd76 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/auth.ts @@ -0,0 +1,29 @@ +import "server-only"; +import { notFound, redirect } from "next/navigation"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getBillingFallbackPath } from "@/lib/membership/navigation"; +import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; + +export const getWorkflowsRouteAuth = async (workspaceId: string) => { + const auth = await getWorkspaceAuth(workspaceId); + + if (auth.isBilling) { + redirect(getBillingFallbackPath(auth.organization.id, IS_FORMBRICKS_CLOUD)); + } + + const hasWorkspaceAccess = + auth.isOwner || auth.isManager || auth.hasReadAccess || auth.hasReadWriteAccess || auth.hasManageAccess; + + if (!hasWorkspaceAccess) { + notFound(); + } + + // Workflows is an EE feature (Cloud plan entitlement / self-hosted EE license). Routes render an + // upgrade prompt instead of the feature when the organization is not entitled. + const isWorkflowsEnabled = await getIsWorkflowsEnabled(auth.organization.id); + + // Consumers only need these flags (plus the organization id for the upgrade prompt's billing + // link); keep the contract narrow. + return { isReadOnly: auth.isReadOnly, isWorkflowsEnabled, organizationId: auth.organization.id }; +}; diff --git a/apps/web/modules/ee/workflows/lib/bound-survey.test.ts b/apps/web/modules/ee/workflows/lib/bound-survey.test.ts new file mode 100644 index 000000000000..8ae3e099a4d5 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/bound-survey.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context"; +import { resolveBoundTriggerSurvey } from "./bound-survey"; + +const definitionWithSurvey = (surveyId: string): TWorkflowDefinition => + ({ + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId, endingCardIds: [] }, + }, + nodes: [], + edges: [], + }) as unknown as TWorkflowDefinition; + +const contextWithSurvey = (surveyId: string | null): TWorkflowEmailAuthoringContext => + ({ + survey: surveyId ? ({ id: surveyId } as TSurvey) : null, + teamMemberDetails: [], + userEmail: "", + mailFrom: "noreply@example.com", + locale: "en-US", + }) as TWorkflowEmailAuthoringContext; + +describe("resolveBoundTriggerSurvey", () => { + test("returns the context survey when it matches the trigger surveyId", () => { + const survey = resolveBoundTriggerSurvey(contextWithSurvey("survey-1"), definitionWithSurvey("survey-1")); + expect(survey?.id).toBe("survey-1"); + }); + + test("returns null when the trigger points at a different survey (stale context)", () => { + expect( + resolveBoundTriggerSurvey(contextWithSurvey("survey-1"), definitionWithSurvey("survey-2")) + ).toBeNull(); + }); + + test("returns null when the context has no survey (seed placeholder / deleted survey)", () => { + expect(resolveBoundTriggerSurvey(contextWithSurvey(null), definitionWithSurvey("survey-1"))).toBeNull(); + }); + + test("returns null without a context or definition", () => { + expect(resolveBoundTriggerSurvey(null, definitionWithSurvey("survey-1"))).toBeNull(); + expect(resolveBoundTriggerSurvey(contextWithSurvey("survey-1"), null)).toBeNull(); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/bound-survey.ts b/apps/web/modules/ee/workflows/lib/bound-survey.ts new file mode 100644 index 000000000000..227bcb9852c7 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/bound-survey.ts @@ -0,0 +1,20 @@ +import type { TSurvey } from "@formbricks/types/surveys/types"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context"; + +/** + * The survey the workflow's trigger is bound to, or null when there is none to author against. + * + * The authoring context is resolved server-side from the survey bound at page load. If the user + * switched the trigger survey in this session, the context is stale for the new survey — so the + * context survey only counts when its id matches the definition's current trigger `surveyId`. + * Null also covers the fresh-workflow seed (placeholder survey id) and a deleted survey. + */ +export const resolveBoundTriggerSurvey = ( + authoringContext: TWorkflowEmailAuthoringContext | null, + definition: TWorkflowDefinition | null +): TSurvey | null => { + const triggerSurveyId = definition?.trigger?.type === "trigger" ? definition.trigger.config.surveyId : null; + const survey = authoringContext?.survey ?? null; + return survey?.id === triggerSurveyId ? survey : null; +}; diff --git a/apps/web/modules/ee/workflows/lib/default-workflow.test.ts b/apps/web/modules/ee/workflows/lib/default-workflow.test.ts new file mode 100644 index 000000000000..417ed2002784 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/default-workflow.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { ZCreateWorkflowInput, ZWorkflowDefinition } from "@formbricks/workflows"; +import { createEmptyWorkflowDefinition } from "./default-workflow"; + +describe("createEmptyWorkflowDefinition", () => { + test("yields a definition that passes ZWorkflowDefinition", () => { + const result = ZWorkflowDefinition.safeParse(createEmptyWorkflowDefinition()); + expect(result.success).toBe(true); + }); + + test("starts without trigger, nodes, edges, or entry point", () => { + const definition = createEmptyWorkflowDefinition(); + + expect(definition.trigger).toBeNull(); + expect(definition.entryNodeId).toBeNull(); + expect(definition.nodes).toHaveLength(0); + expect(definition.edges).toHaveLength(0); + }); + + test("is accepted as the definition of a valid create input", () => { + const result = ZCreateWorkflowInput.safeParse({ + workspaceId: "cpaqkpwhrrj4desofbrjgqud", + name: "My workflow", + description: null, + status: "draft", + definition: createEmptyWorkflowDefinition(), + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/default-workflow.ts b/apps/web/modules/ee/workflows/lib/default-workflow.ts new file mode 100644 index 000000000000..37bc178b6b65 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/default-workflow.ts @@ -0,0 +1,11 @@ +import type { TWorkflowDefinition } from "@formbricks/workflows"; + +// New workflows start with an empty canvas: the user adds the trigger (and actions) from the +// canvas pickers instead of getting pre-seeded default nodes. +export const createEmptyWorkflowDefinition = (): TWorkflowDefinition => ({ + schemaVersion: 1, + trigger: null, + nodes: [], + edges: [], + entryNodeId: null, +}); diff --git a/apps/web/modules/ee/workflows/lib/definition-to-flow.test.ts b/apps/web/modules/ee/workflows/lib/definition-to-flow.test.ts new file mode 100644 index 000000000000..c5a1cf23d9f3 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/definition-to-flow.test.ts @@ -0,0 +1,197 @@ +import type { TFunction } from "i18next"; +import { describe, expect, test } from "vitest"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import { + reorganizeWorkflowDefinition, + snapWorkflowNodePosition, + updateNodePosition, + workflowDefinitionToFlowEdges, + workflowDefinitionToFlowNodes, +} from "./definition-to-flow"; + +const t = ((key: string) => key) as unknown as TFunction; + +const buildDefinition = (overrides?: Partial): TWorkflowDefinition => + ({ + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "u9aohwvk1n3e0gms8p2q6lwt", endingCardIds: [] }, + ui: { position: { x: 0, y: 0 } }, + }, + nodes: [ + { + id: "action-1", + type: "action", + actionType: "send_email", + label: "Send email", + config: { + to: "respondent@example.com", + from: "team@example.com", + replyTo: [], + subject: "Thanks", + body: "Body", + attachResponseData: false, + }, + ui: { position: { x: 0, y: 0 } }, + }, + ], + edges: [{ id: "edge-1", source: "trigger-1", target: "action-1" }], + ...overrides, + }) as TWorkflowDefinition; + +describe("snapWorkflowNodePosition", () => { + test("snaps to the 20px grid", () => { + expect(snapWorkflowNodePosition({ x: 7, y: 31 })).toEqual({ x: 0, y: 40 }); + expect(snapWorkflowNodePosition({ x: 31, y: 49 })).toEqual({ x: 40, y: 40 }); + }); +}); + +describe("updateNodePosition", () => { + test("updates trigger position when id matches", () => { + const def = buildDefinition(); + const next = updateNodePosition(def, "trigger-1", { x: 100, y: 80 }); + expect(next.trigger?.ui?.position).toEqual({ x: 100, y: 80 }); + expect(next.nodes[0].ui?.position).toEqual({ x: 0, y: 0 }); + }); + + test("updates child-node position when id matches", () => { + const def = buildDefinition(); + const next = updateNodePosition(def, "action-1", { x: 220, y: 240 }); + expect(next.trigger?.ui?.position).toEqual({ x: 0, y: 0 }); + expect(next.nodes[0].ui?.position).toEqual({ x: 220, y: 240 }); + }); +}); + +describe("workflowDefinitionToFlowNodes / Edges", () => { + test("projects trigger and child nodes with registry metadata", () => { + const def = buildDefinition(); + const nodes = workflowDefinitionToFlowNodes(def, t); + expect(nodes).toHaveLength(2); + expect(nodes[0]).toMatchObject({ + id: "trigger-1", + data: { category: "trigger", icon: "trigger", title: "workspace.workflows.response_completed" }, + }); + expect(nodes[1]).toMatchObject({ + id: "action-1", + data: { category: "action", icon: "email" }, + }); + }); + + test("flags no issues by default (survey resolution unknown)", () => { + const nodes = workflowDefinitionToFlowNodes(buildDefinition(), t); + expect(nodes.map((node) => node.data.issue)).toEqual([null, null]); + }); + + test("flags only the trigger when the bound survey doesn't resolve (sequential guidance)", () => { + const nodes = workflowDefinitionToFlowNodes(buildDefinition(), t, { + hasBoundSurvey: false, + isDraft: true, + }); + expect(nodes[0].data.issue).toEqual({ + severity: "setup", + label: "workspace.workflows.node_needs_survey", + }); + expect(nodes[1].data.issue).toBeNull(); + }); + + test("flags an incomplete email node once the survey is bound, with error severity when live", () => { + const def = buildDefinition(); + (def.nodes[0] as { config: { to: string } }).config.to = ""; + const nodes = workflowDefinitionToFlowNodes(def, t, { hasBoundSurvey: true, isDraft: false }); + expect(nodes[0].data.issue).toBeNull(); + expect(nodes[1].data.issue).toEqual({ + severity: "error", + label: "workspace.workflows.node_needs_email_content", + }); + }); + + test("flags an email node whose subject is blank (parity with the executable schema)", () => { + const def = buildDefinition(); + (def.nodes[0] as { config: { subject: string } }).config.subject = " "; + const nodes = workflowDefinitionToFlowNodes(def, t, { hasBoundSurvey: true, isDraft: true }); + expect(nodes[1].data.issue).toEqual({ + severity: "setup", + label: "workspace.workflows.node_needs_email_content", + }); + }); + + test("flags an email node whose body is only the editor's empty markup", () => { + // The rich-text editor keeps the enclosing block when the body is emptied, so a trim-based + // check would clear the flag on a step that has nothing to send. + const def = buildDefinition(); + (def.nodes[0] as { config: { body: string } }).config.body = '


'; + const nodes = workflowDefinitionToFlowNodes(def, t, { hasBoundSurvey: true, isDraft: true }); + expect(nodes[1].data.issue).toEqual({ + severity: "setup", + label: "workspace.workflows.node_needs_email_content", + }); + }); + + test("projects edges with sourceHandle preserved", () => { + const def = buildDefinition({ + edges: [{ id: "edge-1", source: "trigger-1", target: "action-1", sourceHandle: "then" }], + }); + const edges = workflowDefinitionToFlowEdges(def); + expect(edges[0]).toMatchObject({ + id: "edge-1", + source: "trigger-1", + target: "action-1", + sourceHandle: "then", + type: "addButton", + }); + }); +}); + +describe("reorganizeWorkflowDefinition", () => { + test("ranks reachable nodes by BFS distance from the trigger", () => { + const def = buildDefinition(); + const next = reorganizeWorkflowDefinition(def); + const triggerY = next.trigger?.ui?.position?.y ?? 0; + const actionY = next.nodes[0].ui?.position?.y ?? 0; + expect(actionY).toBeGreaterThan(triggerY); + }); + + test("places unreachable nodes below the reachable graph", () => { + const base = buildDefinition(); + const orphan = { ...base.nodes[0], id: "orphan", ui: undefined } as (typeof base.nodes)[number]; + const def: TWorkflowDefinition = { ...base, nodes: [...base.nodes, orphan] }; + + const next = reorganizeWorkflowDefinition(def); + const reachableY = next.nodes[0].ui?.position?.y ?? 0; + const orphanY = next.nodes[1].ui?.position?.y ?? 0; + expect(orphanY).toBeGreaterThan(reachableY); + }); +}); + +describe("workflowDefinitionToFlowNodes fallback", () => { + test("falls back to a derived position when ui.position is missing", () => { + const base = buildDefinition(); + const triggerWithoutUi = { ...base.trigger, ui: undefined }; + const def = { ...base, trigger: triggerWithoutUi } as TWorkflowDefinition; + const nodes = workflowDefinitionToFlowNodes(def, t); + expect(nodes[0].position).toEqual({ x: 120, y: 80 }); + }); +}); + +describe("trigger-less draft definitions", () => { + const emptyDefinition = buildDefinition({ + trigger: null, + nodes: [], + edges: [], + entryNodeId: null, + }); + + test("project to zero flow nodes and edges", () => { + expect(workflowDefinitionToFlowNodes(emptyDefinition, t)).toEqual([]); + expect(workflowDefinitionToFlowEdges(emptyDefinition)).toEqual([]); + }); + + test("updateNodePosition and reorganize are no-op safe", () => { + expect(updateNodePosition(emptyDefinition, "anything", { x: 1, y: 1 }).trigger).toBeNull(); + expect(reorganizeWorkflowDefinition(emptyDefinition).trigger).toBeNull(); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/definition-to-flow.ts b/apps/web/modules/ee/workflows/lib/definition-to-flow.ts new file mode 100644 index 000000000000..5907635b11a2 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/definition-to-flow.ts @@ -0,0 +1,208 @@ +import { type Edge, type Node, type SnapGrid } from "@xyflow/react"; +import type { TFunction } from "i18next"; +import { + type TWorkflowDefinition, + type TWorkflowNode, + getBlankSendEmailContentFields, +} from "@formbricks/workflows"; +import { getNodeRegistryEntry } from "@/modules/ee/workflows/lib/node-registry"; +import type { TWorkflowNodeData, TWorkflowNodeIssue } from "@/modules/ee/workflows/state/editor"; + +export const WORKFLOW_CANVAS_NODE_TYPE = "workflowCanvasNode"; + +export const WORKFLOW_CANVAS_SNAP_GRID: SnapGrid = [20, 20]; + +const WORKFLOW_CANVAS_NODE_SPACING = { x: 360, y: 120 }; +const WORKFLOW_CANVAS_START_POSITION = { x: 220, y: 80 }; + +interface WorkflowFlowNodeOptions { + /** + * Whether the trigger's `surveyId` resolves to a real survey (see `resolveBoundTriggerSurvey`). + * Only the canvas can know this (it needs the server-resolved authoring context), so callers + * without it — e.g. the initial hydrate — default to true and get corrected on the next render. + */ + hasBoundSurvey?: boolean; + /** + * Drafts get "setup" severity (amber, guiding) — an unfinished config is the normal starting + * state, not an error. Live or previously-live workflows get "error" (red). + */ + isDraft?: boolean; +} + +// Client-side counterpart of the dry-run test's checks: flags nodes that can't run as configured. +// Sequential guidance: while the survey is missing only the trigger carries a flag — the email +// step can't be configured until a survey exists, so flagging it too is just noise. +const getWorkflowNodeIssue = ( + node: TWorkflowNode, + t: TFunction, + options: Required +): TWorkflowNodeIssue | null => { + const severity = options.isDraft ? "setup" : "error"; + + if (node.type === "trigger" && !options.hasBoundSurvey) { + return { severity, label: t("workspace.workflows.node_needs_survey") }; + } + if (node.type === "action" && node.actionType === "send_email" && options.hasBoundSurvey) { + // Same completeness rule ZWorkflowExecutableDefinition enforces server-side on enable/test, + // including its rich-text handling of the body (see isBlankWorkflowRichText). + if (getBlankSendEmailContentFields(node.config).length > 0) { + return { severity, label: t("workspace.workflows.node_needs_email_content") }; + } + } + return null; +}; + +export const workflowDefinitionToFlowNodes = ( + definition: TWorkflowDefinition, + t: TFunction, + options?: WorkflowFlowNodeOptions +): Array> => { + const sourcesWithEdges = new Set(definition.edges.map((edge) => edge.source)); + const resolvedOptions: Required = { + hasBoundSurvey: options?.hasBoundSurvey ?? true, + isDraft: options?.isDraft ?? true, + }; + + return [...(definition.trigger ? [definition.trigger] : []), ...definition.nodes].map((node, index) => { + const registryEntry = getNodeRegistryEntry(node); + const fallbackPosition = { x: 120, y: 80 + index * 120 }; + + return { + id: node.id, + type: WORKFLOW_CANVAS_NODE_TYPE, + position: node.ui?.position ?? fallbackPosition, + data: { + category: registryEntry.category, + icon: registryEntry.icon, + title: registryEntry.title(node, t), + summary: registryEntry.summary(node, t), + isLeaf: !sourcesWithEdges.has(node.id), + issue: getWorkflowNodeIssue(node, t, resolvedOptions), + }, + }; + }); +}; + +export const workflowDefinitionToFlowEdges = (definition: TWorkflowDefinition): Edge[] => + definition.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + label: edge.sourceHandle, + type: "addButton", + })); + +export const snapWorkflowNodePosition = (position: { x: number; y: number }) => ({ + x: Math.round(position.x / WORKFLOW_CANVAS_SNAP_GRID[0]) * WORKFLOW_CANVAS_SNAP_GRID[0], + y: Math.round(position.y / WORKFLOW_CANVAS_SNAP_GRID[1]) * WORKFLOW_CANVAS_SNAP_GRID[1], +}); + +export const updateNodePosition = ( + definition: TWorkflowDefinition, + nodeId: string, + position: { x: number; y: number } +): TWorkflowDefinition => { + if (definition.trigger?.id === nodeId) { + return { + ...definition, + trigger: { + ...definition.trigger, + ui: { ...definition.trigger.ui, position }, + }, + }; + } + + return { + ...definition, + nodes: definition.nodes.map((node) => + node.id === nodeId ? { ...node, ui: { ...node.ui, position } } : node + ), + }; +}; + +// BFS rank from the trigger so reachable nodes share a row; unreachable nodes (and every node of +// a trigger-less draft) each get their own row below, keeping the original order. +const rankWorkflowNodes = ( + definition: TWorkflowDefinition, + nodesById: Map, + originalNodeOrder: string[] +): Map => { + const edgesBySource = new Map(); + for (const edge of definition.edges) { + edgesBySource.set(edge.source, [...(edgesBySource.get(edge.source) ?? []), edge.target]); + } + + const ranks = new Map(definition.trigger ? [[definition.trigger.id, 0]] : []); + const queue = definition.trigger ? [definition.trigger.id] : []; + while (queue.length > 0) { + const nodeId = queue.shift()!; + const nextRank = (ranks.get(nodeId) ?? 0) + 1; + for (const targetId of edgesBySource.get(nodeId) ?? []) { + if (!nodesById.has(targetId) || ranks.has(targetId)) continue; + ranks.set(targetId, nextRank); + queue.push(targetId); + } + } + + let nextUnreachableRank = Math.max(0, ...ranks.values()) + 1; + for (const nodeId of originalNodeOrder) { + if (!ranks.has(nodeId)) { + ranks.set(nodeId, nextUnreachableRank); + nextUnreachableRank += 1; + } + } + + return ranks; +}; + +export const reorganizeWorkflowDefinition = (definition: TWorkflowDefinition): TWorkflowDefinition => { + const allNodes: TWorkflowNode[] = [ + ...(definition.trigger ? [definition.trigger] : []), + ...definition.nodes, + ]; + const nodesById = new Map(allNodes.map((node) => [node.id, node])); + const originalNodeOrder = allNodes.map((node) => node.id); + const ranks = rankWorkflowNodes(definition, nodesById, originalNodeOrder); + + const nodeIdsByRank = new Map(); + for (const nodeId of originalNodeOrder) { + const rank = ranks.get(nodeId) ?? 0; + nodeIdsByRank.set(rank, [...(nodeIdsByRank.get(rank) ?? []), nodeId]); + } + + const positionsByNodeId = new Map(); + for (const [rank, nodeIds] of nodeIdsByRank) { + const rowOffset = ((nodeIds.length - 1) * WORKFLOW_CANVAS_NODE_SPACING.x) / 2; + nodeIds.forEach((nodeId, index) => { + positionsByNodeId.set( + nodeId, + snapWorkflowNodePosition({ + x: WORKFLOW_CANVAS_START_POSITION.x + index * WORKFLOW_CANVAS_NODE_SPACING.x - rowOffset, + y: WORKFLOW_CANVAS_START_POSITION.y + rank * WORKFLOW_CANVAS_NODE_SPACING.y, + }) + ); + }); + } + + return { + ...definition, + trigger: definition.trigger + ? { + ...definition.trigger, + ui: { + ...definition.trigger.ui, + position: positionsByNodeId.get(definition.trigger.id) ?? definition.trigger.ui?.position, + }, + } + : null, + nodes: definition.nodes.map((node) => ({ + ...node, + ui: { + ...node.ui, + position: positionsByNodeId.get(node.id) ?? node.ui?.position, + }, + })), + }; +}; diff --git a/apps/web/modules/ee/workflows/lib/display.test.ts b/apps/web/modules/ee/workflows/lib/display.test.ts new file mode 100644 index 000000000000..018d39e3f1d1 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/display.test.ts @@ -0,0 +1,273 @@ +import type { TFunction } from "i18next"; +import { describe, expect, test } from "vitest"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import { + type TWorkflowValidationProblem, + deriveTriggerEndingProblems, + deriveWorkflowValidation, +} from "@/modules/ee/workflows/state/editor"; +import { + getWorkflowRunLogStatusBadge, + getWorkflowRunStatusBadge, + getWorkflowStatusBadge, + getWorkflowTriggerTypeLabel, + getWorkflowValidationProblemFocusTarget, + getWorkflowValidationProblemLocation, +} from "./display"; + +// Identity translator so assertions can check the i18n key each helper resolves. +const t = ((key: string) => key) as unknown as TFunction; + +describe("getWorkflowStatusBadge", () => { + test.each([ + ["enabled", "common.enabled", "success"], + ["disabled", "common.disabled", "gray"], + ["archived", "common.archived", "gray"], + ["draft", "common.draft", "gray"], + ] as const)("maps %s to label %s / type %s", (status, label, type) => { + expect(getWorkflowStatusBadge(status, t)).toEqual({ label, type }); + }); +}); + +describe("getWorkflowRunStatusBadge", () => { + test.each([ + ["completed", "common.completed", "success"], + ["failed", "common.failed", "error"], + ["running", "common.running", "warning"], + ["canceled", "common.canceled", "gray"], + ["queued", "common.queued", "gray"], + ] as const)("maps %s to label %s / type %s", (status, label, type) => { + expect(getWorkflowRunStatusBadge(status, t)).toEqual({ label, type }); + }); +}); + +describe("getWorkflowRunLogStatusBadge", () => { + test.each([ + ["succeeded", "common.succeeded", "success"], + ["failed", "common.failed", "error"], + ["running", "common.running", "warning"], + ["skipped", "common.skipped", "gray"], + ["pending", "common.pending", "gray"], + ] as const)("maps %s to label %s / type %s", (status, label, type) => { + expect(getWorkflowRunLogStatusBadge(status, t)).toEqual({ label, type }); + }); +}); + +describe("getWorkflowTriggerTypeLabel", () => { + test("maps response.completed to its label key", () => { + expect(getWorkflowTriggerTypeLabel("response.completed", t)).toBe("common.response_completed"); + }); +}); + +describe("getWorkflowValidationProblemLocation", () => { + const definition = { + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "survey-1", endingCardIds: [] }, + }, + nodes: [ + { id: "email-1", type: "action", actionType: "send_email", label: "Welcome email", config: {} }, + { id: "email-2", type: "action", actionType: "send_email", config: {} }, + { id: "ifelse-1", type: "if_else", config: {} }, + ], + edges: [], + } as unknown as TWorkflowDefinition; + + const problem = (field: string): TWorkflowValidationProblem => ({ code: "definition_invalid", field }); + + test("resolves nodes.N fields to the step's canvas title (user label first)", () => { + expect(getWorkflowValidationProblemLocation(problem("nodes.0.config"), definition, t)).toBe( + "Welcome email" + ); + expect(getWorkflowValidationProblemLocation(problem("nodes.1.config"), definition, t)).toBe( + "workspace.workflows.send_email" + ); + expect(getWorkflowValidationProblemLocation(problem("nodes.2.type"), definition, t)).toBe( + "workspace.workflows.if_else" + ); + }); + + test("resolves trigger config fields to the trigger's title", () => { + expect(getWorkflowValidationProblemLocation(problem("trigger.config.surveyId"), definition, t)).toBe( + "workspace.workflows.response_completed" + ); + expect(getWorkflowValidationProblemLocation(problem("trigger.config.endingCardIds"), definition, t)).toBe( + "workspace.workflows.response_completed" + ); + }); + + test("whole-flow and unresolvable fields carry no location", () => { + expect(getWorkflowValidationProblemLocation(problem("name"), definition, t)).toBeNull(); + expect(getWorkflowValidationProblemLocation(problem("trigger"), definition, t)).toBeNull(); + expect(getWorkflowValidationProblemLocation(problem("edges"), definition, t)).toBeNull(); + expect(getWorkflowValidationProblemLocation(problem("nodes"), definition, t)).toBeNull(); + expect(getWorkflowValidationProblemLocation(problem("nodes.9.config"), definition, t)).toBeNull(); + expect(getWorkflowValidationProblemLocation(problem("nodes.0.config"), null, t)).toBeNull(); + // Trigger-scoped field on a trigger-less draft (unreachable in practice; guard anyway). + const triggerless = { ...definition, trigger: null } as unknown as TWorkflowDefinition; + expect( + getWorkflowValidationProblemLocation(problem("trigger.config.surveyId"), triggerless, t) + ).toBeNull(); + }); +}); + +describe("problem field paths stay shared between producer and consumer", () => { + // The producer (deriveWorkflowValidation) and this consumer both go through + // workflowProblemFields. Feeding the producer's OWN output back in proves the round trip, so a + // renamed path can't compile on one side and silently stop resolving on the other. + const definition = { + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "survey-1", endingCardIds: [] }, + }, + nodes: [ + { + id: "email-1", + type: "action", + actionType: "send_email", + // Otherwise valid so the schema reaches its content check: an incomplete config shape fails + // the discriminated union at `nodes.0` and reports as definition_invalid instead. + config: { + to: "", + from: "team@example.com", + replyTo: [], + subject: "", + body: "", + attachResponseData: false, + }, + }, + ], + edges: [{ id: "e1", source: "trigger-1", target: "email-1" }], + } as unknown as TWorkflowDefinition; + + test("a step_incomplete problem the producer emitted resolves to a field", () => { + const { problems } = deriveWorkflowValidation({ + workflowName: "wf", + definition, + hasBoundTriggerSurvey: true, + }); + const stepProblem = problems.find((p) => p.code === "step_incomplete"); + expect(stepProblem).toBeDefined(); + expect(getWorkflowValidationProblemFocusTarget(stepProblem!, definition)).toEqual({ + nodeId: "email-1", + field: "to", + }); + }); + + test("an unbound-survey problem the producer emitted resolves to the survey picker", () => { + const { problems } = deriveWorkflowValidation({ + workflowName: "wf", + definition, + hasBoundTriggerSurvey: false, + }); + const surveyProblem = problems.find((p) => p.code === "trigger_survey_unbound"); + expect(surveyProblem).toBeDefined(); + expect(getWorkflowValidationProblemFocusTarget(surveyProblem!, definition)).toEqual({ + nodeId: "trigger-1", + field: "surveyId", + }); + }); + + test("a stale-ending problem the producer emitted resolves to the endings control", () => { + const [endingProblem] = deriveTriggerEndingProblems(["gone"], ["kept"]); + expect(endingProblem).toBeDefined(); + expect(getWorkflowValidationProblemFocusTarget(endingProblem, definition)).toEqual({ + nodeId: "trigger-1", + field: "endingCardIds", + }); + }); +}); + +describe("getWorkflowValidationProblemFocusTarget", () => { + const buildDefinition = (emailConfig: Record): TWorkflowDefinition => + ({ + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "survey-1", endingCardIds: [] }, + }, + nodes: [ + { id: "email-1", type: "action", actionType: "send_email", config: emailConfig }, + { id: "ifelse-1", type: "if_else", config: {} }, + ], + edges: [], + }) as unknown as TWorkflowDefinition; + + const problem = (field: string): TWorkflowValidationProblem => ({ code: "step_incomplete", field }); + + test("points a step-level problem at the first blank required field, in form order", () => { + const definition = buildDefinition({ to: "", subject: "", body: "" }); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.config"), definition)).toEqual({ + nodeId: "email-1", + field: "to", + }); + }); + + test("skips already-filled fields", () => { + const definition = buildDefinition({ to: "user@example.com", subject: "", body: "

Hi

" }); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.config"), definition)).toEqual({ + nodeId: "email-1", + field: "subject", + }); + }); + + test("points at a body that only holds the editor's empty markup", () => { + const definition = buildDefinition({ to: "user@example.com", subject: "Hi", body: "


" }); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.config"), definition)).toEqual({ + nodeId: "email-1", + field: "body", + }); + }); + + test("routes each trigger config problem to the control that owns it", () => { + const definition = buildDefinition({ to: "a", subject: "b", body: "c" }); + expect(getWorkflowValidationProblemFocusTarget(problem("trigger.config.surveyId"), definition)).toEqual({ + nodeId: "trigger-1", + field: "surveyId", + }); + // A stale ending is a problem with the endings list, not the survey it came from. + expect( + getWorkflowValidationProblemFocusTarget(problem("trigger.config.endingCardIds"), definition) + ).toEqual({ nodeId: "trigger-1", field: "endingCardIds" }); + }); + + test("ignores non-config paths on an action node", () => { + // Only `step_incomplete`'s exact `nodes.N.config` path resolves to a content field. A problem + // reported elsewhere on the same action node (a shape issue, an unsupported type) must not + // silently jump to "the first blank field", which the message never mentioned. + const definition = buildDefinition({ to: "", subject: "", body: "" }); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.type"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.label"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.config.to"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0"), definition)).toBeNull(); + }); + + test("has no target for problems that aren't fixed in a config form", () => { + const definition = buildDefinition({ to: "a", subject: "b", body: "c" }); + // Nothing blank on the step, a non-action node, whole-flow problems, and out-of-range or + // trigger-less paths all leave the row passive rather than jumping somewhere arbitrary. + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.config"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.1.config"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.9.config"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("name"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("trigger"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("trigger.config"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("edges"), definition)).toBeNull(); + expect(getWorkflowValidationProblemFocusTarget(problem("nodes.0.config"), null)).toBeNull(); + const triggerless = { ...definition, trigger: null } as unknown as TWorkflowDefinition; + expect( + getWorkflowValidationProblemFocusTarget(problem("trigger.config.surveyId"), triggerless) + ).toBeNull(); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/display.ts b/apps/web/modules/ee/workflows/lib/display.ts new file mode 100644 index 000000000000..c38e474c04e0 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/display.ts @@ -0,0 +1,157 @@ +import type { TFunction } from "i18next"; +import { + type TWorkflowDefinition, + type TWorkflowRunLogStatus, + type TWorkflowRunStatus, + type TWorkflowStatus, + type TWorkflowTriggerType, + getBlankSendEmailContentFields, +} from "@formbricks/workflows"; +import { getNodeRegistryEntry } from "@/modules/ee/workflows/lib/node-registry"; +import { + type TWorkflowNodeFieldFocusRequest, + type TWorkflowValidationProblem, + workflowProblemFields, +} from "@/modules/ee/workflows/state/editor"; + +type TBadgeType = "warning" | "success" | "error" | "gray"; + +interface TStatusBadge { + label: string; + type: TBadgeType; +} + +export const getWorkflowStatusBadge = (status: TWorkflowStatus, t: TFunction): TStatusBadge => { + switch (status) { + case "enabled": + return { label: t("common.enabled"), type: "success" }; + case "disabled": + return { label: t("common.disabled"), type: "gray" }; + case "archived": + return { label: t("common.archived"), type: "gray" }; + case "draft": + default: + return { label: t("common.draft"), type: "gray" }; + } +}; + +export const getWorkflowRunStatusBadge = (status: TWorkflowRunStatus, t: TFunction): TStatusBadge => { + switch (status) { + case "completed": + return { label: t("common.completed"), type: "success" }; + case "failed": + return { label: t("common.failed"), type: "error" }; + case "running": + return { label: t("common.running"), type: "warning" }; + case "canceled": + return { label: t("common.canceled"), type: "gray" }; + case "queued": + default: + return { label: t("common.queued"), type: "gray" }; + } +}; + +export const getWorkflowRunLogStatusBadge = (status: TWorkflowRunLogStatus, t: TFunction): TStatusBadge => { + switch (status) { + case "succeeded": + return { label: t("common.succeeded"), type: "success" }; + case "failed": + return { label: t("common.failed"), type: "error" }; + case "running": + return { label: t("common.running"), type: "warning" }; + case "skipped": + return { label: t("common.skipped"), type: "gray" }; + case "pending": + return { label: t("common.pending"), type: "gray" }; + } + + // Exhaustive: a new TWorkflowRunLogStatus from the contract fails to compile here + // instead of being silently mislabeled as "pending". + const exhaustiveCheck: never = status; + return exhaustiveCheck; +}; + +export const getWorkflowTriggerTypeLabel = (triggerType: TWorkflowTriggerType, t: TFunction): string => { + const labels: Record = { + "response.completed": t("common.response_completed"), + }; + + return labels[triggerType]; +}; + +const NODE_FIELD_PATTERN = /^nodes\.(\d+)(?:\.|$)/; + +/** + * Human-readable location for a validation problem, shown under its message in the problems + * dialog: the display title of the step or trigger the problem's `field` points into (the same + * title the canvas card shows — a user-authored label or the localized node-type name). Null for + * whole-flow problems (missing/unconnected trigger, broken flow shape) and any field that does + * not resolve to a concrete node — raw machine paths are never shown. + */ +export const getWorkflowValidationProblemLocation = ( + problem: TWorkflowValidationProblem, + definition: TWorkflowDefinition | null, + t: TFunction +): string | null => { + if (!definition) return null; + + const nodeMatch = NODE_FIELD_PATTERN.exec(problem.field); + if (nodeMatch) { + const node = definition.nodes[Number(nodeMatch[1])]; + if (!node) return null; + return getNodeRegistryEntry(node).title(node, t); + } + + // Only fields inside the trigger's config locate an existing node; the bare "trigger" field + // (= trigger_missing) has nothing on the canvas to point at. + if (problem.field.startsWith("trigger.") && definition.trigger) { + return getNodeRegistryEntry(definition.trigger).title(definition.trigger, t); + } + + return null; +}; + +// Which trigger config path maps to which control in the trigger form. Keyed off the shared +// builders, so a renamed path is a compile error here rather than a jump that silently stops +// resolving at runtime. +const TRIGGER_CONFIG_FIELD_TARGETS: Record = { + [workflowProblemFields.triggerSurveyId]: "surveyId", + [workflowProblemFields.triggerEndingCardIds]: "endingCardIds", +}; + +/** + * The config field a validation problem should send the user to, or null when the problem has no + * single field to fix (a missing trigger, a broken flow shape, an unnamed workflow — those are + * fixed on the canvas or in the page title, not in a config form). + * + * `step_incomplete` deliberately carries a step-level field (`nodes.N.config`) so the problem count + * matches the one unfinished step the user perceives; the specific field is resolved here instead, + * as the first still-blank required one in form order. + */ +export const getWorkflowValidationProblemFocusTarget = ( + problem: TWorkflowValidationProblem, + definition: TWorkflowDefinition | null +): TWorkflowNodeFieldFocusRequest | null => { + if (!definition) return null; + + // Only the exact step-level path `step_incomplete` reports at resolves to a content field. A + // problem elsewhere on an action node (a shape issue at `nodes.N.label`, an unsupported + // `nodes.N.type`) must not land the user on a field its message never mentioned. + const nodeIndex = workflowProblemFields.parseNodeConfigIndex(problem.field); + if (nodeIndex !== null) { + const node = definition.nodes[nodeIndex]; + // Covers an out-of-range index too: `undefined?.type` is undefined, which is not "action". + if (node?.type !== "action") return null; + const [firstBlankField] = getBlankSendEmailContentFields(node.config); + return firstBlankField ? { nodeId: node.id, field: firstBlankField } : null; + } + + // A stale ending is a problem with the endings list, not with the survey itself — sending it to + // the survey picker would imply the wrong fix. + const triggerField = TRIGGER_CONFIG_FIELD_TARGETS[problem.field]; + if (triggerField && definition.trigger) { + return { nodeId: definition.trigger.id, field: triggerField }; + } + + return null; +}; diff --git a/apps/web/modules/ee/workflows/lib/email-authoring-context.test.ts b/apps/web/modules/ee/workflows/lib/email-authoring-context.test.ts new file mode 100644 index 000000000000..b7ed1c029d66 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/email-authoring-context.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getWorkflowEmailAuthoringContext } from "./email-authoring-context"; + +const { + mockGetSession, + mockGetWorkflowById, + mockGetWorkspaceWithTeamIds, + mockGetTeamMemberDetails, + mockGetUserEmail, + mockGetUserLocale, + mockGetSurvey, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetWorkflowById: vi.fn(), + mockGetWorkspaceWithTeamIds: vi.fn(), + mockGetTeamMemberDetails: vi.fn(), + mockGetUserEmail: vi.fn(), + mockGetUserLocale: vi.fn(), + mockGetSurvey: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ prisma: {} })); +vi.mock("@formbricks/workflows/server", () => ({ + createWorkflowsService: () => ({ getWorkflowById: mockGetWorkflowById }), +})); +// Keep ZWorkflowDefinition real so trigger surveyId parsing is exercised end-to-end. +vi.mock("@/lib/constants", () => ({ MAIL_FROM: undefined, DEFAULT_LOCALE: "en-US" })); +vi.mock("@/modules/auth/lib/session", () => ({ getSession: mockGetSession })); +vi.mock("@/modules/survey/editor/lib/team", () => ({ getTeamMemberDetails: mockGetTeamMemberDetails })); +vi.mock("@/modules/survey/editor/lib/user", () => ({ + getUserEmail: mockGetUserEmail, + getUserLocale: mockGetUserLocale, +})); +vi.mock("@/modules/survey/lib/survey", () => ({ getSurvey: mockGetSurvey })); +vi.mock("@/modules/survey/lib/workspace", () => ({ getWorkspaceWithTeamIds: mockGetWorkspaceWithTeamIds })); + +const WORKSPACE_ID = "cm9zr4wsp000508l8y6nh9r2v"; +const SURVEY_ID = "cm9zr4mps000008l8btfy1vtz"; + +const definitionForSurvey = (surveyId: string) => ({ + schemaVersion: 1, + entryNodeId: "trigger", + trigger: { + id: "trigger", + type: "trigger", + triggerType: "response.completed", + config: { surveyId, endingCardIds: [] }, + }, + nodes: [], + edges: [], +}); + +describe("getWorkflowEmailAuthoringContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue({ user: { id: "user1" } }); + mockGetWorkflowById.mockResolvedValue({ + id: "wf1", + workspaceId: WORKSPACE_ID, + definition: definitionForSurvey(SURVEY_ID), + }); + mockGetWorkspaceWithTeamIds.mockResolvedValue({ organizationId: "org1", teamIds: ["team1"] }); + mockGetTeamMemberDetails.mockResolvedValue([{ name: "Alice", email: "alice@example.com" }]); + mockGetUserEmail.mockResolvedValue("me@example.com"); + mockGetUserLocale.mockResolvedValue("de-DE"); + mockGetSurvey.mockResolvedValue({ id: SURVEY_ID, workspaceId: WORKSPACE_ID, blocks: [] }); + }); + + test("returns the bound survey + team/user/sender context for a same-workspace survey", async () => { + const ctx = await getWorkflowEmailAuthoringContext({ workflowId: "wf1", workspaceId: WORKSPACE_ID }); + + expect(ctx.survey).toMatchObject({ id: SURVEY_ID, workspaceId: WORKSPACE_ID }); + expect(ctx.teamMemberDetails).toEqual([{ name: "Alice", email: "alice@example.com" }]); + expect(ctx.userEmail).toBe("me@example.com"); + expect(ctx.locale).toBe("de-DE"); + }); + + test("drops a survey that belongs to another workspace (IDOR guard)", async () => { + mockGetSurvey.mockResolvedValue({ id: SURVEY_ID, workspaceId: "other-workspace", blocks: [] }); + + const ctx = await getWorkflowEmailAuthoringContext({ workflowId: "wf1", workspaceId: WORKSPACE_ID }); + + expect(ctx.survey).toBeNull(); + // The rest of the context still loads so the form can render (degraded to plain inputs). + expect(ctx.userEmail).toBe("me@example.com"); + }); + + test("returns a null survey when the workflow has no survey bound", async () => { + mockGetWorkflowById.mockResolvedValue({ + id: "wf1", + workspaceId: WORKSPACE_ID, + definition: definitionForSurvey(""), + }); + + const ctx = await getWorkflowEmailAuthoringContext({ workflowId: "wf1", workspaceId: WORKSPACE_ID }); + + expect(ctx.survey).toBeNull(); + expect(mockGetSurvey).not.toHaveBeenCalled(); + }); + + test("drops context for a workflow that belongs to another workspace", async () => { + mockGetWorkflowById.mockResolvedValue({ + id: "wf1", + workspaceId: "other-workspace", + definition: definitionForSurvey(SURVEY_ID), + }); + + const ctx = await getWorkflowEmailAuthoringContext({ workflowId: "wf1", workspaceId: WORKSPACE_ID }); + + expect(ctx.survey).toBeNull(); + expect(ctx.teamMemberDetails).toEqual([]); + expect(mockGetSurvey).not.toHaveBeenCalled(); + }); + + test("falls back to the app default sender when MAIL_FROM is unset", async () => { + const ctx = await getWorkflowEmailAuthoringContext({ workflowId: "wf1", workspaceId: WORKSPACE_ID }); + expect(ctx.mailFrom).toBe("noreply@formbricks.com"); + }); + + test("returns an empty context when there is no session", async () => { + mockGetSession.mockResolvedValue(null); + + const ctx = await getWorkflowEmailAuthoringContext({ workflowId: "wf1", workspaceId: WORKSPACE_ID }); + + expect(ctx.survey).toBeNull(); + expect(ctx.userEmail).toBe(""); + expect(mockGetWorkflowById).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/email-authoring-context.ts b/apps/web/modules/ee/workflows/lib/email-authoring-context.ts new file mode 100644 index 000000000000..79d2491d1e28 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/email-authoring-context.ts @@ -0,0 +1,87 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import { ZWorkflowDefinition } from "@formbricks/workflows"; +import { createWorkflowsService } from "@formbricks/workflows/server"; +import { DEFAULT_LOCALE, MAIL_FROM } from "@/lib/constants"; +import { getSession } from "@/modules/auth/lib/session"; +import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context"; +import { getTeamMemberDetails } from "@/modules/survey/editor/lib/team"; +import { getUserEmail, getUserLocale } from "@/modules/survey/editor/lib/user"; +import type { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up"; +import { getSurvey } from "@/modules/survey/lib/survey"; +import { getWorkspaceWithTeamIds } from "@/modules/survey/lib/workspace"; + +const workflowsService = createWorkflowsService({ prisma }); + +/** Reads the bound survey id from a workflow row's (untrusted JSON) definition trigger, if any. */ +const readTriggerSurveyId = (definition: unknown): string | null => { + const parsed = ZWorkflowDefinition.safeParse(definition); + if (!parsed.success) return null; + return parsed.data.trigger?.config.surveyId ?? null; +}; + +/** + * Server-side loader for the data the workflow `send_email` inspector needs to author an email with + * Follow-Ups parity: the bound survey (full internal `TSurvey` for recall + recipient options), the + * team roster, the current user's email/locale, and the configured sender. Resolved entirely in the + * route so the client form receives fully-formed objects (no client re-lookup by id). + * + * The bound survey is looked up from the workflow's persisted trigger `surveyId`; it may be `null` + * when the workflow has no survey bound yet or the survey was deleted — the form degrades gracefully. + */ +export const getWorkflowEmailAuthoringContext = async ({ + workflowId, + workspaceId, +}: { + workflowId: string; + workspaceId: string; +}): Promise => { + const session = await getSession(); + // Match the app-wide default sender used by `@/modules/email` so the read-only From box shows the + // address emails are actually sent from. + const mailFrom = MAIL_FROM ?? "noreply@formbricks.com"; + + const emptyContext: TWorkflowEmailAuthoringContext = { + survey: null, + teamMemberDetails: [], + userEmail: "", + mailFrom, + locale: DEFAULT_LOCALE, + }; + + if (!session?.user) { + return emptyContext; + } + + const [workflow, workspaceWithTeamIds, userEmail, locale] = await Promise.all([ + workflowsService.getWorkflowById(workflowId), + getWorkspaceWithTeamIds(workspaceId), + getUserEmail(session.user.id), + getUserLocale(session.user.id), + ]); + + // Only serve context for a workflow that belongs to the URL workspace (defense in depth; the page + // auth already gates workspace access). + if (workflow?.workspaceId !== workspaceId) { + return { ...emptyContext, userEmail: userEmail ?? "", locale: locale ?? DEFAULT_LOCALE }; + } + + const teamMemberDetails: TFollowUpEmailToUser[] = workspaceWithTeamIds + ? await getTeamMemberDetails(workspaceWithTeamIds.teamIds) + : []; + + // The trigger `surveyId` is author-set but NOT workspace-validated by the workflow patch handler, so a + // member could point it at another workspace's survey. Scope by `workspaceId` here (IDOR guard); a + // non-matching or missing survey resolves to null and the form degrades to plain inputs. + const surveyId = readTriggerSurveyId(workflow.definition); + const loadedSurvey = surveyId ? await getSurvey(surveyId).catch(() => null) : null; + const survey = loadedSurvey?.workspaceId === workspaceId ? loadedSurvey : null; + + return { + survey, + teamMemberDetails, + userEmail: userEmail ?? "", + mailFrom, + locale: locale ?? DEFAULT_LOCALE, + }; +}; diff --git a/apps/web/modules/ee/workflows/lib/format-date.test.ts b/apps/web/modules/ee/workflows/lib/format-date.test.ts new file mode 100644 index 000000000000..87d9752ccdc4 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/format-date.test.ts @@ -0,0 +1,56 @@ +import type { TFunction } from "i18next"; +import { describe, expect, test } from "vitest"; +import { formatRelativeDate } from "@/modules/ee/workflows/lib/format-date"; + +const anchor = new Date("2024-09-15T17:01:00.000Z"); + +// Test stub for i18next's TFunction — echoes the key plus its interpolation values so the tests +// can match on key + payload without needing real translation files loaded. +const t = ((key: string, options?: Record) => { + if (key === "workspace.workflows.relative_today") return `Today, ${options?.time}`; + if (key === "workspace.workflows.relative_yesterday") return `Yesterday, ${options?.time}`; + if (key === "workspace.workflows.relative_days_ago") return `${options?.count} days ago, ${options?.time}`; + return `${options?.date}, ${options?.time}`; +}) as unknown as TFunction; + +describe("formatRelativeDate", () => { + test("labels same-day timestamps with Today", () => { + expect(formatRelativeDate("2024-09-15T16:23:00.000Z", anchor, t)).toMatch(/^Today, /); + }); + + test("labels timestamps from the previous day with Yesterday", () => { + expect(formatRelativeDate("2024-09-14T13:00:00.000Z", anchor, t)).toMatch(/^Yesterday, /); + }); + + test("labels 2–6 days back as N days ago", () => { + expect(formatRelativeDate("2024-09-13T08:15:00.000Z", anchor, t)).toMatch(/^2 days ago, /); + expect(formatRelativeDate("2024-09-10T08:15:00.000Z", anchor, t)).toMatch(/^5 days ago, /); + }); + + test("falls back to month/day for older timestamps", () => { + expect(formatRelativeDate("2024-08-15T09:00:00.000Z", anchor, t)).not.toMatch(/days ago|Yesterday|Today/); + }); + + test("derives Today/Yesterday from the supplied timeZone, not the machine's local day", () => { + // In Pacific/Kiritimati (UTC+14) the anchor is Sep 16, 01:00 and the date is Sep 15, 23:00 — + // different calendar days there, though both instants share the same UTC (and most machines') + // calendar day. The old system-local comparison labeled this "Today". + expect( + formatRelativeDate( + "2024-09-15T09:00:00.000Z", + new Date("2024-09-15T11:00:00.000Z"), + t, + "en-US", + "Pacific/Kiritimati" + ) + ).toMatch(/^Yesterday, /); + }); + + test("counts calendar days, not elapsed 24h blocks, across midnight boundaries", () => { + // 26 elapsed hours but two UTC calendar-day boundaries crossed (Sep 13 23:00 → Sep 15 01:00). + // The old elapsed-ms division floored this to 1 and mislabeled it. + expect(formatRelativeDate("2024-09-13T23:00:00.000Z", new Date("2024-09-15T01:00:00.000Z"), t)).toMatch( + /^2 days ago, / + ); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/format-date.ts b/apps/web/modules/ee/workflows/lib/format-date.ts new file mode 100644 index 000000000000..60fd1d87d749 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/format-date.ts @@ -0,0 +1,58 @@ +import type { TFunction } from "i18next"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** + * The calendar day `date` falls on in `timeZone`, keyed as the UTC midnight of that y/m/d. Keys are + * exact multiples of a day apart, so subtracting two keys yields a calendar-day difference — unlike + * elapsed-ms division, which drifts across midnight boundaries. + */ +const calendarDayKey = (date: Date, timeZone: string): number => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + const get = (type: "year" | "month" | "day"): number => + Number(parts.find((part) => part.type === type)?.value); + return Date.UTC(get("year"), get("month") - 1, get("day")); +}; + +/** + * Formats `iso` as a short relative timestamp ("Today, 4:23 PM" / "Yesterday, 1:00 PM" / + * "2 days ago, 8:15 AM" / "Jan 15, 4:23 PM"). `anchor` is the "now" reference — using a passed-in + * anchor (vs. `new Date()`) keeps placeholder data deterministic and avoids SSR/CSR drift. The + * `t` function localizes the relative tokens; the time and date parts use `Intl` with the user's + * locale. Today/Yesterday/N-days-ago are calendar days in the supplied `timeZone` — the same zone + * the rendered time uses — never the machine's local zone. + */ +export const formatRelativeDate = ( + iso: string, + anchor: Date, + t: TFunction, + locale = "en-US", + timeZone = "UTC" +): string => { + const date = new Date(iso); + const time = date.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit", timeZone }); + + const diffDays = Math.round( + (calendarDayKey(anchor, timeZone) - calendarDayKey(date, timeZone)) / ONE_DAY_MS + ); + + if (diffDays === 0) { + return t("workspace.workflows.relative_today", { time }); + } + + if (diffDays === 1) { + return t("workspace.workflows.relative_yesterday", { time }); + } + + if (diffDays >= 2 && diffDays < 7) { + return t("workspace.workflows.relative_days_ago", { count: diffDays, time }); + } + + const datePart = date.toLocaleDateString(locale, { month: "short", day: "numeric", timeZone }); + return t("workspace.workflows.relative_date", { date: datePart, time }); +}; diff --git a/apps/web/modules/ee/workflows/lib/list-filters.test.ts b/apps/web/modules/ee/workflows/lib/list-filters.test.ts new file mode 100644 index 000000000000..fa79d5fdb112 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/list-filters.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; +import { computeStatusIn, parseStoredWorkflowFilters } from "./list-filters"; + +describe("computeStatusIn", () => { + test("empty selection -> undefined (API default excludes archived)", () => { + expect(computeStatusIn([])).toBeUndefined(); + }); + + test("non-empty selection -> the selected statuses verbatim", () => { + expect(computeStatusIn(["draft", "disabled"])).toEqual(["draft", "disabled"]); + }); + + test("archived flows through when it is one of the selected statuses", () => { + expect(computeStatusIn(["enabled", "archived"])).toEqual(["enabled", "archived"]); + }); +}); + +describe("parseStoredWorkflowFilters", () => { + test("returns null for null or empty input", () => { + expect(parseStoredWorkflowFilters(null)).toBeNull(); + expect(parseStoredWorkflowFilters("")).toBeNull(); + }); + + test("returns null for invalid JSON", () => { + expect(parseStoredWorkflowFilters("{not json")).toBeNull(); + }); + + test("returns null for a payload that does not match the schema", () => { + expect(parseStoredWorkflowFilters(JSON.stringify({ searchValue: 5 }))).toBeNull(); + expect( + parseStoredWorkflowFilters( + JSON.stringify({ searchValue: "x", selectedStatuses: ["bogus"], sortBy: "updatedAt" }) + ) + ).toBeNull(); + }); + + test("parses a valid payload (archived is an allowed status)", () => { + const stored = { + searchValue: "foo", + selectedStatuses: ["draft", "archived"], + sortBy: "name", + }; + expect(parseStoredWorkflowFilters(JSON.stringify(stored))).toEqual(stored); + }); + + test("strips a legacy showArchived field instead of rejecting it", () => { + const parsed = parseStoredWorkflowFilters( + JSON.stringify({ searchValue: "", selectedStatuses: [], sortBy: "updatedAt", showArchived: true }) + ); + expect(parsed).toEqual({ searchValue: "", selectedStatuses: [], sortBy: "updatedAt" }); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/list-filters.ts b/apps/web/modules/ee/workflows/lib/list-filters.ts new file mode 100644 index 000000000000..56aa1cc91b27 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/list-filters.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import type { TWorkflowStatus } from "@formbricks/workflows"; +import { ZWorkflowSortBy, ZWorkflowStatus } from "@formbricks/workflows"; + +/** + * Map the status-filter selection to the `statusIn` the list API expects. The API default-excludes + * archived when `statusIn` is undefined, so an empty selection shows every live workflow and hides + * archived. "archived" is just one more option in the filter: when it is selected it flows through + * here and archived rows appear alongside any other selected statuses. + */ +export const computeStatusIn = (selectedStatuses: TWorkflowStatus[]): TWorkflowStatus[] | undefined => + selectedStatuses.length === 0 ? undefined : selectedStatuses; + +/** + * Toolbar filter state persisted to localStorage (mirrors the surveys list). Parsed defensively on + * read: a malformed or stale payload returns null so the toolbar falls back to its defaults. + */ +const ZStoredWorkflowFilters = z.object({ + searchValue: z.string(), + selectedStatuses: z.array(ZWorkflowStatus), + sortBy: ZWorkflowSortBy, +}); + +export type TStoredWorkflowFilters = z.infer; + +export const parseStoredWorkflowFilters = (raw: string | null): TStoredWorkflowFilters | null => { + if (!raw) return null; + try { + const result = ZStoredWorkflowFilters.safeParse(JSON.parse(raw)); + return result.success ? result.data : null; + } catch { + return null; + } +}; diff --git a/apps/web/modules/ee/workflows/lib/node-registry.test.ts b/apps/web/modules/ee/workflows/lib/node-registry.test.ts new file mode 100644 index 000000000000..61bd9ab36ae8 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/node-registry.test.ts @@ -0,0 +1,97 @@ +import type { TFunction } from "i18next"; +import { describe, expect, test } from "vitest"; +import type { + TWorkflowIfElseNode, + TWorkflowResponseCompletedTriggerNode, + TWorkflowSendEmailActionNode, +} from "@formbricks/workflows"; +import { getNodeRegistryEntry, getNodeRegistryKind } from "./node-registry"; + +const t = ((key: string, options?: Record) => + options ? `${key}:${JSON.stringify(options)}` : key) as unknown as TFunction; + +const triggerNode: TWorkflowResponseCompletedTriggerNode = { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "u9aohwvk1n3e0gms8p2q6lwt", endingCardIds: [] }, +}; + +const triggerNodeWithEndings: TWorkflowResponseCompletedTriggerNode = { + ...triggerNode, + config: { surveyId: "u9aohwvk1n3e0gms8p2q6lwt", endingCardIds: ["ending-1", "ending-2"] }, +}; + +const sendEmailNode: TWorkflowSendEmailActionNode = { + id: "action-1", + type: "action", + actionType: "send_email", + config: { + to: "respondent@example.com", + from: "team@example.com", + replyTo: [], + subject: "Subject", + body: "Body", + attachResponseData: false, + }, +}; + +const ifElseNode: TWorkflowIfElseNode = { + id: "if-1", + type: "if_else", + config: { condition: { connector: "and", conditions: [] } }, +} as unknown as TWorkflowIfElseNode; + +describe("getNodeRegistryKind", () => { + test.each([ + [triggerNode, "trigger:response.completed"], + [sendEmailNode, "action:send_email"], + [ifElseNode, "if_else"], + ] as const)("maps node to its kind", (node, expectedKind) => { + expect(getNodeRegistryKind(node)).toBe(expectedKind); + }); +}); + +describe("getNodeRegistryEntry", () => { + test("trigger entry exposes title and summary", () => { + const entry = getNodeRegistryEntry(triggerNode); + expect(entry.category).toBe("trigger"); + expect(entry.icon).toBe("trigger"); + expect(entry.title(triggerNode, t)).toBe("workspace.workflows.response_completed"); + expect(entry.summary(triggerNode, t)).toBe("workspace.workflows.trigger_summary_all_endings"); + }); + + test("trigger summary includes ending-card count when configured", () => { + const entry = getNodeRegistryEntry(triggerNodeWithEndings); + expect(entry.summary(triggerNodeWithEndings, t)).toContain('"count":2'); + }); + + test("send-email entry includes recipient in summary", () => { + const entry = getNodeRegistryEntry(sendEmailNode); + expect(entry.category).toBe("action"); + expect(entry.icon).toBe("email"); + expect(entry.title(sendEmailNode, t)).toBe("workspace.workflows.send_email"); + expect(entry.summary(sendEmailNode, t)).toContain("respondent@example.com"); + }); + + test("send-email entry uses label override when present", () => { + const labelled = { ...sendEmailNode, label: "Custom send" }; + expect(getNodeRegistryEntry(labelled).title(labelled, t)).toBe("Custom send"); + }); + + test("send-email entry falls back to the unconfigured summary when `to` is blank", () => { + const unconfigured = { ...sendEmailNode, config: { ...sendEmailNode.config, to: "" } }; + expect(getNodeRegistryEntry(unconfigured).summary(unconfigured, t)).toBe( + "workspace.workflows.send_email_unconfigured" + ); + }); + + test("if-else entry exposes flow category + summary key", () => { + const entry = getNodeRegistryEntry(ifElseNode); + expect(entry.category).toBe("flow"); + expect(entry.icon).toBe("ifElse"); + expect(entry.title(ifElseNode, t)).toBe("workspace.workflows.if_else"); + expect(entry.summary(ifElseNode, t)).toBe("workspace.workflows.if_else_summary"); + expect(entry.ConfigForm).toBeNull(); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/node-registry.ts b/apps/web/modules/ee/workflows/lib/node-registry.ts new file mode 100644 index 000000000000..7d418330d58c --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/node-registry.ts @@ -0,0 +1,86 @@ +import type { TFunction } from "i18next"; +import type { ComponentType } from "react"; +import { type TWorkflowNode, WORKFLOW_ACTIONS, WORKFLOW_TRIGGERS } from "@formbricks/workflows"; +import { WorkflowEmailActionForm } from "@/modules/ee/workflows/components/inspector/workflow-email-action-form"; +import { WorkflowTriggerForm } from "@/modules/ee/workflows/components/inspector/workflow-trigger-form"; +import type { TWorkflowNodeCategory, TWorkflowNodeIcon } from "@/modules/ee/workflows/state/editor"; + +export type TWorkflowNodeRegistryKind = + | `trigger:${(typeof WORKFLOW_TRIGGERS)[keyof typeof WORKFLOW_TRIGGERS]}` + | `action:${(typeof WORKFLOW_ACTIONS)[keyof typeof WORKFLOW_ACTIONS]}` + | "if_else"; + +export interface TWorkflowNodeFormProps { + node: TNode; + isEditable: boolean; + onChange: (next: TNode) => void; +} + +export interface TWorkflowNodeRegistryEntry { + kind: TWorkflowNodeRegistryKind; + category: TWorkflowNodeCategory; + icon: TWorkflowNodeIcon; + title: (node: TWorkflowNode, t: TFunction) => string; + summary: (node: TWorkflowNode, t: TFunction) => string; + // Stored erased to `TWorkflowNode`; the inspector narrows by kind before rendering. + ConfigForm: ComponentType> | null; +} + +const eraseForm = ( + Form: ComponentType> +): ComponentType> => + Form as unknown as ComponentType>; + +const responseCompletedTriggerEntry: TWorkflowNodeRegistryEntry = { + kind: `trigger:${WORKFLOW_TRIGGERS.RESPONSE_COMPLETED}`, + category: "trigger", + icon: "trigger", + title: (_node, t) => t("workspace.workflows.response_completed"), + summary: (node, t) => { + if (node.type !== "trigger") return ""; + return node.config.endingCardIds.length > 0 + ? t("workspace.workflows.trigger_summary_ending_cards", { + count: node.config.endingCardIds.length, + }) + : t("workspace.workflows.trigger_summary_all_endings"); + }, + ConfigForm: eraseForm(WorkflowTriggerForm), +}; + +const sendEmailActionEntry: TWorkflowNodeRegistryEntry = { + kind: `action:${WORKFLOW_ACTIONS.SEND_EMAIL}`, + category: "action", + icon: "email", + title: (node, t) => node.label ?? t("workspace.workflows.send_email"), + summary: (node, t) => { + if (node.type !== "action" || node.actionType !== WORKFLOW_ACTIONS.SEND_EMAIL) return ""; + return node.config.to + ? t("workspace.workflows.send_email_summary", { to: node.config.to }) + : t("workspace.workflows.send_email_unconfigured"); + }, + ConfigForm: eraseForm(WorkflowEmailActionForm), +}; + +const ifElseEntry: TWorkflowNodeRegistryEntry = { + kind: "if_else", + category: "flow", + icon: "ifElse", + title: (_node, t) => t("workspace.workflows.if_else"), + summary: (_node, t) => t("workspace.workflows.if_else_summary"), + ConfigForm: null, +}; + +const REGISTRY: Record = { + "trigger:response.completed": responseCompletedTriggerEntry, + "action:send_email": sendEmailActionEntry, + if_else: ifElseEntry, +}; + +export const getNodeRegistryKind = (node: TWorkflowNode): TWorkflowNodeRegistryKind => { + if (node.type === "trigger") return `trigger:${node.triggerType}`; + if (node.type === "if_else") return "if_else"; + return `action:${node.actionType}`; +}; + +export const getNodeRegistryEntry = (node: TWorkflowNode): TWorkflowNodeRegistryEntry => + REGISTRY[getNodeRegistryKind(node)]; diff --git a/apps/web/modules/ee/workflows/lib/query.test.ts b/apps/web/modules/ee/workflows/lib/query.test.ts new file mode 100644 index 000000000000..45d7fd81edd4 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/query.test.ts @@ -0,0 +1,143 @@ +import type { InfiniteData } from "@tanstack/react-query"; +import { describe, expect, test } from "vitest"; +import type { TWorkflowListItem, TWorkflowRunListItem } from "@formbricks/workflows"; +import type { TWorkflowListPage, TWorkflowRunListPage } from "./api-client"; +import { + flattenWorkflowPages, + flattenWorkflowRunPages, + removeWorkflowFromInfiniteData, + workflowKeys, + workflowRunKeys, +} from "./query"; + +const workflowListItem = (id: string, name: string): TWorkflowListItem => ({ + id, + workspaceId: "ws_1", + name, + description: null, + status: "draft", + triggerType: "response.completed", + surveyId: "survey_1", + createdBy: null, + creator: null, + createdAt: "2026-06-11T09:30:00.000Z", + updatedAt: "2026-06-11T09:30:00.000Z", + lastRun: null, + runCount: 0, +}); + +const buildData = (): InfiniteData => ({ + pages: [ + { + data: [workflowListItem("wf_1", "One"), workflowListItem("wf_2", "Two")], + meta: { limit: 12, nextCursor: "cursor_1" }, + }, + { + data: [workflowListItem("wf_3", "Three")], + meta: { limit: 12, nextCursor: null }, + }, + ], + pageParams: [null, "cursor_1"], +}); + +describe("workflowKeys", () => { + test("list key extends the lists key with the full input", () => { + const input = { workspaceId: "ws_1", limit: 12, nameContains: "abc" }; + expect(workflowKeys.list(input)).toEqual(["workflows", "list", input]); + expect(workflowKeys.lists()).toEqual(["workflows", "list"]); + }); + + test("a different search term produces a distinct query key", () => { + const base = { workspaceId: "ws_1", limit: 12, nameContains: "" }; + expect(workflowKeys.list(base)).not.toEqual(workflowKeys.list({ ...base, nameContains: "abc" })); + }); +}); + +describe("flattenWorkflowPages", () => { + test("flattens every page's data in order", () => { + expect(flattenWorkflowPages(buildData()).map((w) => w.id)).toEqual(["wf_1", "wf_2", "wf_3"]); + }); + + test("returns an empty array when there is no data", () => { + expect(flattenWorkflowPages(undefined)).toEqual([]); + }); +}); + +describe("removeWorkflowFromInfiniteData", () => { + test("removes the matching workflow from its page", () => { + const next = removeWorkflowFromInfiniteData(buildData(), "wf_2"); + expect(flattenWorkflowPages(next).map((w) => w.id)).toEqual(["wf_1", "wf_3"]); + }); + + test("returns the same reference when nothing matched", () => { + const data = buildData(); + expect(removeWorkflowFromInfiniteData(data, "missing")).toBe(data); + }); + + test("returns undefined when there is no data", () => { + expect(removeWorkflowFromInfiniteData(undefined, "wf_1")).toBeUndefined(); + }); +}); + +const workflowRunListItem = (id: string, workflowName: string): TWorkflowRunListItem => ({ + id, + workflowId: "wf_1", + workspaceId: "ws_1", + workflowVersionId: "wfv_1", + status: "completed", + isDryRun: false, + triggerType: "response.completed", + surveyId: "survey_1", + responseId: "resp_1", + error: null, + attempt: 0, + createdAt: "2026-06-12T10:00:00.000Z", + updatedAt: "2026-06-12T10:00:30.000Z", + startedAt: "2026-06-12T10:00:10.000Z", + finishedAt: "2026-06-12T10:00:30.000Z", + workflowName, +}); + +const buildRunData = (): InfiniteData => ({ + pages: [ + { + data: [workflowRunListItem("run_1", "Notify team"), workflowRunListItem("run_2", "Notify team")], + meta: { limit: 20, nextCursor: "cursor_1" }, + }, + { + data: [workflowRunListItem("run_3", "Notify team")], + meta: { limit: 20, nextCursor: null }, + }, + ], + pageParams: [null, "cursor_1"], +}); + +describe("workflowRunKeys", () => { + test("list key extends the lists key with the full input", () => { + const input = { workspaceId: "ws_1", limit: 20, filters: { workflowId: "wf_1" } }; + expect(workflowRunKeys.list(input)).toEqual(["workflow-runs", "list", input]); + expect(workflowRunKeys.lists()).toEqual(["workflow-runs", "list"]); + }); + + test("detail key extends the details key with the run id", () => { + expect(workflowRunKeys.detail("run_1")).toEqual(["workflow-runs", "detail", "run_1"]); + expect(workflowRunKeys.details()).toEqual(["workflow-runs", "detail"]); + }); + + test("a different filter set produces a distinct list key", () => { + const base = { workspaceId: "ws_1", limit: 20 }; + expect(workflowRunKeys.list(base)).not.toEqual( + workflowRunKeys.list({ ...base, filters: { workflowId: "wf_1" } }) + ); + }); +}); + +describe("flattenWorkflowRunPages", () => { + test("flattens every page's data in order", () => { + expect(flattenWorkflowRunPages(buildRunData()).map((r) => r.id)).toEqual(["run_1", "run_2", "run_3"]); + }); + + test("returns an empty array when there is no data", () => { + expect(flattenWorkflowRunPages(undefined)).toEqual([]); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/query.ts b/apps/web/modules/ee/workflows/lib/query.ts new file mode 100644 index 000000000000..8168ce2f8fa1 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/query.ts @@ -0,0 +1,72 @@ +import type { InfiniteData } from "@tanstack/react-query"; +import type { + TWorkflowListItem, + TWorkflowRunListItem, + TWorkflowSortBy, + TWorkflowStatus, +} from "@formbricks/workflows"; +import type { TWorkflowListPage, TWorkflowRunListFilters, TWorkflowRunListPage } from "./api-client"; + +export interface TWorkflowListKeyInput { + workspaceId: string; + limit: number; + nameContains: string; + statusIn?: TWorkflowStatus[]; + sortBy?: TWorkflowSortBy; +} + +export const workflowKeys = { + all: ["workflows"] as const, + lists: () => [...workflowKeys.all, "list"] as const, + list: (input: TWorkflowListKeyInput) => [...workflowKeys.lists(), input] as const, + details: () => [...workflowKeys.all, "detail"] as const, + detail: (workflowId: string) => [...workflowKeys.details(), workflowId] as const, +}; + +export function flattenWorkflowPages(data?: InfiniteData): TWorkflowListItem[] { + return data?.pages.flatMap((page) => page.data) ?? []; +} + +export interface TWorkflowRunListKeyInput { + workspaceId: string; + limit: number; + filters?: TWorkflowRunListFilters; +} + +export const workflowRunKeys = { + all: ["workflow-runs"] as const, + lists: () => [...workflowRunKeys.all, "list"] as const, + list: (input: TWorkflowRunListKeyInput) => [...workflowRunKeys.lists(), input] as const, + details: () => [...workflowRunKeys.all, "detail"] as const, + detail: (runId: string) => [...workflowRunKeys.details(), runId] as const, +}; + +export function flattenWorkflowRunPages(data?: InfiniteData): TWorkflowRunListItem[] { + return data?.pages.flatMap((page) => page.data) ?? []; +} + +export function removeWorkflowFromInfiniteData( + data: InfiniteData | undefined, + workflowId: string +): InfiniteData | undefined { + if (!data) { + return data; + } + + let workflowWasRemoved = false; + + const pages = data.pages.map((page) => { + const nextData = page.data.filter((workflow) => workflow.id !== workflowId); + if (nextData.length !== page.data.length) { + workflowWasRemoved = true; + } + + return { ...page, data: nextData }; + }); + + if (!workflowWasRemoved) { + return data; + } + + return { ...data, pages }; +} diff --git a/apps/web/modules/ee/workflows/lib/run-display.test.ts b/apps/web/modules/ee/workflows/lib/run-display.test.ts new file mode 100644 index 000000000000..927555bf598d --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/run-display.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vitest"; +import { MAX_RUN_JSON_CHARS, formatStepDuration, hasKeys, stringifyRunJson } from "./run-display"; + +describe("formatStepDuration", () => { + test("returns null when startedAt is missing", () => { + expect(formatStepDuration(null, "2026-06-12T10:00:01.000Z")).toBeNull(); + }); + + test("returns null when finishedAt is missing", () => { + expect(formatStepDuration("2026-06-12T10:00:00.000Z", null)).toBeNull(); + }); + + test("returns null when both timestamps are missing", () => { + expect(formatStepDuration(null, null)).toBeNull(); + }); + + test("returns null for unparseable dates (NaN elapsed)", () => { + expect(formatStepDuration("not-a-date", "also-not-a-date")).toBeNull(); + }); + + test("returns null when finishedAt precedes startedAt (negative elapsed)", () => { + expect(formatStepDuration("2026-06-12T10:00:05.000Z", "2026-06-12T10:00:00.000Z")).toBeNull(); + }); + + test("formats sub-second durations in milliseconds", () => { + expect(formatStepDuration("2026-06-12T10:00:00.000Z", "2026-06-12T10:00:00.250Z")).toBe("250ms"); + }); + + test("formats the zero-elapsed boundary as 0ms", () => { + expect(formatStepDuration("2026-06-12T10:00:00.000Z", "2026-06-12T10:00:00.000Z")).toBe("0ms"); + }); + + test("formats one-second-and-over durations in seconds with one decimal", () => { + expect(formatStepDuration("2026-06-12T10:00:00.000Z", "2026-06-12T10:00:01.500Z")).toBe("1.5s"); + }); +}); + +describe("hasKeys", () => { + test("is false for an empty object", () => { + expect(hasKeys({})).toBe(false); + }); + + test("is true for a non-empty object", () => { + expect(hasKeys({ a: 1 })).toBe(true); + }); +}); + +describe("stringifyRunJson", () => { + test("pretty-prints a value that fits under the cap", () => { + expect(stringifyRunJson({ a: 1 })).toBe('{\n "a": 1\n}'); + }); + + test("truncates an oversized payload to roughly the cap and notes the true length", () => { + const big = { blob: "x".repeat(MAX_RUN_JSON_CHARS * 2) }; + const full = JSON.stringify(big, null, 2); + const out = stringifyRunJson(big); + // Bounded well under the full payload — the whole point of the cap. + expect(out.length).toBeLessThan(full.length); + expect(out.length).toBeLessThan(MAX_RUN_JSON_CHARS + 100); + expect(out).toContain("truncated"); + expect(out).toContain(String(full.length)); + }); + + test("respects a custom maxChars and notes the true length", () => { + const out = stringifyRunJson({ value: "abcdefghij" }, 5); + expect(out.startsWith('{\n "')).toBe(true); + expect(out).toContain("truncated"); + }); + + test("falls back to String() when JSON.stringify yields undefined", () => { + expect(stringifyRunJson(undefined)).toBe("undefined"); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/run-display.ts b/apps/web/modules/ee/workflows/lib/run-display.ts new file mode 100644 index 000000000000..9268467df17b --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/run-display.ts @@ -0,0 +1,27 @@ +import { type TWorkflowRunDetail } from "@/modules/ee/workflows/types"; + +export type TWorkflowRunLog = TWorkflowRunDetail["logs"][number]; + +// Cap on the characters rendered for a single run JSON blob (trigger payload, run data, step +// input/output). These columns are unbounded `Json`; past this size the pretty-printed string is +// truncated with a notice so one multi-MB payload can't blow up the DOM or the Prism highlight pass. +export const MAX_RUN_JSON_CHARS = 50_000; + +// Render a single step's duration as a compact label; null when the step hasn't started/finished +// or the timestamps don't yield a sane, finite, non-negative elapsed value. +export const formatStepDuration = (startedAt: string | null, finishedAt: string | null): string | null => { + if (!startedAt || !finishedAt) return null; + const ms = new Date(finishedAt).getTime() - new Date(startedAt).getTime(); + if (!Number.isFinite(ms) || ms < 0) return null; + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; +}; + +export const hasKeys = (value: Record): boolean => Object.keys(value).length > 0; + +// Pretty-print a run JSON value for display, bounding the output length. Returns the full string +// when it fits; otherwise truncates and appends a notice with the true character count. +export const stringifyRunJson = (value: unknown, maxChars: number = MAX_RUN_JSON_CHARS): string => { + const json = JSON.stringify(value, null, 2) ?? String(value); + if (json.length <= maxChars) return json; + return `${json.slice(0, maxChars)}\n\n… truncated — ${json.length} characters total`; +}; diff --git a/apps/web/modules/ee/workflows/lib/runner/dispatch.test.ts b/apps/web/modules/ee/workflows/lib/runner/dispatch.test.ts new file mode 100644 index 000000000000..e858a3db7500 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/dispatch.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test, vi } from "vitest"; +import { dispatchWorkflowRunViaJobs } from "./dispatch"; + +const { enqueueWorkflowRunJob } = vi.hoisted(() => ({ enqueueWorkflowRunJob: vi.fn() })); +vi.mock("@formbricks/jobs", () => ({ enqueueWorkflowRunJob })); + +describe("dispatchWorkflowRunViaJobs", () => { + test("enqueues the run with a deterministic jobId equal to the run id", async () => { + enqueueWorkflowRunJob.mockResolvedValue({ id: "job_1" }); + + await dispatchWorkflowRunViaJobs({ workflowRunId: "run_1", workflowId: "wf_1", workspaceId: "ws_1" }); + + expect(enqueueWorkflowRunJob).toHaveBeenCalledWith( + { workflowRunId: "run_1", workflowId: "wf_1", workspaceId: "ws_1" }, + { jobId: "run_1" } + ); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/dispatch.ts b/apps/web/modules/ee/workflows/lib/runner/dispatch.ts new file mode 100644 index 000000000000..198f656e93c8 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/dispatch.ts @@ -0,0 +1,22 @@ +import { enqueueWorkflowRunJob } from "@formbricks/jobs"; + +export interface WorkflowRunDispatch { + workflowRunId: string; + workflowId: string; + workspaceId: string; +} + +/** + * Hands a persisted, `queued` WorkflowRun off to the task backend for processing. The runner depends + * only on this port — the WorkflowRun row is the durable, backend-neutral source of truth — so moving + * off BullMQ to another backend is a one-file change here, not a rewrite of matching/run-creation. + */ +export type DispatchWorkflowRun = (input: WorkflowRunDispatch) => Promise; + +/** + * BullMQ-backed dispatcher: enqueues `workflow-run.process` with a deterministic `jobId` (the run id) + * so a replayed enqueue is idempotent at the queue level (no duplicate job). + */ +export const dispatchWorkflowRunViaJobs: DispatchWorkflowRun = async (input) => { + await enqueueWorkflowRunJob(input, { jobId: input.workflowRunId }); +}; diff --git a/apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.test.ts b/apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.test.ts new file mode 100644 index 000000000000..691ab6be0cf9 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { Prisma } from "@formbricks/database/prisma"; +import { enqueueResponseCompletedWorkflowRuns } from "./enqueue-response-completed-runs"; + +const { findMany, create, findUnique } = vi.hoisted(() => ({ + findMany: vi.fn(), + create: vi.fn(), + findUnique: vi.fn(), +})); +const { warn, error, info } = vi.hoisted(() => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn() })); +const { markDispatched } = vi.hoisted(() => ({ markDispatched: vi.fn() })); +const { getIsWorkflowsEnabled } = vi.hoisted(() => ({ getIsWorkflowsEnabled: vi.fn() })); +const { recordWorkflowRunCreatedMeterEvent } = vi.hoisted(() => ({ + recordWorkflowRunCreatedMeterEvent: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { workflow: { findMany }, workflowRun: { create, findUnique } }, +})); +vi.mock("@formbricks/logger", () => ({ logger: { warn, error, info } })); +vi.mock("./mark-dispatched", () => ({ markWorkflowRunDispatched: markDispatched })); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsWorkflowsEnabled })); +vi.mock("@/modules/ee/billing/lib/metering", () => ({ recordWorkflowRunCreatedMeterEvent })); + +const workspaceId = "cm9zr4mps000008l8btfy1vtz"; +const organizationId = "cm9zr4org000008l8btfy1org"; +const surveyId = "cm9zr4q7i000108l84gozfggr"; +const responseId = "cm9zr4resp0000000000000a1"; +const endingId = "cm9zr4q7i000108l84goze001"; + +const response = { + id: responseId, + surveyId, + finished: true, + // Widened so tests can spread in `endingId: null` (matches RunnerResponse's `string | null`). + endingId: endingId as string | null, + updatedAt: new Date("2026-06-12T09:30:00.000Z"), + data: { q1: "answer" }, +}; + +const definition = (config: { surveyId: string; endingCardIds: string[] }) => ({ + schemaVersion: 1, + trigger: { id: "trigger", type: "trigger", triggerType: "response.completed", config }, + nodes: [], + edges: [], + entryNodeId: "trigger", +}); + +// One enabled workflow whose published version targets the response's survey (any ending). +const enabledWorkflow = (workflowId: string, versionId: string, endingCardIds: string[] = []) => ({ + id: workflowId, + versions: [{ id: versionId, definition: definition({ surveyId, endingCardIds }) }], +}); + +const dispatch = + vi.fn<(input: { workflowRunId: string; workflowId: string; workspaceId: string }) => Promise>(); + +const run = (input = { response, workspaceId, organizationId, dispatch }) => + enqueueResponseCompletedWorkflowRuns(input); + +beforeEach(() => { + vi.clearAllMocks(); + dispatch.mockResolvedValue(undefined); + // Entitled by default; the entitlement-skip tests flip this off explicitly. + getIsWorkflowsEnabled.mockResolvedValue(true); + // The real helper never rejects (it owns its errors); the enqueue awaits the collected promises. + recordWorkflowRunCreatedMeterEvent.mockResolvedValue(undefined); +}); + +describe("enqueueResponseCompletedWorkflowRuns", () => { + test("creates a queued run bound to the published version and dispatches it", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockResolvedValue({ id: "run_1" }); + + await run(); + + const data = create.mock.calls[0][0].data; + expect(data).toMatchObject({ + workflowId: "wf_1", + workspaceId, + workflowVersionId: "ver_1", + status: "queued", + triggerType: "response.completed", + surveyId, + responseId, + isDryRun: false, + attempt: 0, + idempotencyKey: responseId, + }); + expect(data.triggerPayload).toMatchObject({ + type: "response.completed", + surveyId, + responseId, + endingCardId: endingId, + triggeredAt: response.updatedAt.toISOString(), + }); + expect(dispatch).toHaveBeenCalledWith({ workflowRunId: "run_1", workflowId: "wf_1", workspaceId }); + // Dispatch landed → recorded as a durable DB fact (ENG-1658), tenant-scoped by workspaceId. + expect(markDispatched).toHaveBeenCalledWith( + "run_1", + workspaceId, + expect.any(Date), + expect.objectContaining({ workflowId: "wf_1" }) + ); + }); + + test("does nothing for an unfinished response", async () => { + await run({ response: { ...response, finished: false }, workspaceId, organizationId, dispatch }); + expect(findMany).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test("a response without an ending card still fires an all-endings workflow", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockResolvedValue({ id: "run_1" }); + + await run({ response: { ...response, endingId: null }, workspaceId, organizationId, dispatch }); + + const data = create.mock.calls[0][0].data; + expect(data).toMatchObject({ workflowId: "wf_1", status: "queued" }); + // No ending was reached, so the payload simply omits endingCardId (it is optional). + expect(data.triggerPayload.endingCardId).toBeUndefined(); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + test("a response without an ending card does not fire an ending-specific workflow", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1", [endingId])]); + + await run({ response: { ...response, endingId: null }, workspaceId, organizationId, dispatch }); + + expect(create).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test("respects the ending-card filter (specific list, miss → no run)", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1", ["cm9zr4q7i000108l84goze999"])]); + await run(); + expect(create).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test("skips (and logs) an enabled workflow with no published version", async () => { + findMany.mockResolvedValue([{ id: "wf_1", versions: [] }]); + await run(); + expect(warn).toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test("fans out to every matching workflow", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1"), enabledWorkflow("wf_2", "ver_2")]); + create.mockResolvedValueOnce({ id: "run_1" }).mockResolvedValueOnce({ id: "run_2" }); + + await run(); + + expect(create).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith({ workflowRunId: "run_1", workflowId: "wf_1", workspaceId }); + expect(dispatch).toHaveBeenCalledWith({ workflowRunId: "run_2", workflowId: "wf_2", workspaceId }); + }); + + test("is idempotent on a unique-constraint violation (replayed pipeline): re-dispatches the existing run", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }) + ); + findUnique.mockResolvedValue({ id: "existing_run" }); + + await run(); + + expect(dispatch).toHaveBeenCalledWith({ workflowRunId: "existing_run", workflowId: "wf_1", workspaceId }); + expect(error).not.toHaveBeenCalled(); + }); + + test("meters a newly created run exactly once, with the run id and its persisted createdAt", async () => { + const runCreatedAt = new Date("2026-06-12T09:30:02.000Z"); + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockResolvedValue({ id: "run_1", createdAt: runCreatedAt }); + + await enqueueResponseCompletedWorkflowRuns({ + response, + workspaceId, + organizationId, + stripeCustomerId: "cus_123", + dispatch, + }); + + // Timestamped from the run's own createdAt (not response.updatedAt) so usage lands in the + // correct billing period. + expect(recordWorkflowRunCreatedMeterEvent).toHaveBeenCalledTimes(1); + expect(recordWorkflowRunCreatedMeterEvent).toHaveBeenCalledWith({ + stripeCustomerId: "cus_123", + workflowRunId: "run_1", + createdAt: runCreatedAt, + }); + }); + + test("does not meter a unique-constraint replay that re-found the existing run", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }) + ); + findUnique.mockResolvedValue({ id: "existing_run", createdAt: new Date("2026-06-11T08:00:00.000Z") }); + + await run(); + + // The replayed run still dispatches, but is never re-metered: Stripe dedups a meter identifier + // for only ~24h, so re-emitting on a later replay would double-bill the same run (ENG-1936). + expect(dispatch).toHaveBeenCalledWith({ workflowRunId: "existing_run", workflowId: "wf_1", workspaceId }); + expect(recordWorkflowRunCreatedMeterEvent).not.toHaveBeenCalled(); + }); + + test("on a unique violation with no findable existing run, surfaces an error and does not dispatch", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }) + ); + findUnique.mockResolvedValue(null); + + await expect(run()).resolves.toBeUndefined(); + + expect(dispatch).not.toHaveBeenCalled(); + // A unique violation with no recoverable run is a contradictory state; surface it, don't hide it. + expect(error).toHaveBeenCalled(); + }); + + test("propagates a database error raised during candidate load (does not swallow it before matching)", async () => { + findMany.mockRejectedValue(new Error("Timed out fetching a new connection from the connection pool")); + + await expect(run()).rejects.toThrow(/connection pool/i); + expect(create).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test("rethrows a transient pool-exhaustion error raised during run creation so the pipeline can retry", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockRejectedValue(new Error("Timed out fetching a new connection from the connection pool")); + + await expect(run()).rejects.toThrow(/connection pool/i); + expect(dispatch).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test("isolates a non-unique create failure (logs, does not throw, continues fan-out)", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1"), enabledWorkflow("wf_2", "ver_2")]); + create.mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce({ id: "run_2" }); + + await expect(run()).resolves.toBeUndefined(); + + expect(error).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ workflowRunId: "run_2", workflowId: "wf_2", workspaceId }); + }); + + test("creates nothing when no workflow matches the survey", async () => { + findMany.mockResolvedValue([ + { + id: "wf_other", + versions: [ + { id: "v", definition: definition({ surveyId: "cm9zr4q7i000108l84gozzzzz", endingCardIds: [] }) }, + ], + }, + ]); + await run(); + expect(create).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test("skips (and logs) a published version with a malformed trigger, still processing the others", async () => { + findMany.mockResolvedValue([ + // No trigger at all — a malformed/corrupt published snapshot. + { id: "wf_bad", versions: [{ id: "ver_bad", definition: { schemaVersion: 1, nodes: [], edges: [] } }] }, + enabledWorkflow("wf_ok", "ver_ok"), + ]); + create.mockResolvedValue({ id: "run_ok" }); + + await run(); + + expect(warn).toHaveBeenCalled(); + expect(create).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ workflowRunId: "run_ok", workflowId: "wf_ok", workspaceId }); + }); + + test("logs an orphan and does not throw when dispatch fails after the run is persisted", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + create.mockResolvedValue({ id: "run_1" }); + dispatch.mockRejectedValue(new Error("redis unavailable")); + + await expect(run()).resolves.toBeUndefined(); + + // Attempted once (not retried here), the persisted run is surfaced as an orphan for the reconciler. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalled(); + // Dispatch failed → the run is left unmarked (dispatchedAt stays null) for the reconciler to recover. + expect(markDispatched).not.toHaveBeenCalled(); + }); + + test("skips (and logs) enqueueing when the organization lacks the workflows entitlement", async () => { + findMany.mockResolvedValue([enabledWorkflow("wf_1", "ver_1")]); + getIsWorkflowsEnabled.mockResolvedValue(false); + + await run(); + + expect(getIsWorkflowsEnabled).toHaveBeenCalledWith(organizationId); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId, organizationId, responseId, matchedWorkflowIds: ["wf_1"] }), + expect.stringContaining("entitlement") + ); + expect(create).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test("does not pay the entitlement lookup when no workflow matches the response", async () => { + findMany.mockResolvedValue([]); + + await run(); + + expect(getIsWorkflowsEnabled).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.ts b/apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.ts new file mode 100644 index 000000000000..a4e17a6f8dc8 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/enqueue-response-completed-runs.ts @@ -0,0 +1,322 @@ +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { logger } from "@formbricks/logger"; +import { type TWorkflowTriggerRunPayload, ZWorkflowTriggerRunPayload } from "@formbricks/workflows"; +import { isDatabasePoolExhaustionError } from "@/lib/jobs/pool-exhaustion"; +import { recordWorkflowRunCreatedMeterEvent } from "@/modules/ee/billing/lib/metering"; +import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { type DispatchWorkflowRun } from "./dispatch"; +import { markWorkflowRunDispatched } from "./mark-dispatched"; +import { + type WorkflowMatch, + type WorkflowMatchCandidate, + matchWorkflowsForResponse, + readResponseCompletedTriggerConfig, +} from "./match-workflows"; + +/** The subset of a completed response the runner needs. Structurally satisfied by the response-pipeline payload. */ +interface RunnerResponse { + id: string; + surveyId: string; + finished: boolean; + endingId?: string | null; + updatedAt: Date; + data: Record; +} + +interface EnqueueResponseCompletedWorkflowRunsInput { + response: RunnerResponse; + workspaceId: string; + organizationId: string; + // Passed through for workflow-run metering (ENG-1936). Null/absent off Formbricks Cloud or when the + // org has no Stripe customer — the meter helper no-ops in those cases. + stripeCustomerId?: string | null; + dispatch: DispatchWorkflowRun; + logContext?: Record; +} + +const isUniqueConstraintError = (error: unknown): boolean => + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === PrismaErrorType.UniqueConstraintViolation; + +/** + * Load the enabled workflows for a workspace as match candidates, each carrying its current published + * version (highest `version`). Skips (and logs) two unrunnable cases so they never reach matching: + * an enabled workflow with no published version (enable always publishes one — a data-integrity guard), + * and a published version whose definition has no usable `response.completed` trigger (a malformed or + * corrupt snapshot — the definition is unvalidated DB JSON). + */ +const loadEnabledWorkflowCandidates = async ( + workspaceId: string, + logContext?: Record +): Promise => { + const workflows = await prisma.workflow.findMany({ + where: { workspaceId, status: "enabled" }, + select: { + id: true, + versions: { orderBy: { version: "desc" }, take: 1, select: { id: true, definition: true } }, + }, + }); + + const candidates: WorkflowMatchCandidate[] = []; + for (const workflow of workflows) { + const publishedVersion = workflow.versions[0]; + if (!publishedVersion) { + logger.warn( + { ...logContext, workflowId: workflow.id, workspaceId }, + "Enabled workflow has no published version; skipping workflow run enqueue" + ); + continue; + } + if (!readResponseCompletedTriggerConfig(publishedVersion.definition)) { + logger.warn( + { ...logContext, workflowId: workflow.id, workspaceId, workflowVersionId: publishedVersion.id }, + "Published workflow version has no usable response.completed trigger; skipping workflow run enqueue" + ); + continue; + } + candidates.push({ + workflowId: workflow.id, + publishedVersionId: publishedVersion.id, + definition: publishedVersion.definition, + }); + } + + return candidates; +}; + +/** + * Persist one `queued` `WorkflowRun` for a matched workflow (bound to its published version) and return + * its id. Idempotent: on the `@@unique([workflowId, idempotencyKey])` violation from a replayed pipeline + * pass, returns the run already created on an earlier pass; a unique violation with no findable run is a + * contradictory state and is surfaced. Transient DB pool exhaustion is rethrown so the pipeline retries + * the whole (idempotent) enqueue; any other create failure is isolated per workflow (logged → null). + */ +const persistQueuedRun = async ({ + match, + response, + workspaceId, + triggerPayload, + logContext, +}: { + match: WorkflowMatch; + response: RunnerResponse; + workspaceId: string; + triggerPayload: TWorkflowTriggerRunPayload; + logContext?: Record; +}): Promise<{ id: string; createdAt: Date; isNew: boolean } | null> => { + try { + const run = await prisma.workflowRun.create({ + data: { + workflowId: match.workflowId, + workspaceId, + workflowVersionId: match.publishedVersionId, + status: "queued", + triggerType: "response.completed", + surveyId: response.surveyId, + responseId: response.id, + isDryRun: false, + attempt: 0, + idempotencyKey: response.id, + triggerPayload, + }, + select: { id: true, createdAt: true }, + }); + return { id: run.id, createdAt: run.createdAt, isNew: true }; + } catch (error) { + if (isUniqueConstraintError(error)) { + const existing = await prisma.workflowRun.findUnique({ + where: { workflowId_idempotencyKey: { workflowId: match.workflowId, idempotencyKey: response.id } }, + select: { id: true, createdAt: true }, + }); + // isNew: false — a replayed enqueue re-found an already-persisted run. It must NOT be metered + // again: Stripe only dedups a meter identifier for ~24h, so a retry beyond that window would + // double-bill the same run (ENG-1936). + if (existing) return { id: existing.id, createdAt: existing.createdAt, isNew: false }; + // The unique violation says a run exists, but we can't find it by its idempotency key — a + // contradictory state with no run id to dispatch. Surface it rather than silently drop it. + logger.error( + { ...logContext, workflowId: match.workflowId, workspaceId, responseId: response.id }, + "Workflow run unique-constraint violation but no existing run found to re-dispatch" + ); + return null; + } + if (isDatabasePoolExhaustionError(error)) { + // Transient DB pool exhaustion: propagate so the pipeline retries the whole (idempotent) enqueue. + throw error; + } + logger.error( + { ...logContext, workflowId: match.workflowId, workspaceId, responseId: response.id, err: error }, + "Failed to persist workflow run" + ); + return null; + } +}; + +/** + * Persist a `queued` `WorkflowRun` for a matched workflow and hand it to the dispatcher. + * + * If `dispatch` fails after the row is persisted, the run is left `queued` with no backing job — an + * orphan. We log it distinctly (so it is alertable) and swallow rather than rethrow: rethrowing would + * retry the whole response-pipeline job and re-run its other side-effects (webhooks, follow-ups, + * notifications). The durable run row plus the `status` / `nextAttemptAt` indexes exist precisely so a + * reconciler can re-dispatch orphaned runs; that reconciler is tracked separately (out of scope here). + */ +const createAndDispatchWorkflowRun = async ({ + match, + response, + workspaceId, + stripeCustomerId, + triggerPayload, + dispatch, + meterEvents, + logContext, +}: { + match: WorkflowMatch; + response: RunnerResponse; + workspaceId: string; + stripeCustomerId?: string | null; + triggerPayload: TWorkflowTriggerRunPayload; + dispatch: DispatchWorkflowRun; + meterEvents: Promise[]; + logContext?: Record; +}): Promise => { + const persisted = await persistQueuedRun({ match, response, workspaceId, triggerPayload, logContext }); + if (!persisted) { + return; + } + const { id: workflowRunId, createdAt, isNew } = persisted; + + // Meter the run once, only when it is newly created (isNew) — a persisted run is billable + // regardless of whether dispatch later succeeds, matching how responses meter at creation + // (ENG-1936). A replayed enqueue that re-found an existing run is skipped so it can't double-bill + // past Stripe's ~24h identifier-dedup window. These runs are always real (isDryRun: false); dry + // runs are metered nowhere. Timestamped from the run's own createdAt so usage lands in the correct + // billing period (response.updatedAt could be stale/older than Stripe's 35-day accept window). + // + // Cloud-only; the helper swallows its own errors, so metering never blocks or fails run creation. + // The Stripe call starts here but is awaited by the caller only after the dispatch loop: it never + // adds a round-trip to (or, on a Stripe hang, stalls) the serialized per-match dispatch below, yet + // it is not dropped mid-flight if the worker exits right after the job completes — isNew is + // consumed on creation, so a dropped event would leave the run permanently unbilled. + if (isNew) { + meterEvents.push( + recordWorkflowRunCreatedMeterEvent({ + stripeCustomerId, + workflowRunId, + createdAt, + }) + ); + } + + try { + await dispatch({ workflowRunId, workflowId: match.workflowId, workspaceId }); + } catch (error) { + logger.error( + { + ...logContext, + workflowId: match.workflowId, + workspaceId, + responseId: response.id, + workflowRunId, + err: error, + }, + "Workflow run persisted but dispatch failed; queued run is orphaned until the reconciler re-dispatches it" + ); + return; + } + + // Dispatch landed — record it as a durable DB fact (ENG-1658) so recovery can tell a genuine + // never-dispatched orphan (dispatchedAt IS NULL) from a dispatched-but-lagging run. Best-effort: + // a missed stamp self-heals on the next reconcile tick. + await markWorkflowRunDispatched(workflowRunId, workspaceId, new Date(), { + ...logContext, + workflowId: match.workflowId, + responseId: response.id, + }); +}; + +/** + * Producer half of the workflow runner. On a completed response, find the enabled workflows whose + * current published version targets this survey/ending, persist one `queued` `WorkflowRun` per match + * (bound to that published version), and hand each to the injected dispatcher. Responses without an + * ending card (surveys can have none) still count as completed: an "all endings" trigger fires for + * them; only an explicit ending selection requires the response to have reached one of those cards. + * + * Idempotent: `idempotencyKey = responseId` + `@@unique([workflowId, idempotencyKey])` plus a + * deterministic dispatch `jobId` mean a replayed `responseFinished` creates no duplicate runs or jobs. + * Each match is isolated so one workflow's failure never blocks the others. The caller wraps the whole + * call so a runner failure never affects the response pipeline. + * + * Entitlement gate: workflows is an EE feature (Cloud plan entitlement / self-hosted EE license). + * The check runs only after a workflow actually matched — the common no-workflow response pays + * nothing — and reads the Redis-cached entitlements context, so a non-entitled organization's + * enabled workflows stop producing runs without an uncached per-response query. + */ +export const enqueueResponseCompletedWorkflowRuns = async ({ + response, + workspaceId, + organizationId, + stripeCustomerId, + dispatch, + logContext, +}: EnqueueResponseCompletedWorkflowRunsInput): Promise => { + // Only completed responses enqueue runs; the ending card is optional (null on surveys without one). + if (!response.finished) { + return; + } + const endingId = response.endingId ?? null; + + const candidates = await loadEnabledWorkflowCandidates(workspaceId, logContext); + const matches = matchWorkflowsForResponse(candidates, { surveyId: response.surveyId, endingId }); + if (matches.length === 0) { + return; + } + + const isWorkflowsEnabled = await getIsWorkflowsEnabled(organizationId); + if (!isWorkflowsEnabled) { + logger.info( + { + ...logContext, + workspaceId, + organizationId, + responseId: response.id, + matchedWorkflowIds: matches.map((match) => match.workflowId), + }, + "Workflows entitlement missing for organization; skipping workflow run enqueue" + ); + return; + } + + // One trigger payload for every matched workflow. `triggeredAt` is derived from `response.updatedAt` + // (not wall-clock) so a pipeline retry rebuilds a byte-identical payload. + const triggerPayload = ZWorkflowTriggerRunPayload.parse({ + type: "response.completed", + workspaceId, + surveyId: response.surveyId, + responseId: response.id, + ...(endingId ? { endingCardId: endingId } : {}), + data: response.data, + triggeredAt: response.updatedAt.toISOString(), + }); + + const meterEvents: Promise[] = []; + for (const match of matches) { + await createAndDispatchWorkflowRun({ + match, + response, + workspaceId, + stripeCustomerId, + triggerPayload, + dispatch, + meterEvents, + logContext, + }); + } + + // Meter events ran concurrently with the dispatch loop; settle them before returning so the job + // holds the worker alive until every billed run is actually reported (parity with the awaited + // response meter event). The helper owns its errors, so this only waits — it can never throw. + await Promise.all(meterEvents); +}; diff --git a/apps/web/modules/ee/workflows/lib/runner/mark-dispatched.test.ts b/apps/web/modules/ee/workflows/lib/runner/mark-dispatched.test.ts new file mode 100644 index 000000000000..0f06e6819b14 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/mark-dispatched.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { markWorkflowRunDispatched } from "./mark-dispatched"; + +const { updateMany } = vi.hoisted(() => ({ updateMany: vi.fn() })); +const { error } = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("@formbricks/database", () => ({ prisma: { workflowRun: { updateMany } } })); +vi.mock("@formbricks/logger", () => ({ logger: { error } })); + +const AT = new Date("2026-07-01T12:00:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + updateMany.mockResolvedValue({ count: 1 }); +}); + +describe("markWorkflowRunDispatched", () => { + test("stamps dispatchedAt on the run, tenant-scoped by workspaceId", async () => { + await markWorkflowRunDispatched("run_1", "ws_1", AT); + + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "run_1", workspaceId: "ws_1" }, + data: { dispatchedAt: AT }, + }); + expect(error).not.toHaveBeenCalled(); + }); + + test("swallows a write failure and logs it (best-effort; never throws)", async () => { + updateMany.mockRejectedValueOnce(new Error("db down")); + + await expect(markWorkflowRunDispatched("run_1", "ws_1", AT, { jobId: "j1" })).resolves.toBeUndefined(); + + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith( + expect.objectContaining({ workflowRunId: "run_1", workspaceId: "ws_1", jobId: "j1" }), + expect.any(String) + ); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/mark-dispatched.ts b/apps/web/modules/ee/workflows/lib/runner/mark-dispatched.ts new file mode 100644 index 000000000000..778a17dde376 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/mark-dispatched.ts @@ -0,0 +1,36 @@ +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; + +/** + * Records that a `WorkflowRun` was handed off to the task backend, as a durable DB fact (ENG-1658). + * Stamped by the producer and the reconciler right after a successful dispatch — never inside the + * backend-specific dispatch port — so recovery can distinguish a genuine never-dispatched producer + * orphan (`dispatchedAt IS NULL`) from a dispatched-but-still-queued run, without inferring it from the + * queue's jobId-dedup semantics. + * + * Best-effort by design: the dispatch has already succeeded, so a failed marker write must not surface + * as a dispatch failure or abort a fan-out / sweep. A missed stamp self-heals on the next reconcile + * tick, which re-stamps after its own idempotent re-dispatch. Errors are logged, never thrown. + * + * Tenant-scoped (`updateMany where { id, workspaceId }`), mirroring the executor's terminal writes: the + * `id` PK already pins the row, so this is defense-in-depth, and `updateMany` means a benign no-match + * (row cascade-deleted between dispatch and stamp) is a silent 0-row result rather than a thrown P2025. + */ +export const markWorkflowRunDispatched = async ( + workflowRunId: string, + workspaceId: string, + dispatchedAt: Date, + logContext?: Record +): Promise => { + try { + await prisma.workflowRun.updateMany({ + where: { id: workflowRunId, workspaceId }, + data: { dispatchedAt }, + }); + } catch (error) { + logger.error( + { ...logContext, workflowRunId, workspaceId, err: error }, + "Workflow run dispatched but recording dispatchedAt failed; the reconciler will heal the marker" + ); + } +}; diff --git a/apps/web/modules/ee/workflows/lib/runner/match-workflows.test.ts b/apps/web/modules/ee/workflows/lib/runner/match-workflows.test.ts new file mode 100644 index 000000000000..c7aa20dd4d13 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/match-workflows.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import type { TWorkflowExecutableDefinition } from "@formbricks/workflows"; +import { type WorkflowMatchCandidate, matchWorkflowsForResponse } from "./match-workflows"; + +const SURVEY = "cm9zr4q7i000108l84gozfggr"; +const OTHER_SURVEY = "cm9zr4q7i000108l84gozzzzz"; +const ENDING_A = "cm9zr4q7i000108l84goze001"; +const ENDING_B = "cm9zr4q7i000108l84goze002"; + +const makeCandidate = ( + workflowId: string, + config: { surveyId: string; endingCardIds: string[] } +): WorkflowMatchCandidate => ({ + workflowId, + publishedVersionId: `${workflowId}-v`, + definition: { + schemaVersion: 1, + trigger: { id: "trigger", type: "trigger", triggerType: "response.completed", config }, + nodes: [], + edges: [], + entryNodeId: "trigger", + } as TWorkflowExecutableDefinition, +}); + +describe("matchWorkflowsForResponse", () => { + test("empty endingCardIds matches any ending of the same survey", () => { + const candidate = makeCandidate("wf_any", { surveyId: SURVEY, endingCardIds: [] }); + const result = matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_A }); + expect(result).toEqual([{ workflowId: "wf_any", publishedVersionId: "wf_any-v" }]); + }); + + test("specific endingCardIds match only when the reached ending is listed", () => { + const candidate = makeCandidate("wf_specific", { surveyId: SURVEY, endingCardIds: [ENDING_A] }); + expect(matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_A })).toHaveLength(1); + expect(matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_B })).toHaveLength(0); + }); + + test("a null endingId (survey without ending cards) matches only all-endings triggers", () => { + const anyEnding = makeCandidate("wf_any", { surveyId: SURVEY, endingCardIds: [] }); + const specific = makeCandidate("wf_specific", { surveyId: SURVEY, endingCardIds: [ENDING_A] }); + + expect(matchWorkflowsForResponse([anyEnding], { surveyId: SURVEY, endingId: null })).toHaveLength(1); + expect(matchWorkflowsForResponse([specific], { surveyId: SURVEY, endingId: null })).toHaveLength(0); + }); + + test("does not match a different survey", () => { + const candidate = makeCandidate("wf", { surveyId: OTHER_SURVEY, endingCardIds: [] }); + expect(matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_A })).toHaveLength(0); + }); + + test("does not match a non-response.completed trigger (future trigger types)", () => { + const candidate = makeCandidate("wf", { surveyId: SURVEY, endingCardIds: [] }); + // Force a different triggerType to exercise the guard (only response.completed exists today). + (candidate.definition.trigger as { triggerType: string }).triggerType = "other.trigger"; + expect(matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_A })).toHaveLength(0); + }); + + test("fans out to every matching workflow and skips the non-matching ones", () => { + const candidates = [ + makeCandidate("wf_any", { surveyId: SURVEY, endingCardIds: [] }), + makeCandidate("wf_hit", { surveyId: SURVEY, endingCardIds: [ENDING_A] }), + makeCandidate("wf_miss", { surveyId: SURVEY, endingCardIds: [ENDING_B] }), + makeCandidate("wf_other_survey", { surveyId: OTHER_SURVEY, endingCardIds: [] }), + ]; + const result = matchWorkflowsForResponse(candidates, { surveyId: SURVEY, endingId: ENDING_A }); + expect(result.map((m) => m.workflowId)).toEqual(["wf_any", "wf_hit"]); + }); + + test("returns nothing for no candidates", () => { + expect(matchWorkflowsForResponse([], { surveyId: SURVEY, endingId: ENDING_A })).toEqual([]); + }); + + test("does not match (and does not throw) a malformed definition with no trigger", () => { + const candidate = { + workflowId: "wf_bad", + publishedVersionId: "v", + definition: { schemaVersion: 1, nodes: [], edges: [] } as unknown as TWorkflowExecutableDefinition, + }; + expect(matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_A })).toEqual([]); + }); + + test.each([null, undefined])("does not match (and does not throw) a JSON-%s definition", (definition) => { + const candidate = { + workflowId: "wf_bad", + publishedVersionId: "v", + definition: definition as unknown as TWorkflowExecutableDefinition, + }; + expect(matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_A })).toEqual([]); + }); + + test("does not match (and does not throw) a trigger missing its config", () => { + const candidate = { + workflowId: "wf_bad", + publishedVersionId: "v", + definition: { + schemaVersion: 1, + trigger: { id: "t", type: "trigger", triggerType: "response.completed" }, + nodes: [], + edges: [], + entryNodeId: "t", + } as unknown as TWorkflowExecutableDefinition, + }; + expect(matchWorkflowsForResponse([candidate], { surveyId: SURVEY, endingId: ENDING_A })).toEqual([]); + }); + + test("a malformed candidate never blocks matching of a valid one", () => { + const malformed = { + workflowId: "wf_bad", + publishedVersionId: "vb", + definition: {} as unknown as TWorkflowExecutableDefinition, + }; + const valid = makeCandidate("wf_ok", { surveyId: SURVEY, endingCardIds: [] }); + const result = matchWorkflowsForResponse([malformed, valid], { surveyId: SURVEY, endingId: ENDING_A }); + expect(result.map((m) => m.workflowId)).toEqual(["wf_ok"]); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/match-workflows.ts b/apps/web/modules/ee/workflows/lib/runner/match-workflows.ts new file mode 100644 index 000000000000..14d7319235c7 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/match-workflows.ts @@ -0,0 +1,70 @@ +import { WORKFLOW_TRIGGERS } from "@formbricks/workflows"; +import type { TWorkflowExecutableDefinition } from "@formbricks/workflows"; + +/** An enabled workflow with its current published version's executable definition. */ +export interface WorkflowMatchCandidate { + workflowId: string; + publishedVersionId: string; + definition: TWorkflowExecutableDefinition; +} + +export interface WorkflowMatch { + workflowId: string; + publishedVersionId: string; +} + +export interface ResponseMatchInput { + surveyId: string; + /** Null when the survey has no ending cards; only "all endings" triggers match then. */ + endingId: string | null; +} + +/** Loose view of a trigger node: the published definition is persisted DB JSON, branded as + * `TWorkflowExecutableDefinition` but never parsed on read, so it must be treated as untrusted. */ +interface LooseTriggerNode { + triggerType?: string; + config?: { surveyId?: unknown; endingCardIds?: unknown }; +} + +/** + * Reads the `response.completed` trigger config from a published definition. Because the definition is + * unvalidated DB JSON, this is **total**: it returns `null` (never throws) when the snapshot is + * malformed or isn't a `response.completed` trigger, so a single bad version skips its own workflow + * instead of throwing and aborting matching for every workflow on the response. + */ +export const readResponseCompletedTriggerConfig = ( + definition: TWorkflowExecutableDefinition +): { surveyId: string; endingCardIds: string[] } | null => { + const trigger = (definition as { trigger?: LooseTriggerNode } | null | undefined)?.trigger; + if (trigger?.triggerType !== WORKFLOW_TRIGGERS.RESPONSE_COMPLETED) return null; + + const surveyId = trigger.config?.surveyId; + const endingCardIds = trigger.config?.endingCardIds; + if (typeof surveyId !== "string" || !Array.isArray(endingCardIds)) return null; + if (!endingCardIds.every((id): id is string => typeof id === "string")) return null; + + return { surveyId, endingCardIds }; +}; + +/** + * Pure matcher: from the candidate enabled workflows (each carrying its current published version's + * definition), return those whose `response.completed` trigger fires for this completed response — + * the trigger targets the response's survey and its ending filter passes (empty `endingCardIds` means + * "any completion" — including surveys without ending cards — otherwise the reached `endingId` must be + * listed). No I/O, so it's exhaustively + * table-testable. Reads the published snapshot (never the mutable draft) and is total over malformed + * definitions (a bad one simply doesn't match). + */ +export const matchWorkflowsForResponse = ( + candidates: WorkflowMatchCandidate[], + { surveyId, endingId }: ResponseMatchInput +): WorkflowMatch[] => + candidates + .filter(({ definition }) => { + const config = readResponseCompletedTriggerConfig(definition); + if (!config) return false; + if (config.surveyId !== surveyId) return false; + if (config.endingCardIds.length === 0) return true; + return endingId !== null && config.endingCardIds.includes(endingId); + }) + .map(({ workflowId, publishedVersionId }) => ({ workflowId, publishedVersionId })); diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts new file mode 100644 index 000000000000..41db0e6c6144 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts @@ -0,0 +1,404 @@ +/** + * ENG-1228 — workflow `send_email` Follow-Ups RENDER-PARITY lock-in (integration). + * + * Sibling to `process-workflow-run-job.test.ts`, which mocks the HTML builder (`buildSurveyResponseEmailHtml`) + * and asserts on the *arguments* handed to a mocked `sendEmail`. That unit test proves the wiring but, by + * design, fakes the exact thing that must not regress: the branded HTML, recall expansion, sanitization, + * response-data table, HTML-only shape, and the MAIL_FROM sender default. + * + * This test therefore drives the REAL `processWorkflowRunJob` end-to-end with the render/transport stack + * left INTACT: + * - REAL `buildSurveyResponseEmailHtml` → `parseRecallInfo` + `sanitizeHtml` + `getElementResponseMapping` + * + `renderFollowUpEmail` (react-email) + real i18n (`getTranslate`). + * - REAL `resolveResponseRecipient` (Follow-Ups recipient resolution). + * - REAL `sendEmail` from `@/modules/email` — so the deployment `MAIL_FROM` default is applied by the + * production code path, not asserted against a stub. + * + * The ONLY mocked boundaries are: + * - the outermost transport: `nodemailer.createTransport().sendMail` — captured so we can assert on the + * fully-built message (from/subject/html) that would go on the wire. + * - the durable/data edges the runner reads: `prisma`, `getResponse`, `getSurvey`, + * `getOrganizationByWorkspaceId`, and `logger`. These are infra, not render logic. + * + * MAIL_FROM comes from the shared vitestSetup `@/lib/constants` mock: + * MAIL_FROM = "mock@mail.com", MAIL_FROM_NAME = "Mock Mail" → From: `Mock Mail `. + * + * No external services: prisma and nodemailer are mocked, so this runs in CI with no DB and no SMTP server. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { TWorkflowRunJobData } from "@formbricks/jobs"; +import { processWorkflowRunJob } from "./process-workflow-run-job"; + +// --------------------------------------------------------------------------- +// Mocks — ONLY the transport boundary and the durable/data edges. The whole +// render pipeline (buildSurveyResponseEmailHtml, renderFollowUpEmail, +// parseRecallInfo, sanitizeHtml, resolveResponseRecipient, getTranslate) is REAL. +// --------------------------------------------------------------------------- +const { + mockSendMail, + mockCreateTransport, + mockLoggerError, + mockLoggerInfo, + mockLoggerWarn, + mockWorkflowRunFindFirst, + mockWorkflowRunUpdateMany, + mockWorkflowRunLogCreate, + mockWorkflowRunLogUpdate, + mockWorkflowRunLogUpdateMany, + mockWorkflowRunLogFindFirst, + mockGetResponse, + mockGetSurvey, + mockGetOrganizationByWorkspaceId, + mockGetOrganizationMemberEmails, +} = vi.hoisted(() => { + const sendMail = vi.fn(); + return { + mockSendMail: sendMail, + mockCreateTransport: vi.fn(() => ({ sendMail })), + mockLoggerError: vi.fn(), + mockLoggerInfo: vi.fn(), + mockLoggerWarn: vi.fn(), + mockWorkflowRunFindFirst: vi.fn(), + mockWorkflowRunUpdateMany: vi.fn(), + mockWorkflowRunLogCreate: vi.fn(), + mockWorkflowRunLogUpdate: vi.fn(), + mockWorkflowRunLogUpdateMany: vi.fn(), + mockWorkflowRunLogFindFirst: vi.fn(), + mockGetResponse: vi.fn(), + mockGetSurvey: vi.fn(), + mockGetOrganizationByWorkspaceId: vi.fn(), + mockGetOrganizationMemberEmails: vi.fn(), + }; +}); + +// The BA harness setup (integration/setup.ts) mocks @/modules/email to capture transactional emails; +// this test needs the REAL sendEmail (that's the render-parity point), so cancel that registration. +vi.unmock("@/modules/email"); + +// The integration config doesn't load the unit-suite vitestSetup, so pin the sender constants this +// file's assertions were written against (everything else stays the real .env-backed constants). +vi.mock("@/lib/constants", async (importOriginal) => ({ + ...(await importOriginal()), + MAIL_FROM: "mock@mail.com", + MAIL_FROM_NAME: "Mock Mail", +})); + +// Outermost transport: mock nodemailer so the REAL sendEmail() runs and applies MAIL_FROM, but no SMTP +// connection is made. sendMail captures the fully-built message we assert on. +vi.mock("nodemailer", () => ({ + createTransport: mockCreateTransport, + default: { createTransport: mockCreateTransport }, +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + workflowRun: { + findFirst: mockWorkflowRunFindFirst, + updateMany: mockWorkflowRunUpdateMany, + }, + workflowRunLog: { + create: mockWorkflowRunLogCreate, + update: mockWorkflowRunLogUpdate, + updateMany: mockWorkflowRunLogUpdateMany, + findFirst: mockWorkflowRunLogFindFirst, + }, + }, +})); + +vi.mock("@/lib/response/service", () => ({ getResponse: mockGetResponse })); +vi.mock("@/lib/survey/service", () => ({ getSurvey: mockGetSurvey })); +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByWorkspaceId: mockGetOrganizationByWorkspaceId, + getOrganizationMemberEmails: mockGetOrganizationMemberEmails, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + debug: vi.fn(), + error: mockLoggerError, + info: mockLoggerInfo, + warn: mockLoggerWarn, + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures — a real survey block/element + a completing response with answers, +// so getElementResponseMapping produces a genuine response-data row and recall +// resolves against real response data. `to` is a question id resolved to an email. +// --------------------------------------------------------------------------- +const WORKSPACE_ID = "cm9zr4wsp000508l8y6nh9r2v"; +const SURVEY_ID = "cm9zr4mps000008l8btfy1vtz"; +const RESPONSE_ID = "cm9zr4rsp000708l8bqccpfrx"; +const RUN_ID = "cm9zr4run000908l8q9b9d3pm"; +const WORKFLOW_ID = "cm9zr4wfl000008l8q9b9d3pm"; + +const EMAIL_ELEMENT_ID = "email-question"; +const NAME_ELEMENT_ID = "name-question"; + +const RECIPIENT_EMAIL = "respondent@example.com"; +const RESPONDENT_NAME = "Ada Lovelace"; +const NAME_QUESTION_HEADLINE = "What is your name?"; + +// Deliberately BOGUS — proves config.from is NOT used as the sender (Follow-Ups parity: MAIL_FROM applies). +const BOGUS_FROM = "bogus-should-be-ignored@evil.example"; + +const triggerPayload = { + type: "response.completed" as const, + workspaceId: WORKSPACE_ID, + surveyId: SURVEY_ID, + responseId: RESPONSE_ID, + endingCardId: "cm9zr4q7i000108l84gozfggr", + data: { response: { [EMAIL_ELEMENT_ID]: RECIPIENT_EMAIL, [NAME_ELEMENT_ID]: RESPONDENT_NAME } }, + triggeredAt: "2026-06-09T12:01:00.000Z", +}; + +// trigger(response.completed, surveyId, endingCardIds=[]) -> send_email +// to = a question id resolving to an email +// body = recall body whose elementId resolves against the response (fallback "there" must NOT show) +// from = BOGUS (must be ignored) +// attachResponseData: true +const makeDefinition = (overrides: Record = {}) => ({ + schemaVersion: 1, + entryNodeId: "trigger", + trigger: { + id: "trigger", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: SURVEY_ID, endingCardIds: [] }, + }, + nodes: [ + { + id: "send-email", + type: "action", + actionType: "send_email", + label: "Send thank you email", + config: { + to: EMAIL_ELEMENT_ID, + from: BOGUS_FROM, + replyTo: [], + subject: "Thanks for your response", + body: `

Hi #recall:${NAME_ELEMENT_ID}/fallback:there#, thanks!

`, + attachResponseData: true, + includeVariables: false, + includeHiddenFields: false, + ...overrides, + }, + }, + ], + edges: [{ id: "trigger-send-email", source: "trigger", target: "send-email" }], +}); + +const makeRun = (definition = makeDefinition(), overrides: Record = {}) => ({ + id: RUN_ID, + status: "queued", + attempt: 0, + triggerPayload, + workflowVersion: { definition }, + workflow: { definition }, + ...overrides, +}); + +// Real survey shape consumed by getElementResponseMapping (blocks -> elements) with localized headlines. +const survey = { + id: SURVEY_ID, + workspaceId: WORKSPACE_ID, + languages: [], + blocks: [ + { + id: "block-1", + elements: [ + { + id: EMAIL_ELEMENT_ID, + type: "openText", + headline: { default: "What is your email?" }, + required: true, + inputType: "email", + longAnswer: false, + charLimit: { enabled: false }, + }, + { + id: NAME_ELEMENT_ID, + type: "openText", + headline: { default: NAME_QUESTION_HEADLINE }, + required: true, + inputType: "text", + longAnswer: false, + charLimit: { enabled: false }, + }, + ], + }, + ], + variables: [], + hiddenFields: { enabled: false, fieldIds: [] }, +}; + +const response = { + id: RESPONSE_ID, + surveyId: SURVEY_ID, + data: { [EMAIL_ELEMENT_ID]: RECIPIENT_EMAIL, [NAME_ELEMENT_ID]: RESPONDENT_NAME }, + variables: {}, + language: "en-US", + finished: true, +}; + +const jobData: TWorkflowRunJobData = { + workflowRunId: RUN_ID, + workflowId: WORKFLOW_ID, + workspaceId: WORKSPACE_ID, +}; + +const baseContext = { + attempt: 1, + jobId: "job_123", + jobName: "workflow-run.process", + maxAttempts: 3, + queueName: "background-jobs", +}; +const finalAttemptContext = { ...baseContext, attempt: baseContext.maxAttempts }; + +/** The message object the REAL sendEmail built and handed to nodemailer's sendMail. */ +const capturedMessage = (): { + from?: string; + to?: string; + subject?: string; + html?: string; + text?: string; + replyTo?: string; + messageId?: string; +} => { + expect(mockSendMail).toHaveBeenCalledTimes(1); + return mockSendMail.mock.calls[0][0]; +}; + +describe("processWorkflowRunJob — send_email Follow-Ups render parity (integration)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWorkflowRunFindFirst.mockResolvedValue(makeRun()); + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 1 }); + mockWorkflowRunLogCreate.mockResolvedValue(undefined); + mockWorkflowRunLogUpdate.mockResolvedValue(undefined); + mockWorkflowRunLogUpdateMany.mockResolvedValue({ count: 1 }); + mockWorkflowRunLogFindFirst.mockResolvedValue(null); + mockGetResponse.mockResolvedValue(response); + mockGetSurvey.mockResolvedValue(survey); + mockGetOrganizationByWorkspaceId.mockResolvedValue({ id: "org1", whitelabel: { logoUrl: "" } }); + // Recipient is a respondent field (not a literal address), so the allowlist is not consulted; + // an empty set is enough to satisfy loadRunEmailContext's lookup. + mockGetOrganizationMemberEmails.mockResolvedValue(new Set()); + // The real sendEmail awaits sendMail; a resolved value means "sent". + mockSendMail.mockResolvedValue({ messageId: "smtp-accepted" }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("happy path — the actually-built message", () => { + test("assertion 1: From is the deployment MAIL_FROM, NOT the config `from` (bogus address absent)", async () => { + await expect(processWorkflowRunJob(jobData, baseContext)).resolves.toBeUndefined(); + + const msg = capturedMessage(); + // MAIL_FROM / MAIL_FROM_NAME come from the shared vitestSetup constants mock. + expect(msg.from).toBe("Mock Mail "); + // The bogus config.from must never appear as sender. + expect(msg.from).not.toContain("bogus-should-be-ignored"); + expect(msg.from).not.toContain(BOGUS_FROM); + }); + + test("assertion 2: recall is resolved against the response (value present, no literal #recall: remains)", async () => { + await processWorkflowRunJob(jobData, baseContext); + + const html = capturedMessage().html ?? ""; + expect(html).toContain(RESPONDENT_NAME); // "Ada Lovelace" — the resolved recall value + expect(html).not.toContain("#recall:"); // no unresolved recall tag survived + expect(html).not.toMatch(/Hi\s+there,\s+thanks!/); // the fallback "there" must NOT have been used + }); + + test("assertion 3: full branded template from renderFollowUpEmail (not a bare

)", async () => { + await processWorkflowRunJob(jobData, baseContext); + + const html = capturedMessage().html ?? ""; + // Full HTML document (react-email root) with table layout + Formbricks branded footer. + expect(html).toMatch(/Hi ${RESPONDENT_NAME}, thanks!

`); + expect(html.length).toBeGreaterThan(500); + }); + + test("assertion 4: response-data table is present (question headline + answer rows)", async () => { + await processWorkflowRunJob(jobData, baseContext); + + const html = capturedMessage().html ?? ""; + expect(html).toContain("Response data"); // section heading (emails.response_data) + expect(html).toContain(NAME_QUESTION_HEADLINE); // the question headline row + expect(html).toContain(RESPONDENT_NAME); // the answer value row + }); + + test("assertion 5: HTML-only — an html body and no text/plain part carrying markup", async () => { + await processWorkflowRunJob(jobData, baseContext); + + const msg = capturedMessage(); + expect(typeof msg.html).toBe("string"); + expect((msg.html ?? "").length).toBeGreaterThan(0); + // No text/plain alternative at all — parity with survey Follow-Ups (HTML-only send). + expect(msg.text).toBeUndefined(); + }); + + test("recipient is resolved Follow-Ups-style from the response question id; subject preserved", async () => { + await processWorkflowRunJob(jobData, baseContext); + + const msg = capturedMessage(); + expect(msg.to).toBe(RECIPIENT_EMAIL); + expect(msg.subject).toBe("Thanks for your response"); + expect(msg.messageId).toMatch(/^<.+@.+>$/); + }); + + test("run completes and the send_email step log is recorded as succeeded", async () => { + await processWorkflowRunJob(jobData, baseContext); + + // Terminal transition = completed. + const completion = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(completion.data.status).toBe("completed"); + expect(completion.data.data.steps[0]).toMatchObject({ + stepId: "send-email", + stepType: "send_email", + status: "succeeded", + }); + + // Claim-before-send: the step row is created `running` first, then updated to `succeeded` on the + // same row (never a second create — the (runId, stepId) unique constraint forbids it). + expect(mockWorkflowRunLogCreate).toHaveBeenCalledTimes(1); + expect(mockWorkflowRunLogCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + stepId: "send-email", + stepType: "send_email", + status: "running", + }), + }) + ); + expect(mockWorkflowRunLogUpdate).toHaveBeenCalledTimes(1); + expect(mockWorkflowRunLogUpdate.mock.calls[0][0].data.status).toBe("succeeded"); + }); + }); + + describe("assertion 6: tenant guard", () => { + test("a run whose trigger survey belongs to another workspace ends `failed` and sends NOTHING", async () => { + mockGetSurvey.mockResolvedValue({ ...survey, workspaceId: "cm9zr4wsp000000000foreign" }); + + await expect(processWorkflowRunJob(jobData, finalAttemptContext)).resolves.toBeUndefined(); + + // Nothing built, nothing sent. + expect(mockSendMail).not.toHaveBeenCalled(); + expect(mockCreateTransport).not.toHaveBeenCalled(); + + // Run failed with the tenant-guard error. + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.error).toMatch(/does not belong to workspace/); + }); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts new file mode 100644 index 000000000000..78f5934aaaa1 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts @@ -0,0 +1,806 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { Prisma } from "@formbricks/database/prisma"; +import type { TWorkflowRunJobData } from "@formbricks/jobs"; +import { processWorkflowRunJob } from "./process-workflow-run-job"; + +const { + mockSendEmail, + mockBuildHtml, + mockLoggerError, + mockLoggerInfo, + mockLoggerWarn, + mockWorkflowRunFindFirst, + mockWorkflowRunUpdateMany, + mockWorkflowRunLogCreate, + mockWorkflowRunLogUpdate, + mockWorkflowRunLogUpdateMany, + mockWorkflowRunLogFindFirst, + mockGetResponse, + mockGetSurvey, + mockGetOrganizationByWorkspaceId, + mockGetOrganizationMemberEmails, +} = vi.hoisted(() => ({ + mockSendEmail: vi.fn(), + mockBuildHtml: vi.fn(), + mockLoggerError: vi.fn(), + mockLoggerInfo: vi.fn(), + mockLoggerWarn: vi.fn(), + mockWorkflowRunFindFirst: vi.fn(), + mockWorkflowRunUpdateMany: vi.fn(), + mockWorkflowRunLogCreate: vi.fn(), + mockWorkflowRunLogUpdate: vi.fn(), + mockWorkflowRunLogUpdateMany: vi.fn(), + mockWorkflowRunLogFindFirst: vi.fn(), + mockGetResponse: vi.fn(), + mockGetSurvey: vi.fn(), + mockGetOrganizationByWorkspaceId: vi.fn(), + mockGetOrganizationMemberEmails: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + workflowRun: { + findFirst: mockWorkflowRunFindFirst, + updateMany: mockWorkflowRunUpdateMany, + }, + workflowRunLog: { + create: mockWorkflowRunLogCreate, + update: mockWorkflowRunLogUpdate, + updateMany: mockWorkflowRunLogUpdateMany, + findFirst: mockWorkflowRunLogFindFirst, + }, + }, +})); + +// Prisma's known-request-error shape the claim path checks for a P2002 unique-constraint conflict. +vi.mock("@formbricks/database/prisma", () => ({ + Prisma: { + PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, { code }: { code: string }) { + super(message); + this.code = code; + } + }, + }, +})); + +vi.mock("@formbricks/database/types/error", () => ({ + PrismaErrorType: { UniqueConstraintViolation: "P2002" }, +})); + +vi.mock("@/modules/email", () => ({ + sendEmail: mockSendEmail, +})); + +// Keep the real recipient resolution (pure zod) so `to`-resolution is exercised end-to-end; +// only the HTML builder (i18n + render) is stubbed. +vi.mock("@/modules/email/lib/survey-response-email", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, buildSurveyResponseEmailHtml: mockBuildHtml }; +}); + +vi.mock("@/lib/response/service", () => ({ + getResponse: mockGetResponse, +})); + +vi.mock("@/lib/survey/service", () => ({ + getSurvey: mockGetSurvey, +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByWorkspaceId: mockGetOrganizationByWorkspaceId, + getOrganizationMemberEmails: mockGetOrganizationMemberEmails, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + debug: vi.fn(), + error: mockLoggerError, + info: mockLoggerInfo, + warn: mockLoggerWarn, + }, +})); + +const triggerPayload = { + type: "response.completed" as const, + workspaceId: "cm9zr4wsp000508l8y6nh9r2v", + surveyId: "cm9zr4mps000008l8btfy1vtz", + responseId: "cm9zr4rsp000708l8bqccpfrx", + endingCardId: "cm9zr4q7i000108l84gozfggr", + data: { response: { email: "jane@example.com", score: 9 } }, + triggeredAt: "2026-06-09T12:01:00.000Z", +}; + +// `to` is a question/hidden-field id resolved against the response (Follow-Ups parity). +const makeDefinition = (to = "email", overrides: Record = {}) => ({ + schemaVersion: 1, + entryNodeId: "trigger", + trigger: { + id: "trigger", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "cm9zr4mps000008l8btfy1vtz", endingCardIds: [] }, + }, + nodes: [ + { + id: "send-email", + type: "action", + actionType: "send_email", + label: "Send thank you email", + config: { + to, + from: "noreply@example.com", + replyTo: ["support@example.com"], + subject: "Thanks for your response", + body: "

Hi #recall:name/fallback:there#

", + attachResponseData: true, + includeVariables: true, + includeHiddenFields: true, + ...overrides, + }, + }, + ], + edges: [{ id: "trigger-send-email", source: "trigger", target: "send-email" }], +}); + +const executableDefinition = makeDefinition(); + +const baseRun = { + id: "cm9zr4run000908l8q9b9d3pm", + status: "queued", + attempt: 0, + triggerPayload, + workflowVersion: { definition: executableDefinition }, + workflow: { definition: executableDefinition }, +}; + +const mockResponse = { + id: "cm9zr4rsp000708l8bqccpfrx", + surveyId: "cm9zr4mps000008l8btfy1vtz", + data: { email: "jane@example.com", name: "Jane" }, + variables: {}, + language: "en-US", +}; + +const mockSurvey = { + id: "cm9zr4mps000008l8btfy1vtz", + workspaceId: "cm9zr4wsp000508l8y6nh9r2v", + blocks: [], + languages: [], +}; + +const data: TWorkflowRunJobData = { + workflowRunId: "cm9zr4run000908l8q9b9d3pm", + workflowId: "cm9zr4wfl000008l8q9b9d3pm", + workspaceId: "cm9zr4wsp000508l8y6nh9r2v", +}; + +const baseContext = { + attempt: 1, + jobId: "job_123", + jobName: "workflow-run.process", + maxAttempts: 3, + queueName: "background-jobs", +}; + +const finalAttemptContext = { ...baseContext, attempt: baseContext.maxAttempts }; + +// A single-attempt context — the real prod queue config (attempts: 1), where attempt 1 IS the final one. +const singleAttemptContext = { ...baseContext, attempt: 1, maxAttempts: 1 }; + +const makeP2002 = (): Error => + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }); + +// A persisted WorkflowRunLog row for `(runId, "send-email")` in a given state, as findFirst returns it. +const existingStepLog = (status: string, overrides: Record = {}) => ({ + stepId: "send-email", + stepType: "send_email", + status, + input: { to: "email", subject: "Thanks for your response" }, + output: status === "succeeded" ? { provider: "smtp", messageId: "" } : {}, + error: null, + startedAt: new Date("2026-06-09T12:01:00.000Z"), + finishedAt: status === "succeeded" ? new Date("2026-06-09T12:01:01.000Z") : null, + ...overrides, +}); + +describe("processWorkflowRunJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWorkflowRunFindFirst.mockResolvedValue(baseRun); + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 1 }); + // No prior step log by default → the step gets claimed via create({status:"running"}) then sent. + mockWorkflowRunLogFindFirst.mockResolvedValue(null); + mockWorkflowRunLogCreate.mockResolvedValue(undefined); + mockWorkflowRunLogUpdate.mockResolvedValue(undefined); + mockWorkflowRunLogUpdateMany.mockResolvedValue({ count: 1 }); + mockSendEmail.mockResolvedValue(true); + mockBuildHtml.mockResolvedValue("branded"); + mockGetResponse.mockResolvedValue(mockResponse); + mockGetSurvey.mockResolvedValue(mockSurvey); + mockGetOrganizationByWorkspaceId.mockResolvedValue({ id: "org1", whitelabel: { logoUrl: "logo.png" } }); + // Recipient allowlist (ENG-2029): the literal addresses the send tests target are org members. + mockGetOrganizationMemberEmails.mockResolvedValue(new Set(["teammate@example.com", "jane@example.com"])); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("loads the run scoped to the job's workspace", async () => { + await processWorkflowRunJob(data, baseContext); + + expect(mockWorkflowRunFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: data.workflowRunId, workspaceId: data.workspaceId }, + }) + ); + }); + + test("executes a send_email run end-to-end (Follow-Ups parity) and completes it", async () => { + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + // Survey + response + org loaded once for the run. + expect(mockGetResponse).toHaveBeenCalledWith(triggerPayload.responseId); + expect(mockGetSurvey).toHaveBeenCalledWith(triggerPayload.surveyId); + expect(mockGetOrganizationByWorkspaceId).toHaveBeenCalledWith(data.workspaceId); + + // Branded HTML built from the recall body + gating flags, org logo threaded in. + expect(mockBuildHtml).toHaveBeenCalledWith( + expect.objectContaining({ + body: "

Hi #recall:name/fallback:there#

", + survey: mockSurvey, + response: mockResponse, + attachResponseData: true, + includeVariables: true, + includeHiddenFields: true, + logoUrl: "logo.png", + locale: "en-US", + }) + ); + + // HTML-only send (no `text`), resolved recipient, sanitized subject, stable Message-ID. + expect(mockSendEmail).toHaveBeenCalledTimes(1); + const sendArgs = mockSendEmail.mock.calls[0][0]; + expect(sendArgs).toMatchObject({ + to: "jane@example.com", + replyTo: "support@example.com", + subject: "Thanks for your response", + html: "branded", + }); + // No `from` — the deployment MAIL_FROM default applies (Follow-Ups parity). + expect(sendArgs.from).toBeUndefined(); + expect(sendArgs.text).toBeUndefined(); + expect(sendArgs.messageId).toMatch(/^<.+@example\.com>$/); + + // Claim-before-send: the step row is created `running` first, then updated to `succeeded` on the + // same row (never a second create). + expect(mockWorkflowRunLogCreate).toHaveBeenCalledTimes(1); + expect(mockWorkflowRunLogCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ runId: baseRun.id, stepId: "send-email", status: "running" }), + }) + ); + expect(mockWorkflowRunLogUpdate).toHaveBeenCalledTimes(1); + expect(mockWorkflowRunLogUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { runId_stepId: { runId: baseRun.id, stepId: "send-email" } }, + data: expect.objectContaining({ + status: "succeeded", + output: { provider: "smtp", messageId: sendArgs.messageId }, + }), + }) + ); + + const completion = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(completion.data.status).toBe("completed"); + expect(completion.data.data.steps[0]).toMatchObject({ + stepId: "send-email", + stepType: "send_email", + status: "succeeded", + output: { provider: "smtp", messageId: sendArgs.messageId }, + }); + }); + + test("resolves a literal-email `to` directly", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("teammate@example.com") }, + workflow: { definition: makeDefinition("teammate@example.com") }, + }); + + await processWorkflowRunJob(data, baseContext); + + expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "teammate@example.com" })); + }); + + test("does not send to a literal recipient outside the organization allowlist (ENG-2029)", async () => { + // A literal external recipient that is not an org member: the step fails and no email is sent, + // even though the address resolves fine — the send-time backstop to the enable-time check. + mockGetOrganizationMemberEmails.mockResolvedValue(new Set(["teammate@example.com"])); + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("attacker@external-evil.example") }, + workflow: { definition: makeDefinition("attacker@external-evil.example") }, + }); + + await processWorkflowRunJob(data, finalAttemptContext); + + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockWorkflowRunLogUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "failed", + error: expect.stringContaining("not a member of the organization"), + }), + }) + ); + }); + + test("matches a literal recipient against the allowlist case-insensitively (ENG-2029)", async () => { + // The member allowlist is lowercased; a mixed-case literal `to` must still match and send. + mockGetOrganizationMemberEmails.mockResolvedValue(new Set(["teammate@example.com"])); + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("Teammate@Example.com") }, + workflow: { definition: makeDefinition("Teammate@Example.com") }, + }); + + await processWorkflowRunJob(data, baseContext); + + expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "Teammate@Example.com" })); + }); + + test("fails closed for a literal recipient when the organization cannot be resolved (ENG-2029)", async () => { + // No organization → empty allowlist → a literal recipient is rejected rather than sent unchecked. + mockGetOrganizationByWorkspaceId.mockResolvedValue(null); + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("teammate@example.com") }, + workflow: { definition: makeDefinition("teammate@example.com") }, + }); + + await processWorkflowRunJob(data, finalAttemptContext); + + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockWorkflowRunLogUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "failed", + error: expect.stringContaining("not a member of the organization"), + }), + }) + ); + }); + + test("still sends to a respondent-field recipient even when it is outside the member allowlist", async () => { + // A field-resolved recipient is the respondent's own address, always allowed regardless of the + // org allowlist — only literal addresses are gated. + mockGetOrganizationMemberEmails.mockResolvedValue(new Set()); + mockGetResponse.mockResolvedValue({ + ...mockResponse, + data: { contact: ["Jane", "Doe", "jane@example.com", "+1"] }, + }); + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("contact") }, + workflow: { definition: makeDefinition("contact") }, + }); + + await processWorkflowRunJob(data, baseContext); + + expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "jane@example.com" })); + }); + + test("does not query the member allowlist when no step has a literal recipient", async () => { + // The allowlist only gates literal addresses, so the common respondent-field run must not pay an + // unbounded membership query on every completed response. + mockGetResponse.mockResolvedValue({ + ...mockResponse, + data: { contact: ["Jane", "Doe", "jane@example.com", "+1"] }, + }); + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("contact") }, + workflow: { definition: makeDefinition("contact") }, + }); + + await processWorkflowRunJob(data, baseContext); + + expect(mockGetOrganizationMemberEmails).not.toHaveBeenCalled(); + expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "jane@example.com" })); + }); + + test("queries the member allowlist once when a step has a literal recipient", async () => { + mockGetOrganizationMemberEmails.mockResolvedValue(new Set(["teammate@example.com"])); + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("teammate@example.com") }, + workflow: { definition: makeDefinition("teammate@example.com") }, + }); + + await processWorkflowRunJob(data, baseContext); + + expect(mockGetOrganizationMemberEmails).toHaveBeenCalledTimes(1); + expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "teammate@example.com" })); + }); + + test("resolves a contact-info array `to` using index [2]", async () => { + mockGetResponse.mockResolvedValue({ + ...mockResponse, + data: { contact: ["Jane", "Doe", "jane@example.com", "+1"] }, + }); + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("contact") }, + workflow: { definition: makeDefinition("contact") }, + }); + + await processWorkflowRunJob(data, baseContext); + + expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "jane@example.com" })); + }); + + test("fails the step (no send) when the recipient cannot be resolved (final attempt → failed)", async () => { + mockGetResponse.mockResolvedValue({ ...mockResponse, data: { name: "Jane" } }); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockBuildHtml).not.toHaveBeenCalled(); + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.data.steps[0]).toMatchObject({ status: "failed" }); + }); + + test("claims a WorkflowRunLog row per executed step (running → succeeded, no duplicate create)", async () => { + await processWorkflowRunJob(data, baseContext); + + // Exactly one row claimed (create running) and one terminal update — never two creates for a step. + expect(mockWorkflowRunLogCreate).toHaveBeenCalledTimes(1); + expect(mockWorkflowRunLogCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + runId: baseRun.id, + sequence: 1, + stepId: "send-email", + stepType: "send_email", + status: "running", + }), + }) + ); + expect(mockWorkflowRunLogUpdate).toHaveBeenCalledTimes(1); + expect(mockWorkflowRunLogUpdate.mock.calls[0][0].data.status).toBe("succeeded"); + }); + + test("no-ops on a run that is already terminal (replay safe)", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ ...baseRun, status: "completed" }); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockWorkflowRunUpdateMany).not.toHaveBeenCalled(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.objectContaining({ status: "completed" }), + "Workflow run already terminal; skipping" + ); + }); + + test("drops the job when the run cannot be found for the workspace", async () => { + mockWorkflowRunFindFirst.mockResolvedValue(null); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockWorkflowRunUpdateMany).not.toHaveBeenCalled(); + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Object), + "Workflow run not found for workspace; dropping job" + ); + }); + + test("fails the run when the response is missing (final attempt → failed)", async () => { + mockGetResponse.mockResolvedValue(null); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.error).toMatch(/Response .* not found/); + }); + + test("fails the run when the survey is missing (final attempt → failed)", async () => { + mockGetSurvey.mockResolvedValue(null); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.error).toMatch(/Survey .* not found/); + }); + + test("fails the run when the survey belongs to another workspace (final attempt → failed)", async () => { + mockGetSurvey.mockResolvedValue({ ...mockSurvey, workspaceId: "cm9zr4wsp000000000foreign" }); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.error).toMatch(/does not belong to workspace/); + }); + + test("fails the run when the response belongs to another survey (final attempt → failed)", async () => { + mockGetResponse.mockResolvedValue({ ...mockResponse, surveyId: "cm9zr4mps00000000foreign" }); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.error).toMatch(/does not belong to survey/); + }); + + test("uses an empty logo url when the organization has no whitelabel logo", async () => { + mockGetOrganizationByWorkspaceId.mockResolvedValue({ id: "org1", whitelabel: null }); + + await processWorkflowRunJob(data, baseContext); + + expect(mockBuildHtml).toHaveBeenCalledWith(expect.objectContaining({ logoUrl: "" })); + }); + + test("returns without double-processing when the queued -> running claim loses the race", async () => { + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 0 }); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockWorkflowRunUpdateMany).toHaveBeenCalledTimes(1); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.any(Object), + "Workflow run already claimed by another delivery; skipping" + ); + }); + + test("falls back to the workflow definition when no version snapshot exists", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ ...baseRun, workflowVersion: null }); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + expect(mockSendEmail).toHaveBeenCalledTimes(1); + }); + + test("resume: does NOT re-send a step that already has a succeeded log", async () => { + // Retry of a run mid-flight: run already `running`, its only step already `succeeded`. + mockWorkflowRunFindFirst.mockResolvedValue({ ...baseRun, status: "running" }); + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 0 }); + mockWorkflowRunLogFindFirst.mockResolvedValue(existingStepLog("succeeded")); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + // No claim create and no re-update for an already-succeeded step. + expect(mockWorkflowRunLogCreate).not.toHaveBeenCalled(); + expect(mockWorkflowRunLogUpdate).not.toHaveBeenCalled(); + const completion = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(completion.data.status).toBe("completed"); + expect(completion.data.data.steps[0]).toMatchObject({ + stepId: "send-email", + status: "succeeded", + output: { provider: "smtp", messageId: "" }, + }); + }); + + test("retry of a failed send: claims the failed row and re-sends (it never went out)", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ ...baseRun, status: "running" }); + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 0 }); + mockWorkflowRunLogFindFirst.mockResolvedValue(existingStepLog("failed", { error: "prev SMTP error" })); + mockWorkflowRunLogUpdateMany.mockResolvedValue({ count: 1 }); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + // Claimed the failed row via updateMany (failed→running), then re-sent, then updated to succeeded. + expect(mockWorkflowRunLogUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { runId: baseRun.id, stepId: "send-email", status: { in: ["failed", "pending"] } }, + data: expect.objectContaining({ status: "running" }), + }) + ); + expect(mockWorkflowRunLogCreate).not.toHaveBeenCalled(); + expect(mockSendEmail).toHaveBeenCalledTimes(1); + const completion = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(completion.data.status).toBe("completed"); + expect(completion.data.data.steps[0]).toMatchObject({ status: "succeeded" }); + }); + + test("crash-mid-send / concurrent: a running row → bail (no send, run left running, NOT finalized)", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ ...baseRun, status: "running" }); + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 0 }); + mockWorkflowRunLogFindFirst.mockResolvedValue(existingStepLog("running")); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + // At-most-once: never re-send a step already claimed (running). And bail: never finalize a run the + // owner still holds — no completed/failed write, so it's left `running` for the owner to finish. + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockWorkflowRunLogCreate).not.toHaveBeenCalled(); + expect(mockWorkflowRunLogUpdate).not.toHaveBeenCalled(); + expect(mockWorkflowRunLogUpdateMany).not.toHaveBeenCalled(); + const runStatuses = mockWorkflowRunUpdateMany.mock.calls.map((call) => call[0].data.status); + expect(runStatuses).not.toContain("completed"); + expect(runStatuses).not.toContain("failed"); + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ stepId: "send-email" }), + "Workflow step already in-flight (running); another delivery owns this run — bailing" + ); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.any(Object), + "Workflow run owned by another delivery; leaving it running without finalizing" + ); + }); + + test("concurrent claim lost (create P2002): bail, no send, run not finalized", async () => { + // No row on first read → attempt create → a concurrent worker created it first (P2002) → bail. + mockWorkflowRunFindFirst.mockResolvedValue({ ...baseRun, status: "running" }); + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 0 }); + mockWorkflowRunLogFindFirst.mockResolvedValue(null); + mockWorkflowRunLogCreate.mockRejectedValueOnce(makeP2002()); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + const runStatuses = mockWorkflowRunUpdateMany.mock.calls.map((call) => call[0].data.status); + expect(runStatuses).not.toContain("completed"); + expect(runStatuses).not.toContain("failed"); + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ stepId: "send-email" }), + "Workflow step claim lost to a concurrent delivery — bailing" + ); + }); + + test("concurrent claim lost (failed row, updateMany count 0): bail, no send, run not finalized", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ ...baseRun, status: "running" }); + mockWorkflowRunUpdateMany.mockResolvedValue({ count: 0 }); + mockWorkflowRunLogFindFirst.mockResolvedValue(existingStepLog("failed")); + mockWorkflowRunLogUpdateMany.mockResolvedValue({ count: 0 }); + + await expect(processWorkflowRunJob(data, baseContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockWorkflowRunLogCreate).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ stepId: "send-email" }), + "Workflow step claim lost to a concurrent delivery — bailing" + ); + const runStatuses = mockWorkflowRunUpdateMany.mock.calls.map((call) => call[0].data.status); + expect(runStatuses).not.toContain("completed"); + expect(runStatuses).not.toContain("failed"); + }); + + test("keeps the run non-terminal and rethrows on a non-final attempt when SMTP is not configured", async () => { + mockSendEmail.mockResolvedValue(false); + + await expect(processWorkflowRunJob(data, baseContext)).rejects.toThrow(/SMTP is not configured/); + + const statuses = mockWorkflowRunUpdateMany.mock.calls.map((call) => call[0].data.status); + expect(statuses).not.toContain("failed"); + }); + + test("marks the run failed on the final attempt when SMTP is not configured (sendEmail returns false)", async () => { + mockSendEmail.mockResolvedValue(false); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.error).toMatch(/SMTP is not configured/); + expect(failure.data.data.steps[0]).toMatchObject({ status: "failed" }); + }); + + test("keeps the run non-terminal and rethrows on a non-final attempt when sendEmail throws", async () => { + mockSendEmail.mockRejectedValue(new Error("SMTP provider rejected the message")); + + await expect(processWorkflowRunJob(data, baseContext)).rejects.toThrow(/SMTP provider rejected/); + + const statuses = mockWorkflowRunUpdateMany.mock.calls.map((call) => call[0].data.status); + expect(statuses).not.toContain("failed"); + }); + + test("marks the run failed on the final attempt when sendEmail throws", async () => { + mockSendEmail.mockRejectedValue(new Error("SMTP provider rejected the message")); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.data.steps[0]).toMatchObject({ + status: "failed", + error: "SMTP provider rejected the message", + }); + }); + + test("marks the run failed for an invalid / non-executable definition (final attempt)", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: { not: "a workflow" } }, + workflow: { definition: { not: "a workflow" } }, + }); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + expect(mockSendEmail).not.toHaveBeenCalled(); + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + }); + + test("sanitizes control characters out of the subject before sending", async () => { + mockWorkflowRunFindFirst.mockResolvedValue({ + ...baseRun, + workflowVersion: { definition: makeDefinition("email", { subject: "Hi\r\nBcc: evil@example.com" }) }, + workflow: { definition: makeDefinition("email", { subject: "Hi\r\nBcc: evil@example.com" }) }, + }); + + await processWorkflowRunJob(data, baseContext); + + const sendArgs = mockSendEmail.mock.calls[0][0]; + expect(sendArgs.subject).toBe("HiBcc: evil@example.com"); + expect(sendArgs.subject).not.toContain("\r"); + expect(sendArgs.subject).not.toContain("\n"); + }); + + test("swallows the failure on the final attempt after recording it", async () => { + mockSendEmail.mockRejectedValue(new Error("SMTP provider rejected the message")); + + await expect(processWorkflowRunJob(data, finalAttemptContext)).resolves.toBeUndefined(); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + "Workflow run job failed after final attempt" + ); + }); + + test("rethrows a transient DB pool exhaustion so BullMQ retries (no failed status persisted)", async () => { + mockWorkflowRunUpdateMany.mockImplementation(({ data: updateData }: { data: { status: string } }) => { + if (updateData.status === "running") { + return Promise.reject(new Error("Timed out fetching a new connection from the connection pool")); + } + return Promise.resolve({ count: 1 }); + }); + + await expect(processWorkflowRunJob(data, baseContext)).rejects.toThrow(/connection pool/i); + + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + "Workflow run job hit database pool exhaustion; will retry" + ); + const statuses = mockWorkflowRunUpdateMany.mock.calls.map((call) => call[0].data.status); + expect(statuses).not.toContain("failed"); + }); + + test("maxAttempts:1 — pool exhaustion on the only attempt records failed (never stuck running)", async () => { + // The real prod config: attempts:1, so the first attempt is also the final one. A pool-exhaustion + // here must be recorded terminal `failed`, not rethrown into the void leaving the run `running`. + mockWorkflowRunUpdateMany.mockImplementation(({ data: updateData }: { data: { status?: string } }) => { + if (updateData.status === "running") { + return Promise.reject(new Error("Timed out fetching a new connection from the connection pool")); + } + return Promise.resolve({ count: 1 }); + }); + + // Swallowed on the final attempt (no rethrow into BullMQ with nothing left to retry). + await expect(processWorkflowRunJob(data, singleAttemptContext)).resolves.toBeUndefined(); + + const failure = mockWorkflowRunUpdateMany.mock.calls.at(-1)?.[0]; + expect(failure.data.status).toBe("failed"); + expect(failure.data.finishedAt).toBeInstanceOf(Date); + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + "Workflow run job failed after final attempt" + ); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.ts new file mode 100644 index 000000000000..8117b62eeebb --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.ts @@ -0,0 +1,688 @@ +import "server-only"; +import { createHash } from "node:crypto"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { type JobHandler, type TWorkflowRunJobData } from "@formbricks/jobs"; +import { logger } from "@formbricks/logger"; +import type { TResponse } from "@formbricks/types/responses"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import { type TUserLocale, ZUserLocale } from "@formbricks/types/user"; +import { + type TWorkflowExecutableStep, + type TWorkflowRunData, + type TWorkflowStepResult, + type TWorkflowTriggerRunPayload, + ZWorkflowExecutableDefinition, + ZWorkflowRunData, + ZWorkflowTriggerRunPayload, + isLiteralEmailRecipient, + planExecutableSteps, +} from "@formbricks/workflows"; +import { isDatabasePoolExhaustionError } from "@/lib/jobs/pool-exhaustion"; +import { getOrganizationByWorkspaceId, getOrganizationMemberEmails } from "@/lib/organization/service"; +import { getResponse } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { normalizeEmailForComparison } from "@/lib/utils/email"; +import { sendEmail } from "@/modules/email"; +import { + buildSurveyResponseEmailHtml, + resolveResponseRecipient, +} from "@/modules/email/lib/survey-response-email"; + +/** Strips CR/LF and other control chars from the subject — defense against SMTP header injection. */ +// eslint-disable-next-line no-control-regex -- intentionally matching control chars to strip them +const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f\u2028\u2029]/g; +const stripControlChars = (value: string): string => value.replace(CONTROL_CHARS_PATTERN, ""); + +/** Coerces a response's free-form `language` to a supported locale, falling back to undefined (→ default). */ +const toResponseLocale = (language: string | null): TUserLocale | undefined => { + const parsed = ZUserLocale.safeParse(language); + if (!parsed.success) return undefined; + return parsed.data; +}; + +/** Run states from which there is no further work — replays/redeliveries on these are no-ops. */ +const TERMINAL_STATUS_LIST = ["completed", "failed", "canceled"] as const; +const TERMINAL_STATUSES = new Set(TERMINAL_STATUS_LIST); + +/** Run states a delivery may claim by transitioning to `running`. */ +const CLAIMABLE_STATUSES = ["queued"] as const; + +const EMAIL_PROVIDER = "smtp"; + +const workflowRunSelect = { + id: true, + status: true, + attempt: true, + triggerPayload: true, + workflowVersion: { select: { definition: true } }, + workflow: { select: { definition: true } }, +} as const; + +/** Thrown when the run cannot be executed at all (definition missing/invalid). Marks the run failed. */ +class WorkflowRunNotExecutableError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowRunNotExecutableError"; + } +} + +/** + * Thrown when a step fails after some steps may have run. Carries the partial `data.steps` so the + * single failure-persistence path records the executed trace alongside the failed status. + */ +class WorkflowStepFailedError extends Error { + readonly runData: TWorkflowRunData; + constructor(message: string, runData: TWorkflowRunData) { + super(message); + this.name = "WorkflowStepFailedError"; + this.runData = runData; + } +} + +const toError = (error: unknown, fallbackMessage: string): Error => { + if (error instanceof Error) return error; + return new Error(fallbackMessage); +}; + +const getWorkflowRunLogContext = ( + data: TWorkflowRunJobData, + context: Parameters>[1] +) => ({ + attempt: context.attempt, + jobId: context.jobId, + jobName: context.jobName, + maxAttempts: context.maxAttempts, + queueName: context.queueName, + workflowId: data.workflowId, + workflowRunId: data.workflowRunId, + workspaceId: data.workspaceId, +}); + +const MESSAGE_ID_FALLBACK_DOMAIN = "formbricks.com"; + +/** Extracts the domain from a validated email address, falling back to a stable default. */ +const domainFromEmail = (from: string): string => from.split("@")[1]?.trim() || MESSAGE_ID_FALLBACK_DOMAIN; + +/** + * Builds the deterministic RFC 5322 Message-ID (``) stored on a step output and sent as + * the `Message-ID` header. Stable across retries for the same run + step, so a replayed send carries + * the same id — enabling protocol-level dedup and traceability to the same logical message. + * + * Sized to 128 bits (32 hex chars) — the conventional scale for uniqueness ids (a UUID is 122). + * The id only needs to be unique, never preimage-resistant, so the full 256-bit digest bought + * nothing while pushing the header past the RFC 5322 78-char soft limit, forcing nodemailer to + * fold it; folded headers are valid MIME but trip naive parsers (MailHog shows the folded id as + * part of the Subject). + */ +const buildMessageId = (workflowRunId: string, stepId: string, from: string): string => { + const hash = createHash("sha256").update(`${workflowRunId}:${stepId}`).digest("hex").slice(0, 32); + return `<${hash}@${domainFromEmail(from)}>`; +}; + +/** + * Resolve the run's executable definition: prefer the immutable `workflowVersion.definition` + * snapshot, fall back to the workflow's live `definition`. The persisted JSON is untrusted, so it is + * parsed through `ZWorkflowExecutableDefinition`; a non-executable definition fails the run. + */ +const resolveExecutableDefinition = (run: { + workflowVersion: { definition: unknown } | null; + workflow: { definition: unknown }; +}): ReturnType => { + const rawDefinition = run.workflowVersion?.definition ?? run.workflow.definition; + const parsed = ZWorkflowExecutableDefinition.safeParse(rawDefinition); + if (!parsed.success) { + throw new WorkflowRunNotExecutableError("Workflow run definition is not executable"); + } + return parsed.data; +}; + +interface StepOutcome { + status: TWorkflowStepResult["status"]; + error: string | null; + output: Record; +} + +/** The survey/response/branding context, loaded once per run, that every step renders against. */ +interface RunEmailContext { + survey: TSurvey; + response: TResponse; + logoUrl: string; + /** + * Lowercased allowlist of the organization's member emails. A literal `to` recipient must be in + * this set to receive response data (ENG-2029); loaded once per run, and only when the run + * actually has a literal recipient to check. Empty when the organization can't be resolved (or + * when no step needs it), which fails closed — a literal external recipient is then rejected. + */ + allowedRecipientEmails: Set; +} + +/** + * Resolves and sends one `send_email` step, Follow-Ups parity: resolves the recipient from the + * response (literal email or question/hidden-field id), renders the branded HTML via + * `buildSurveyResponseEmailHtml` (recall body + optional response-data blocks), and sends HTML-only. + * The subject is sanitized (control chars stripped) and a stable RFC `Message-ID` is recorded on both + * the sent header and the step output. Any resolution/send failure returns a failed outcome; the + * caller fails the whole run so it propagates to the run lifecycle. + */ +const sendResolvedEmail = async ( + config: TWorkflowExecutableStep["node"]["config"], + emailContext: RunEmailContext, + workflowRunId: string, + stepId: string +): Promise => { + const recipient = resolveResponseRecipient(config.to, emailContext.response); + if (!recipient.ok) { + return { status: "failed", error: recipient.error, output: {} }; + } + + // Recipient allowlist (ENG-2029): a literal `to` address may only be an organization member, so a + // live workflow can't silently forward response data to an arbitrary external inbox. A + // respondent-field `to` resolves to the respondent's own address and is always allowed. This is + // the send-time backstop to the enable-time check — it also covers a member removed after enable. + if ( + isLiteralEmailRecipient(config.to) && + !emailContext.allowedRecipientEmails.has(normalizeEmailForComparison(recipient.email)) + ) { + return { + status: "failed", + error: `Recipient ${recipient.email} is not a member of the organization`, + output: {}, + }; + } + + const messageId = buildMessageId(workflowRunId, stepId, config.from); + const subject = stripControlChars(config.subject); + + try { + const html = await buildSurveyResponseEmailHtml({ + body: config.body, + survey: emailContext.survey, + response: emailContext.response, + attachResponseData: config.attachResponseData, + includeVariables: config.includeVariables, + includeHiddenFields: config.includeHiddenFields, + logoUrl: emailContext.logoUrl, + locale: toResponseLocale(emailContext.response.language), + }); + + // No `from` — the deployment MAIL_FROM default applies, exactly like survey Follow-Ups. `config.from` + // is only used to derive the stable Message-ID domain, never as the actual sender. + const sent = await sendEmail({ + to: recipient.email, + replyTo: config.replyTo.length > 0 ? config.replyTo.join(", ") : undefined, + subject, + html, + messageId, + }); + + if (sent) { + return { status: "succeeded", error: null, output: { messageId, provider: EMAIL_PROVIDER } }; + } + return { status: "failed", error: "SMTP is not configured; workflow email was not sent", output: {} }; + } catch (sendError) { + return { + status: "failed", + error: toError(sendError, "Failed to send workflow email").message, + output: {}, + }; + } +}; + +/** A persisted `WorkflowRunLog` row, as much of it as the claim/resume logic needs. */ +interface StepLogRow { + stepId: string; + stepType: string; + status: TWorkflowStepResult["status"]; + input: unknown; + output: unknown; + error: string | null; + startedAt: Date | null; + finishedAt: Date | null; +} + +const stepLogSelect = { + stepId: true, + stepType: true, + status: true, + input: true, + output: true, + error: true, + startedAt: true, + finishedAt: true, +} as const; + +/** Reconstructs a step result from its persisted log row (used to resume a `succeeded` step, or record a `skipped` one). */ +const resultFromLogRow = (log: StepLogRow, status: TWorkflowStepResult["status"]): TWorkflowStepResult => ({ + stepId: log.stepId, + stepType: log.stepType, + status, + input: (log.input as Record) ?? {}, + output: (log.output as Record) ?? {}, + ...(log.error ? { error: log.error } : {}), + startedAt: log.startedAt?.toISOString() ?? new Date().toISOString(), + finishedAt: log.finishedAt?.toISOString() ?? new Date().toISOString(), +}); + +const isUniqueConstraintError = (error: unknown): boolean => + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === PrismaErrorType.UniqueConstraintViolation; + +/** + * Sends an owned step (we hold its `running` log row) and records the terminal outcome onto that same + * row via `update` (never a second `create` — the `(runId, stepId)` unique constraint forbids it). + * + * At-most-once tradeoff: we claim (`running`) BEFORE calling `sendEmail`, so if the process crashes + * between a successful SMTP handoff and the `succeeded` update, the retry sees `running` and will NOT + * re-send — that email is potentially lost rather than duplicated. This is the accepted policy: never + * send the same step twice. + */ +const sendOwnedStep = async ( + step: TWorkflowExecutableStep, + runId: string, + emailContext: RunEmailContext +): Promise => { + const input = { to: step.node.config.to, subject: step.node.config.subject }; + const { status, error, output } = await sendResolvedEmail( + step.node.config, + emailContext, + runId, + step.stepId + ); + const finishedAt = new Date(); + + await prisma.workflowRunLog.update({ + where: { runId_stepId: { runId, stepId: step.stepId } }, + data: { status, output, error, finishedAt }, + }); + + return { + stepId: step.stepId, + stepType: step.stepType, + status, + input, + output, + ...(error ? { error } : {}), + startedAt: finishedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + }; +}; + +/** + * Claims a not-yet-succeeded step and sends it. Two claim paths: + * - no row: `create` a `running` row. A P2002 means a concurrent worker created it first — re-read and + * defer to the resume/skip logic in `runStep`. + * - existing `failed`/`pending` row: flip it to `running` via a conditional `updateMany`; `count===0` + * means another worker already claimed it, so skip (at-most-once). + * Returns `null` when the claim was lost (caller re-reads / skips). + */ +const claimAndSendStep = async ( + step: TWorkflowExecutableStep, + sequence: number, + runId: string, + existing: StepLogRow | null, + emailContext: RunEmailContext +): Promise => { + const startedAt = new Date(); + + if (!existing) { + try { + await prisma.workflowRunLog.create({ + data: { + runId, + sequence, + stepId: step.stepId, + stepType: step.stepType, + status: "running", + input: { to: step.node.config.to, subject: step.node.config.subject }, + startedAt, + }, + }); + } catch (error) { + if (isUniqueConstraintError(error)) { + return null; // Concurrent create won; caller re-reads and resumes/skips. + } + throw error; + } + return sendOwnedStep(step, runId, emailContext); + } + + // A prior attempt left a `failed`/`pending` row — take it over only if it is still in that state. + const claim = await prisma.workflowRunLog.updateMany({ + where: { runId, stepId: step.stepId, status: { in: ["failed", "pending"] } }, + data: { status: "running", startedAt, output: {}, error: null, finishedAt: null }, + }); + if (claim.count === 0) { + return null; // Another worker claimed it first; skip per at-most-once. + } + return sendOwnedStep(step, runId, emailContext); +}; + +/** + * Sentinel: another live delivery (or a stalled prior attempt) owns this run. The current execution + * must stop and leave the run `running` rather than finalize it — otherwise a second worker could + * `complete` the run while the owner's in-flight send is still resolving (possibly to `failed`), + * masking the failure. The owner finishes the run; a truly stuck one is recovered by the + * execution-side reconciler (`reconcile-stuck-running-runs.ts`), which fails the run and skips its + * orphaned steps. + */ +const STEP_BAIL = Symbol("workflow-step-bail"); + +/** + * Runs one planned step with a per-step claim guard: + * - `succeeded` row → resume (no send). + * - `running` row → owned by a concurrent delivery / stalled attempt → BAIL (no send, don't finalize). + * - no row / `failed` / `pending` → claim, then send (a `failed` send never went out, so re-sending is safe). + * A lost claim (concurrent `create`/`updateMany` won) also BAILs. + */ +const runStep = async ( + step: TWorkflowExecutableStep, + sequence: number, + runId: string, + emailContext: RunEmailContext, + logContext: ReturnType +): Promise => { + const existing: StepLogRow | null = await prisma.workflowRunLog.findFirst({ + where: { runId, stepId: step.stepId }, + select: stepLogSelect, + }); + + if (existing?.status === "succeeded") { + return resultFromLogRow(existing, "succeeded"); + } + + if (existing?.status === "running") { + // A `running` row we did NOT just create means another live delivery (or a stalled prior attempt) + // owns this run. At-most-once: never re-send; and don't finalize the run — bail and let the owner. + logger.warn( + { ...logContext, stepId: step.stepId }, + "Workflow step already in-flight (running); another delivery owns this run — bailing" + ); + return STEP_BAIL; + } + + const result = await claimAndSendStep(step, sequence, runId, existing, emailContext); + if (result) { + return result; + } + + // Claim lost to a concurrent delivery after we read the row — bail rather than risk a double-send or + // prematurely finalizing a run the winner still owns. + logger.warn( + { ...logContext, stepId: step.stepId }, + "Workflow step claim lost to a concurrent delivery — bailing" + ); + return STEP_BAIL; +}; + +/** + * Claims the run with a conditional, tenant-scoped `queued → running` transition so two concurrent + * deliveries cannot both process it. Returns `false` when the claim loses the race (0 rows updated + * while the loaded status was still `queued`); a loaded `running` status is a legitimate retry and + * still claims (so the resume path runs). + */ +const claimRun = async ( + run: { id: string; status: string }, + workspaceId: string, + attempt: number, + logContext: ReturnType +): Promise => { + const claim = await prisma.workflowRun.updateMany({ + where: { id: run.id, workspaceId, status: { in: [...CLAIMABLE_STATUSES] } }, + data: { status: "running", startedAt: new Date(), attempt }, + }); + + if (claim.count === 0 && run.status === "queued") { + logger.info(logContext, "Workflow run already claimed by another delivery; skipping"); + return false; + } + return true; +}; + +/** + * Loads the survey/response/branding context a run's emails render against. Uses the same loaders the + * response-pipeline job uses (worker-safe, no request scope). Missing survey or response is + * unrecoverable for this run and fails it. + * + * `steps` is taken (already planned by the caller) only to decide whether the recipient allowlist is + * needed at all — see `allowedRecipientEmails` below. + */ +const loadRunEmailContext = async ( + triggerPayload: TWorkflowTriggerRunPayload, + workspaceId: string, + steps: TWorkflowExecutableStep[] +): Promise => { + const [response, survey] = await Promise.all([ + getResponse(triggerPayload.responseId), + getSurvey(triggerPayload.surveyId), + ]); + + if (!response) { + throw new WorkflowRunNotExecutableError(`Response ${triggerPayload.responseId} not found`); + } + if (!survey) { + throw new WorkflowRunNotExecutableError(`Survey ${triggerPayload.surveyId} not found`); + } + + // Tenant-integrity guard: the survey/response ids come from the (untrusted) stored trigger payload, + // so confirm they belong to this run's workspace/survey before rendering an email from their data. + if (survey.workspaceId !== workspaceId) { + throw new WorkflowRunNotExecutableError( + `Survey ${triggerPayload.surveyId} does not belong to workspace ${workspaceId}` + ); + } + if (response.surveyId !== triggerPayload.surveyId) { + throw new WorkflowRunNotExecutableError( + `Response ${triggerPayload.responseId} does not belong to survey ${triggerPayload.surveyId}` + ); + } + + const organization = await getOrganizationByWorkspaceId(workspaceId); + const logoUrl = organization?.whitelabel?.logoUrl ?? ""; + // Only literal `to` recipients are allowlist-checked, so the member query is skipped entirely for + // the common respondent-field-only run rather than paying an unbounded lookup on every response. + // Fail closed: no resolvable organization means an empty allowlist, so a literal external + // recipient is rejected rather than allowed through unchecked. + const needsRecipientAllowlist = steps.some((step) => isLiteralEmailRecipient(step.node.config.to)); + const allowedRecipientEmails = + needsRecipientAllowlist && organization + ? await getOrganizationMemberEmails(organization.id) + : new Set(); + + return { survey, response, logoUrl, allowedRecipientEmails }; +}; + +/** + * Runs the planned steps in order under the per-step claim guard (`runStep`). Three outcomes: + * - `bail`: a step is owned by a concurrent delivery / stalled attempt → stop, do NOT finalize the run + * (the owner does). The per-step `WorkflowRunLog` rows remain the source of truth for the run's state. + * - `failure`: a step's send definitively failed this attempt → fail the whole run (row stays `failed`, + * which is retryable). + * - success: all steps resolved. + */ +const runSteps = async ( + steps: TWorkflowExecutableStep[], + runId: string, + emailContext: RunEmailContext, + logContext: ReturnType +): Promise<{ bail: true } | { bail: false; stepResults: TWorkflowStepResult[]; failure: string | null }> => { + const stepResults: TWorkflowStepResult[] = []; + + for (const [index, step] of steps.entries()) { + const result = await runStep(step, index + 1, runId, emailContext, logContext); + if (result === STEP_BAIL) { + return { bail: true }; + } + stepResults.push(result); + + if (result.status === "failed") { + return { bail: false, stepResults, failure: result.error ?? "Workflow step failed" }; + } + } + + return { bail: false, stepResults, failure: null }; +}; + +/** Walks the claimed run to completion, throwing `WorkflowStepFailedError` (with the trace) on a failed step. */ +const executeClaimedRun = async ( + run: { id: string; workflowVersion: { definition: unknown } | null; workflow: { definition: unknown } }, + workspaceId: string, + triggerPayload: TWorkflowTriggerRunPayload, + logContext: ReturnType +): Promise => { + const definition = resolveExecutableDefinition(run); + const steps = planExecutableSteps(definition); + + const emailContext = await loadRunEmailContext(triggerPayload, workspaceId, steps); + const outcome = await runSteps(steps, run.id, emailContext, logContext); + + if (outcome.bail) { + // Another live delivery / stalled attempt owns this run; leave it `running` for the owner to finish. + logger.info(logContext, "Workflow run owned by another delivery; leaving it running without finalizing"); + return; + } + + const runData: TWorkflowRunData = ZWorkflowRunData.parse({ + trigger: triggerPayload, + steps: outcome.stepResults, + }); + + if (outcome.failure) { + throw new WorkflowStepFailedError(outcome.failure, runData); + } + + // Status-guarded terminal write: only finalize a run that is still `running`. A 0-row result means + // another delivery already finalized it — don't clobber its verdict. + const completed = await prisma.workflowRun.updateMany({ + where: { id: run.id, workspaceId, status: "running" }, + data: { status: "completed", finishedAt: new Date(), data: runData }, + }); + if (completed.count === 0) { + logger.info(logContext, "Workflow run already finalized by another delivery; skipping completion write"); + } +}; + +/** + * Handles a run-execution error. On a non-final attempt the run stays non-terminal (only the error + * trace is recorded) and the error is rethrown so BullMQ retries — this holds for both transient DB + * pool exhaustion and definitive execution failures. On the FINAL attempt (including the prod + * `maxAttempts:1` config, where the first attempt IS the final one) a terminal `failed` is recorded + * and the error is swallowed, so a pool-exhaustion or any other failure never strands the run in + * `running` with no retry left. + */ +const handleRunError = async ( + error: unknown, + runId: string, + workspaceId: string, + context: Parameters>[1], + logContext: ReturnType +): Promise => { + const runData = error instanceof WorkflowStepFailedError ? error.runData : undefined; + const isFinalAttempt = context.attempt >= context.maxAttempts; + + if (!isFinalAttempt) { + // Retryable: don't write a terminal status (would defeat the retry via the terminal-skip guard). + // Record the error trace for observability, then rethrow so BullMQ retries the whole job. + if (isDatabasePoolExhaustionError(error)) { + logger.warn({ ...logContext, err: error }, "Workflow run job hit database pool exhaustion; will retry"); + } + await recordRunFailure(runId, workspaceId, error, runData, false, logContext); + throw toError(error, "Workflow run job failed"); + } + + // Final attempt: commit a terminal `failed` and swallow. Pool exhaustion on the last attempt must be + // recorded here rather than rethrown into the void, or the run would stay stuck `running` forever. + await recordRunFailure(runId, workspaceId, error, runData, true, logContext); + logger.error({ ...logContext, err: error }, "Workflow run job failed after final attempt"); +}; + +/** + * Apps/web handler for the `workflow-run.process` BullMQ job. Loads the run (tenant-scoped by + * `workspaceId`), walks its executable trigger → `send_email` graph, sends each email, persists + * per-step `WorkflowRunLog` rows plus the mirrored `data.steps`, and transitions the run + * `queued → running → completed|failed`. + * + * Tenant-scoped: every read and write is constrained by `data.workspaceId`; a run that does not match + * the job's workspace is treated as not found and the job is dropped (no retry). + * + * Replay/retry-safe (at-most-once email delivery): a run already terminal is a no-op; the + * `queued → running` claim is a conditional `updateMany` so concurrent deliveries can't both process + * it; and beneath that, each step is claimed BEFORE its send via a `(runId, stepId)`-unique + * `WorkflowRunLog` row — a `succeeded` step resumes, a `running` (already-attempted) step is skipped + * (never re-sent), and only a definitively `failed` step is re-sent (it never went out). On a non-final + * attempt any failure (incl. DB pool exhaustion) is rethrown so BullMQ retries; on the final attempt a + * terminal `failed` is recorded and swallowed so the run never stays stuck `running`. + */ +export const processWorkflowRunJob: JobHandler = async (data, context) => { + const logContext = getWorkflowRunLogContext(data, context); + + const run = await prisma.workflowRun.findFirst({ + where: { id: data.workflowRunId, workspaceId: data.workspaceId }, + select: workflowRunSelect, + }); + + if (!run) { + // Missing OR cross-workspace: the row is the durable source of truth and a tenant mismatch must + // never be retried into a foreign workspace. Drop the job either way. + logger.error(logContext, "Workflow run not found for workspace; dropping job"); + return; + } + + if (TERMINAL_STATUSES.has(run.status)) { + logger.info({ ...logContext, status: run.status }, "Workflow run already terminal; skipping"); + return; + } + + try { + const triggerPayload = ZWorkflowTriggerRunPayload.parse(run.triggerPayload); + + const claimed = await claimRun(run, data.workspaceId, context.attempt, logContext); + if (!claimed) { + return; + } + + await executeClaimedRun(run, data.workspaceId, triggerPayload, logContext); + } catch (error) { + await handleRunError(error, run.id, data.workspaceId, context, logContext); + } +}; + +/** + * Persists the failure trace on the run, tenant-scoped by `workspaceId`. On the final attempt this + * commits the terminal `failed` status + `finishedAt`; on earlier attempts it records only + * `error`/`lastErrorAt`/`attempt` and KEEPS the run non-terminal (`running`) so BullMQ retries are not + * defeated by the terminal-skip guard. Best-effort: a persistence failure here is logged but never + * masks the original execution error the caller is about to rethrow/swallow. + */ +const recordRunFailure = async ( + runId: string, + workspaceId: string, + error: unknown, + runData: TWorkflowRunData | undefined, + isFinalAttempt: boolean, + logContext: ReturnType +): Promise => { + const now = new Date(); + try { + // Status-guarded: only touch a run that is still non-terminal. A 0-row result means another delivery + // already finalized it (completed/failed) — don't clobber that verdict with a stale failure. + const updated = await prisma.workflowRun.updateMany({ + where: { id: runId, workspaceId, status: { notIn: [...TERMINAL_STATUS_LIST] } }, + data: { + error: toError(error, "Workflow run job failed").message, + lastErrorAt: now, + ...(isFinalAttempt ? { status: "failed", finishedAt: now } : {}), + ...(runData ? { data: runData } : {}), + }, + }); + if (updated.count === 0) { + logger.info( + { ...logContext }, + "Workflow run already finalized by another delivery; skipping failure write" + ); + } + } catch (persistError) { + logger.error({ ...logContext, err: persistError }, "Failed to persist workflow run failure state"); + } +}; diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.test.ts new file mode 100644 index 000000000000..0bc806684fab --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { processWorkflowRunReconcileJob } from "./process-workflow-run-reconcile-job"; + +const { + reconcileOrphanedWorkflowRuns, + reconcileStuckRunningWorkflowRuns, + dispatchWorkflowRunViaJobs, + info, + debug, +} = vi.hoisted(() => ({ + reconcileOrphanedWorkflowRuns: vi.fn(), + reconcileStuckRunningWorkflowRuns: vi.fn(), + dispatchWorkflowRunViaJobs: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("./reconcile-orphaned-runs", () => ({ reconcileOrphanedWorkflowRuns })); +vi.mock("./reconcile-stuck-running-runs", () => ({ reconcileStuckRunningWorkflowRuns })); +vi.mock("./dispatch", () => ({ dispatchWorkflowRunViaJobs })); +vi.mock("@formbricks/logger", () => ({ logger: { info, debug } })); + +const context = { + attempt: 1, + jobId: "job_1", + jobName: "workflow-run.reconcile", + maxAttempts: 3, + queueName: "background-jobs", +}; + +const data = { scope: "global" } as const; + +const noQueuedWork = { scanned: 0, redispatched: 0, agedOutFailed: 0 }; +const noStuckWork = { scanned: 0, recovered: 0, stepsSkipped: 0 }; + +beforeEach(() => { + vi.clearAllMocks(); + reconcileOrphanedWorkflowRuns.mockResolvedValue(noQueuedWork); + reconcileStuckRunningWorkflowRuns.mockResolvedValue(noStuckWork); +}); + +describe("processWorkflowRunReconcileJob", () => { + test("runs both sweeps against one shared clock (queued sweep gets the real dispatch port)", async () => { + await processWorkflowRunReconcileJob(data, context); + + expect(reconcileOrphanedWorkflowRuns).toHaveBeenCalledWith({ + dispatch: dispatchWorkflowRunViaJobs, + now: expect.any(Date), + logContext: expect.objectContaining({ + jobId: "job_1", + jobName: "workflow-run.reconcile", + queueName: "background-jobs", + scope: "global", + }), + }); + expect(reconcileStuckRunningWorkflowRuns).toHaveBeenCalledWith({ + now: expect.any(Date), + logContext: expect.objectContaining({ jobId: "job_1", scope: "global" }), + }); + // Both sweeps share the same clock instance. + const queuedNow = reconcileOrphanedWorkflowRuns.mock.calls[0][0].now; + const stuckNow = reconcileStuckRunningWorkflowRuns.mock.calls[0][0].now; + expect(stuckNow).toBe(queuedNow); + }); + + test("logs at info when the queued sweep re-dispatched, carrying both sweep results", async () => { + reconcileOrphanedWorkflowRuns.mockResolvedValue({ scanned: 3, redispatched: 2, agedOutFailed: 0 }); + + await processWorkflowRunReconcileJob(data, context); + + expect(info).toHaveBeenCalledTimes(1); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + queued: { scanned: 3, redispatched: 2, agedOutFailed: 0 }, + stuckRunning: noStuckWork, + }), + expect.any(String) + ); + expect(debug).not.toHaveBeenCalled(); + }); + + test("logs at info when only aged-out (queued) runs were failed", async () => { + reconcileOrphanedWorkflowRuns.mockResolvedValue({ scanned: 1, redispatched: 0, agedOutFailed: 1 }); + + await processWorkflowRunReconcileJob(data, context); + + expect(info).toHaveBeenCalledTimes(1); + expect(debug).not.toHaveBeenCalled(); + }); + + test("logs at info when only the stuck-running sweep recovered", async () => { + reconcileStuckRunningWorkflowRuns.mockResolvedValue({ scanned: 1, recovered: 1, stepsSkipped: 2 }); + + await processWorkflowRunReconcileJob(data, context); + + expect(info).toHaveBeenCalledTimes(1); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + queued: noQueuedWork, + stuckRunning: { scanned: 1, recovered: 1, stepsSkipped: 2 }, + }), + expect.any(String) + ); + expect(debug).not.toHaveBeenCalled(); + }); + + test("logs at debug when both sweeps found nothing", async () => { + await processWorkflowRunReconcileJob(data, context); + + expect(debug).toHaveBeenCalledTimes(1); + expect(info).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.ts new file mode 100644 index 000000000000..d3684ec83add --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job.ts @@ -0,0 +1,51 @@ +import "server-only"; +import { type JobHandler, type TWorkflowRunReconcileJobData } from "@formbricks/jobs"; +import { logger } from "@formbricks/logger"; +import { dispatchWorkflowRunViaJobs } from "./dispatch"; +import { reconcileOrphanedWorkflowRuns } from "./reconcile-orphaned-runs"; +import { reconcileStuckRunningWorkflowRuns } from "./reconcile-stuck-running-runs"; + +/** + * Apps/web handler for the recurring `workflow-run.reconcile` BullMQ job. Runs both runner + * reconcilers against one shared clock: the **producer-side** sweep re-dispatches `queued` runs whose + * dispatch never landed (idempotent, via the real BullMQ port), and the **execution-side** sweep + * marks stale `running` runs `failed` (crash between step claim and send). Logs a summary only when it + * actually acted, to avoid noise from the (common) empty sweep. A thrown scan fails the job so BullMQ + * retries; the recurring schedule is the backstop. + */ +export const processWorkflowRunReconcileJob: JobHandler = async ( + data, + context +) => { + const logContext = { + attempt: context.attempt, + jobId: context.jobId, + jobName: context.jobName, + maxAttempts: context.maxAttempts, + queueName: context.queueName, + scope: data.scope, + }; + + const now = new Date(); + + // Independent sweeps (disjoint status sets: `queued` vs `running`), so run them concurrently. If one + // rejects the job fails and BullMQ retries; the sibling's work is not cancelled (its writes are + // idempotent/guarded, so a retried tick redoing them is harmless). + const [queued, stuckRunning] = await Promise.all([ + reconcileOrphanedWorkflowRuns({ dispatch: dispatchWorkflowRunViaJobs, now, logContext }), + reconcileStuckRunningWorkflowRuns({ now, logContext }), + ]); + + const acted = queued.redispatched > 0 || queued.agedOutFailed > 0 || stuckRunning.recovered > 0; + + if (acted) { + // Neutral wording: the action may be a re-dispatch, an age-out, or a stuck-running recovery — the + // per-sweep counts in the payload carry the specifics, so the message claims none of them. + logger.info({ ...logContext, queued, stuckRunning }, "Workflow run reconciliation acted"); + } else { + logger.debug( + { ...logContext, queued, stuckRunning }, + "Workflow run reconciliation found nothing to reconcile" + ); + } +}; diff --git a/apps/web/modules/ee/workflows/lib/runner/reconcile-constants.ts b/apps/web/modules/ee/workflows/lib/runner/reconcile-constants.ts new file mode 100644 index 000000000000..c66a8eb291e4 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/reconcile-constants.ts @@ -0,0 +1,32 @@ +// Tuning for the workflow-run orphan reconciler (workflow-run.reconcile recurring job). + +/** Recurring-schedule identity (a single global sweep, mirroring survey-scheduling). */ +export const WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE = "global"; +export const WORKFLOW_RUN_RECONCILE_SCHEDULE_ID = "workflow-run-reconcile"; + +/** How often the sweep runs. Orphans are rare, so a few minutes balances recovery latency vs. churn. */ +export const WORKFLOW_RUN_RECONCILE_INTERVAL_MS = 3 * 60 * 1000; + +/** + * Grace window: a run younger than this may still be mid-dispatch in the producer (or its response- + * pipeline retry), so it is not yet considered orphaned. Must exceed normal dispatch + pipeline-retry + * latency. + */ +export const WORKFLOW_RUN_ORPHAN_MIN_AGE_MS = 2 * 60 * 1000; + +/** + * Ceiling: a run still `queued` past this is treated as permanently un-dispatchable and marked + * `failed` (bounding the re-dispatch loop and surfacing the stuck run) instead of being re-dispatched + * forever. + */ +export const WORKFLOW_RUN_ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +/** + * A run still `running` with no executor writes for this long is treated as abandoned mid-execution + * (crash between step claim and send) and recovered → `failed`. Healthy runs finish in seconds and the + * full retry/backoff window is ≲1 min, so 1h is generous enough that no live owner can be racing. + */ +export const WORKFLOW_RUN_STUCK_RUNNING_MAX_AGE_MS = 60 * 60 * 1000; + +/** Max orphans handled per tick. A larger backlog drains over subsequent ticks (re-dispatch is idempotent). */ +export const WORKFLOW_RUN_RECONCILE_BATCH_SIZE = 250; diff --git a/apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.test.ts b/apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.test.ts new file mode 100644 index 000000000000..30f01375287b --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { reconcileOrphanedWorkflowRuns } from "./reconcile-orphaned-runs"; + +const { findMany, updateMany } = vi.hoisted(() => ({ findMany: vi.fn(), updateMany: vi.fn() })); +const { markDispatched } = vi.hoisted(() => ({ markDispatched: vi.fn() })); +const { warn, error, info, debug } = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { workflowRun: { findMany, updateMany } }, +})); +vi.mock("@formbricks/logger", () => ({ logger: { warn, error, info, debug } })); +vi.mock("./mark-dispatched", () => ({ markWorkflowRunDispatched: markDispatched })); + +const NOW = new Date("2026-07-01T12:00:00.000Z"); +// Grace window is 2 min, ceiling is 24 h (see reconcile-constants). +const orphaned = new Date(NOW.getTime() - 10 * 60 * 1000); // 10 min old → eligible orphan +const ancient = new Date(NOW.getTime() - 25 * 60 * 60 * 1000); // 25 h old → past the ceiling + +const dispatch = + vi.fn<(input: { workflowRunId: string; workflowId: string; workspaceId: string }) => Promise>(); + +// dispatchedAt defaults to null: a genuine never-handed-off producer orphan. +const runRow = (id: string, createdAt: Date, dispatchedAt: Date | null = null) => ({ + id, + workflowId: `wf_${id}`, + workspaceId: `ws_${id}`, + createdAt, + dispatchedAt, +}); + +const reconcile = () => reconcileOrphanedWorkflowRuns({ dispatch, now: NOW }); + +beforeEach(() => { + vi.clearAllMocks(); + dispatch.mockResolvedValue(undefined); + updateMany.mockResolvedValue({ count: 1 }); + markDispatched.mockResolvedValue(undefined); +}); + +describe("reconcileOrphanedWorkflowRuns", () => { + test("re-dispatches queued orphans older than the grace window and stamps the never-dispatched ones", async () => { + findMany.mockResolvedValue([runRow("run1", orphaned), runRow("run2", orphaned)]); + + const result = await reconcile(); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith({ + workflowRunId: "run1", + workflowId: "wf_run1", + workspaceId: "ws_run1", + }); + // Both were dispatchedAt: null → genuine orphans → stamped with the injected clock. + expect(markDispatched).toHaveBeenCalledTimes(2); + expect(markDispatched).toHaveBeenCalledWith( + "run1", + "ws_run1", + NOW, + expect.objectContaining({ workflowRunId: "run1" }) + ); + expect(updateMany).not.toHaveBeenCalled(); + expect(result).toEqual({ scanned: 2, redispatched: 2, agedOutFailed: 0, neverDispatched: 2 }); + }); + + test("re-dispatches an already-dispatched-but-still-queued run without counting or re-stamping it", async () => { + findMany.mockResolvedValue([runRow("lagging", orphaned, new Date(NOW.getTime() - 5 * 60 * 1000))]); + + const result = await reconcile(); + + expect(dispatch).toHaveBeenCalledTimes(1); // still re-dispatched (idempotent, preserves Redis-loss recovery) + expect(markDispatched).not.toHaveBeenCalled(); // dispatchedAt already set → no churn on the marker + expect(result).toEqual({ scanned: 1, redispatched: 1, agedOutFailed: 0, neverDispatched: 0 }); + }); + + test("scopes the scan to queued, non-dry-run runs past the grace window, selecting dispatchedAt, oldest first and bounded", async () => { + findMany.mockResolvedValue([]); + + await reconcile(); + + const args = findMany.mock.calls[0][0]; + expect(args.where).toMatchObject({ status: "queued", isDryRun: false }); + expect(args.where.createdAt.lt).toEqual(new Date(NOW.getTime() - 2 * 60 * 1000)); + expect(args.select).toMatchObject({ dispatchedAt: true }); + expect(args.orderBy).toEqual({ createdAt: "asc" }); + expect(args.take).toBe(250); + }); + + test("marks a run past the age ceiling as failed (status-guarded) and never re-dispatches it", async () => { + findMany.mockResolvedValue([runRow("stuck", ancient)]); + + const result = await reconcile(); + + expect(dispatch).not.toHaveBeenCalled(); + expect(markDispatched).not.toHaveBeenCalled(); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "stuck", workspaceId: "ws_stuck", status: "queued" }, + data: expect.objectContaining({ status: "failed", finishedAt: NOW, lastErrorAt: NOW }), + }); + expect(result).toEqual({ scanned: 1, redispatched: 0, agedOutFailed: 1, neverDispatched: 0 }); + }); + + test("does not count an aged-out run that lost the status-guard race", async () => { + findMany.mockResolvedValue([runRow("stuck", ancient)]); + updateMany.mockResolvedValue({ count: 0 }); // concurrently claimed queued → running + + const result = await reconcile(); + + expect(result).toEqual({ scanned: 1, redispatched: 0, agedOutFailed: 0, neverDispatched: 0 }); + }); + + test("isolates a per-run dispatch failure and continues the sweep", async () => { + findMany.mockResolvedValue([runRow("bad", orphaned), runRow("good", orphaned)]); + dispatch.mockRejectedValueOnce(new Error("redis down")); + + const result = await reconcile(); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(error).toHaveBeenCalledTimes(1); + // Only the successfully re-dispatched "good" run is counted + stamped. + expect(markDispatched).toHaveBeenCalledTimes(1); + expect(result).toEqual({ scanned: 2, redispatched: 1, agedOutFailed: 0, neverDispatched: 1 }); + }); + + test("does nothing when there are no orphans", async () => { + findMany.mockResolvedValue([]); + + const result = await reconcile(); + + expect(dispatch).not.toHaveBeenCalled(); + expect(updateMany).not.toHaveBeenCalled(); + expect(markDispatched).not.toHaveBeenCalled(); + expect(result).toEqual({ scanned: 0, redispatched: 0, agedOutFailed: 0, neverDispatched: 0 }); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.ts b/apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.ts new file mode 100644 index 000000000000..6510ba2aa29f --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/reconcile-orphaned-runs.ts @@ -0,0 +1,146 @@ +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { type DispatchWorkflowRun } from "./dispatch"; +import { markWorkflowRunDispatched } from "./mark-dispatched"; +import { + WORKFLOW_RUN_ORPHAN_MAX_AGE_MS, + WORKFLOW_RUN_ORPHAN_MIN_AGE_MS, + WORKFLOW_RUN_RECONCILE_BATCH_SIZE, +} from "./reconcile-constants"; + +interface ReconcileOrphanedWorkflowRunsInput { + /** Injected so the reconciler stays backend-neutral and unit-testable (same port the producer uses). */ + dispatch: DispatchWorkflowRun; + /** Injected clock for deterministic age-threshold tests; defaults to wall-clock. */ + now?: Date; + logContext?: Record; +} + +export interface ReconcileOrphanedWorkflowRunsResult { + scanned: number; + redispatched: number; + agedOutFailed: number; + /** + * Of the re-dispatched runs, how many had never been handed off (`dispatchedAt IS NULL`) — genuine + * producer-side orphans. The remainder were already dispatched but still `queued` (executor lag or a + * lost job), re-dispatched idempotently. Lets operators tell a producer-dispatch problem from an + * executor-throughput one. + */ + neverDispatched: number; +} + +/** + * Periodic reconciler for the **producer-side orphan**: a `WorkflowRun` persisted `queued` whose + * dispatch never landed (the process died or the Redis enqueue failed after the row was committed — + * see `createAndDispatchWorkflowRun`). It finds `queued` runs older than the grace window and + * re-dispatches them through the same idempotent port (`dispatchWorkflowRunViaJobs`, deterministic + * `jobId = run.id`): a no-op if a job still exists, a genuine recovery if it was lost. + * + * - **Scope:** only real runs (`isDryRun: false`); `running` is the sibling sweep's concern + * (`reconcile-stuck-running-runs.ts`), and `completed` / `failed` / `canceled` are terminal and never + * touched (execution-failure retry is the executor's concern, not the reconciler's). + * - **Ceiling:** a run still `queued` past `WORKFLOW_RUN_ORPHAN_MAX_AGE_MS` is treated as permanently + * un-dispatchable and marked `failed` (bounds the loop; surfaces the stuck run) instead of being + * re-dispatched forever. + * - **Re-dispatch vs. ceiling:** the deterministic `jobId` makes re-dispatch a no-op while a job of + * that id is still retained, including a terminally `failed` one. Retention is not absolute: + * `removeOnFail` is age 7d *and* `count: 5000` (`packages/jobs/src/constants.ts`), so under a large + * failure backlog a failed job can be count-evicted well inside the 24h ceiling, and a later + * re-dispatch then genuinely re-runs it instead of leaving it to age out. That re-run is safe — + * per-step send idempotency plus the terminal-status guard make it a clean resume or a no-op. So the + * boundary is: re-dispatch recovers a run whose job is gone (never created, or evicted) and no-ops + * while the job is retained, with the ceiling as the backstop — not an absolute 7d-vs-24h rule. (The + * executor marks its own runs terminal on the final attempt, so a job coexisting with a still-`queued` + * row is only reachable when claiming never reached the `queued → running` write.) + * - **Bounded:** one batch per tick; a larger backlog drains over subsequent ticks (re-dispatch is + * idempotent, so re-selecting the same still-`queued` run next tick is harmless). + * - **Safe under concurrency:** every write is status-guarded (`status: "queued"`) so a run claimed + * `queued → running` between the scan and the write is never clobbered; and re-dispatch idempotency + * means overlapping sweeps cannot double-dispatch. + * - **Dispatch marker (ENG-1658):** the scan still re-dispatches *every* eligible `queued` run (so a + * Redis-loss recovery re-creates all lost jobs via the idempotent port — the `dispatchedAt IS NULL` + * fact is deliberately *not* used to filter the scan, which would strand dispatched-but-lost runs). + * Instead `dispatchedAt` distinguishes outcomes: a run with `dispatchedAt IS NULL` is a genuine + * never-handed-off producer orphan (counted as `neverDispatched`, and stamped on this first + * re-dispatch); one with `dispatchedAt` set was already handed off (executor lag or a lost job) and is + * re-dispatched idempotently but neither counted nor re-stamped. Gives operators a producer-dispatch + * vs executor-throughput signal from a DB fact. + * - **Isolated:** one run's failure is logged and skipped so it never aborts the sweep. + */ +export const reconcileOrphanedWorkflowRuns = async ({ + dispatch, + now = new Date(), + logContext, +}: ReconcileOrphanedWorkflowRunsInput): Promise => { + const minAgeThreshold = new Date(now.getTime() - WORKFLOW_RUN_ORPHAN_MIN_AGE_MS); + const maxAgeThreshold = new Date(now.getTime() - WORKFLOW_RUN_ORPHAN_MAX_AGE_MS); + + // Index-backed by @@index([status, createdAt]); oldest first so a backlog drains deterministically. + const orphans = await prisma.workflowRun.findMany({ + where: { + status: "queued", + isDryRun: false, + createdAt: { lt: minAgeThreshold }, + }, + orderBy: { createdAt: "asc" }, + take: WORKFLOW_RUN_RECONCILE_BATCH_SIZE, + select: { id: true, workflowId: true, workspaceId: true, createdAt: true, dispatchedAt: true }, + }); + + let redispatched = 0; + let agedOutFailed = 0; + let neverDispatched = 0; + + for (const orphan of orphans) { + const runLogContext = { + ...logContext, + workflowRunId: orphan.id, + workflowId: orphan.workflowId, + workspaceId: orphan.workspaceId, + }; + + try { + if (orphan.createdAt < maxAgeThreshold) { + // Past the ceiling: never re-dispatch forever. Tenant- and status-guarded so a concurrent + // claim (queued → running) wins and we never touch a foreign workspace's row. + const failed = await prisma.workflowRun.updateMany({ + where: { id: orphan.id, workspaceId: orphan.workspaceId, status: "queued" }, + data: { + status: "failed", + error: "Workflow run was never dispatched and exceeded the reconcile age ceiling", + lastErrorAt: now, + finishedAt: now, + }, + }); + if (failed.count > 0) { + agedOutFailed += 1; + logger.warn( + { ...runLogContext, createdAt: orphan.createdAt }, + "Orphaned workflow run exceeded reconcile age ceiling; marked failed" + ); + } + continue; + } + + await dispatch({ + workflowRunId: orphan.id, + workflowId: orphan.workflowId, + workspaceId: orphan.workspaceId, + }); + redispatched += 1; + + if (orphan.dispatchedAt === null) { + // Genuine producer orphan (dispatch never landed): count it, and record this first successful + // hand-off as a durable DB fact. Already-dispatched-but-still-queued runs (dispatchedAt set) are + // re-dispatched idempotently above but neither counted nor re-stamped — no churn on the marker. + neverDispatched += 1; + await markWorkflowRunDispatched(orphan.id, orphan.workspaceId, now, runLogContext); + } + } catch (error) { + // Isolate per run: a single re-dispatch/mark failure must not abort the sweep; the next tick retries. + logger.error({ ...runLogContext, err: error }, "Failed to reconcile orphaned workflow run"); + } + } + + return { scanned: orphans.length, redispatched, agedOutFailed, neverDispatched }; +}; diff --git a/apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.test.ts b/apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.test.ts new file mode 100644 index 000000000000..84e2ea2d4caf --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { reconcileStuckRunningWorkflowRuns } from "./reconcile-stuck-running-runs"; + +const { runFindMany, runUpdateMany, logUpdateMany } = vi.hoisted(() => ({ + runFindMany: vi.fn(), + runUpdateMany: vi.fn(), + logUpdateMany: vi.fn(), +})); +const { warn, error } = vi.hoisted(() => ({ warn: vi.fn(), error: vi.fn() })); + +vi.mock("@formbricks/database", () => ({ + prisma: { + workflowRun: { findMany: runFindMany, updateMany: runUpdateMany }, + workflowRunLog: { updateMany: logUpdateMany }, + }, +})); +vi.mock("@formbricks/logger", () => ({ logger: { warn, error } })); + +const NOW = new Date("2026-07-01T12:00:00.000Z"); +// Stale threshold is 1h (see reconcile-constants). 2h since the last executor write → abandoned. +const stale = new Date(NOW.getTime() - 2 * 60 * 60 * 1000); + +const runRow = (id: string, updatedAt: Date) => ({ + id, + workflowId: `wf_${id}`, + workspaceId: `ws_${id}`, + startedAt: new Date(updatedAt.getTime() - 60 * 1000), + updatedAt, +}); + +const reconcile = () => reconcileStuckRunningWorkflowRuns({ now: NOW }); + +beforeEach(() => { + vi.clearAllMocks(); + runUpdateMany.mockResolvedValue({ count: 1 }); // claimed the run for failure + logUpdateMany.mockResolvedValue({ count: 2 }); // two orphaned steps skipped +}); + +describe("reconcileStuckRunningWorkflowRuns", () => { + test("scopes the scan to running, non-dry-run runs stale past the threshold, oldest first and bounded", async () => { + runFindMany.mockResolvedValue([]); + + await reconcile(); + + const args = runFindMany.mock.calls[0][0]; + expect(args.where).toMatchObject({ status: "running", isDryRun: false }); + expect(args.where.updatedAt.lt).toEqual(new Date(NOW.getTime() - 60 * 60 * 1000)); + expect(args.orderBy).toEqual({ updatedAt: "asc" }); + expect(args.take).toBe(250); + }); + + test("recovers a stale run: marks it failed (status-guarded) and skips its orphaned running steps", async () => { + runFindMany.mockResolvedValue([runRow("run1", stale)]); + + const result = await reconcile(); + + expect(runUpdateMany).toHaveBeenCalledWith({ + where: { id: "run1", workspaceId: "ws_run1", status: "running" }, + data: expect.objectContaining({ status: "failed", lastErrorAt: NOW, finishedAt: NOW }), + }); + expect(logUpdateMany).toHaveBeenCalledWith({ + where: { runId: "run1", status: "running" }, + data: expect.objectContaining({ status: "skipped", finishedAt: NOW }), + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ scanned: 1, recovered: 1, stepsSkipped: 2 }); + }); + + test("leaves a run that lost the status-guard race untouched (owner finalized it concurrently)", async () => { + runFindMany.mockResolvedValue([runRow("run1", stale)]); + runUpdateMany.mockResolvedValue({ count: 0 }); // run no longer `running` + + const result = await reconcile(); + + expect(logUpdateMany).not.toHaveBeenCalled(); // never skip steps of a run we didn't claim + expect(warn).not.toHaveBeenCalled(); + expect(result).toEqual({ scanned: 1, recovered: 0, stepsSkipped: 0 }); + }); + + test("recovers a run that crashed before its first step was claimed (no running steps to skip)", async () => { + runFindMany.mockResolvedValue([runRow("run1", stale)]); + logUpdateMany.mockResolvedValue({ count: 0 }); + + const result = await reconcile(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ scanned: 1, recovered: 1, stepsSkipped: 0 }); + }); + + test("isolates a per-run recovery failure and continues the sweep", async () => { + runFindMany.mockResolvedValue([runRow("bad", stale), runRow("good", stale)]); + runUpdateMany.mockRejectedValueOnce(new Error("db down")).mockResolvedValue({ count: 1 }); + logUpdateMany.mockResolvedValue({ count: 1 }); + + const result = await reconcile(); + + expect(error).toHaveBeenCalledTimes(1); + expect(result).toEqual({ scanned: 2, recovered: 1, stepsSkipped: 1 }); + }); + + test("still counts the run as recovered when the step-skip write fails after a won claim", async () => { + // The documented cosmetic gap: run already marked failed, step flip dies. The failed run is + // unreachable (terminal-status guard), so the sweep just logs and moves on — no retry, no warn. + runFindMany.mockResolvedValue([runRow("run1", stale)]); + logUpdateMany.mockRejectedValueOnce(new Error("db down")); + + const result = await reconcile(); + + expect(error).toHaveBeenCalledTimes(1); + expect(warn).not.toHaveBeenCalled(); + expect(result).toEqual({ scanned: 1, recovered: 1, stepsSkipped: 0 }); + }); + + test("does nothing when there are no stuck running runs", async () => { + runFindMany.mockResolvedValue([]); + + const result = await reconcile(); + + expect(runUpdateMany).not.toHaveBeenCalled(); + expect(logUpdateMany).not.toHaveBeenCalled(); + expect(result).toEqual({ scanned: 0, recovered: 0, stepsSkipped: 0 }); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.ts b/apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.ts new file mode 100644 index 000000000000..cb503e988f39 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/runner/reconcile-stuck-running-runs.ts @@ -0,0 +1,134 @@ +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { + WORKFLOW_RUN_RECONCILE_BATCH_SIZE, + WORKFLOW_RUN_STUCK_RUNNING_MAX_AGE_MS, +} from "./reconcile-constants"; + +interface ReconcileStuckRunningWorkflowRunsInput { + /** Injected clock for deterministic staleness-threshold tests; defaults to wall-clock. */ + now?: Date; + logContext?: Record; +} + +export interface ReconcileStuckRunningWorkflowRunsResult { + scanned: number; + recovered: number; + stepsSkipped: number; +} + +/** + * Periodic reconciler for the **execution-side orphan**: a `WorkflowRun` stuck `running` after the + * executor crashed/stalled between claiming a step (`WorkflowRunLog` row → `running`, written BEFORE + * the send) and recording its result. Once that happens the run is wedged: every BullMQ redelivery + * sees the `running` step row and bails (`STEP_BAIL`, at-most-once — never re-send), the job completes + * without error so no retry fires, and nothing moves the run to a terminal state. This is the + * "future orphan-reconciler" the executor defers to (see `process-workflow-run-job.ts`). + * + * - **Staleness by `updatedAt`, not `startedAt`:** the executor never touches the run row between the + * claim and the terminal write, and a non-final failure bumps `updatedAt` while keeping status + * `running`. So "`running` with no `updatedAt` movement for `WORKFLOW_RUN_STUCK_RUNNING_MAX_AGE_MS`" + * means no executor activity at all — including no in-flight retry (the whole attempts×backoff + * window is ≲1 min, far inside the 1h threshold), so no live owner can be racing. + * - **Recover, don't resume:** a stuck run cannot be re-run — its `running` step rows block every + * claim path (a `running` step bails; after this sweep a `skipped` step matches no re-claim + * condition either). The send may or may not have gone out, so we surface the failure rather than + * guess. Recovery = mark the run `failed` (distinct error) so redeliveries no-op on the executor's + * pre-claim terminal-status check. + * - **Steps → `skipped`, never `failed`:** the executor re-claims a `failed` step and re-sends it, so + * `failed` here would re-arm a double-send and break at-most-once. `skipped` records "outcome + * unknown, not retried" and matches no claim path. + * - **Run-first, then steps:** the run is claimed-for-failure with a status-guarded `updateMany` + * (`status: "running"`); only when we win (`count > 0`) do we touch its steps — so we never skip + * the steps of a run a live owner still holds. A crash between the two writes leaves a `failed` run + * with `running` step rows: cosmetic only, since the terminal-status guard makes those steps + * unreachable forever. + * - **Benign overwrite race:** `sendOwnedStep` finalizes a step with an unconditional write by + * `(runId, stepId)`. If an impossibly-slow owner finalizes a step *after* we skipped it, its true + * outcome overwrites our `skipped` — harmless, and combined with the guarded run terminal writes + * (which reject clobbering a `failed` run) every interleaving still yields zero double-sends. + * - **`data.steps` mirror not reconstructed:** the run's denormalized step mirror is left as-is; the + * authoritative per-step record is the `WorkflowRunLog` rows this sweep marks `skipped`. + * - **Bounded / isolated:** one batch per tick, oldest-first; one run's failure is logged and skipped + * so it never aborts the sweep. Running-state cardinality is tiny, so a backlog is not expected. + */ +export const reconcileStuckRunningWorkflowRuns = async ({ + now = new Date(), + logContext, +}: ReconcileStuckRunningWorkflowRunsInput): Promise => { + const staleThreshold = new Date(now.getTime() - WORKFLOW_RUN_STUCK_RUNNING_MAX_AGE_MS); + + // Index-assisted by @@index([status, createdAt]) on the `status = 'running'` prefix; running-state + // cardinality is tiny (executions are seconds long), so the residual `updatedAt` filter + sort are + // cheap. Oldest first so the longest-stuck runs are surfaced first. + const stuck = await prisma.workflowRun.findMany({ + where: { + status: "running", + isDryRun: false, + updatedAt: { lt: staleThreshold }, + }, + orderBy: { updatedAt: "asc" }, + take: WORKFLOW_RUN_RECONCILE_BATCH_SIZE, + select: { id: true, workflowId: true, workspaceId: true, startedAt: true, updatedAt: true }, + }); + + let recovered = 0; + let stepsSkipped = 0; + + for (const run of stuck) { + const runLogContext = { + ...logContext, + workflowRunId: run.id, + workflowId: run.workflowId, + workspaceId: run.workspaceId, + }; + + try { + // Claim-for-failure, tenant- and status-guarded: only a run still `running` is ours to recover. + // A 0-row result means the owner finalized it between the scan and here — leave its verdict alone. + const failed = await prisma.workflowRun.updateMany({ + where: { id: run.id, workspaceId: run.workspaceId, status: "running" }, + data: { + status: "failed", + error: "Workflow run was abandoned mid-execution (stale running) and recovered by the reconciler", + lastErrorAt: now, + finishedAt: now, + }, + }); + + if (failed.count === 0) { + continue; + } + + recovered += 1; + + // Only after we own the failure: flip the orphaned in-flight steps to `skipped` (never `failed`, + // which the executor would re-claim and re-send). 0 is legitimate — a crash before the first + // step was ever claimed leaves no `running` step rows. + const skipped = await prisma.workflowRunLog.updateMany({ + where: { runId: run.id, status: "running" }, + data: { + status: "skipped", + error: "Step outcome unknown: run abandoned mid-execution; not retried (at-most-once)", + finishedAt: now, + }, + }); + stepsSkipped += skipped.count; + + logger.warn( + { + ...runLogContext, + startedAt: run.startedAt, + updatedAt: run.updatedAt, + stepsSkipped: skipped.count, + }, + "Stuck running workflow run recovered; marked failed" + ); + } catch (error) { + // Isolate per run: a single recovery failure must not abort the sweep; the next tick retries. + logger.error({ ...runLogContext, err: error }, "Failed to recover stuck running workflow run"); + } + } + + return { scanned: stuck.length, recovered, stepsSkipped }; +}; diff --git a/apps/web/modules/ee/workflows/lib/trigger-ending-cards.test.ts b/apps/web/modules/ee/workflows/lib/trigger-ending-cards.test.ts new file mode 100644 index 000000000000..108efa739608 --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/trigger-ending-cards.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import type { TWorkflowDefinition } from "@formbricks/workflows"; +import { reconcileDefinitionEndingCardIds, reconcileEndingCardIds } from "./trigger-ending-cards"; + +const buildDefinition = (endingCardIds: string[]): TWorkflowDefinition => + ({ + schemaVersion: 1, + trigger: { + id: "trigger_1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "survey_1", endingCardIds }, + }, + nodes: [], + edges: [], + entryNodeId: "trigger_1", + }) as unknown as TWorkflowDefinition; + +describe("reconcileEndingCardIds", () => { + test("keeps ids that still exist on the survey, in their stored order", () => { + expect(reconcileEndingCardIds(["end_2", "end_1"], ["end_1", "end_2", "end_3"])).toEqual({ + endingCardIds: ["end_2", "end_1"], + removedEndingCardIds: [], + }); + }); + + test("separates ids whose ending was deleted from the survey", () => { + expect(reconcileEndingCardIds(["deleted_1", "end_1", "deleted_2"], ["end_1"])).toEqual({ + endingCardIds: ["end_1"], + removedEndingCardIds: ["deleted_1", "deleted_2"], + }); + }); + + test("drops duplicates without reporting them as missing endings", () => { + expect(reconcileEndingCardIds(["end_1", "end_1", "gone", "gone"], ["end_1"])).toEqual({ + endingCardIds: ["end_1"], + removedEndingCardIds: ["gone"], + }); + }); + + test("removes everything when the survey has no endings left", () => { + expect(reconcileEndingCardIds(["end_1", "end_2"], [])).toEqual({ + endingCardIds: [], + removedEndingCardIds: ["end_1", "end_2"], + }); + }); + + test("is a no-op for an empty selection (= all endings)", () => { + expect(reconcileEndingCardIds([], ["end_1"])).toEqual({ + endingCardIds: [], + removedEndingCardIds: [], + }); + }); +}); + +describe("reconcileDefinitionEndingCardIds", () => { + test("returns null when the stored selection is already clean", () => { + expect(reconcileDefinitionEndingCardIds(buildDefinition(["end_1"]), ["end_1", "end_2"])).toBeNull(); + }); + + test("returns null without a definition or without a trigger", () => { + expect(reconcileDefinitionEndingCardIds(null, ["end_1"])).toBeNull(); + const triggerless = { ...buildDefinition([]), trigger: null } as unknown as TWorkflowDefinition; + expect(reconcileDefinitionEndingCardIds(triggerless, ["end_1"])).toBeNull(); + }); + + test("prunes stale ids into a new definition and leaves the original untouched", () => { + const definition = buildDefinition(["deleted_1", "end_1"]); + const result = reconcileDefinitionEndingCardIds(definition, ["end_1"]); + + expect(result?.removedEndingCardIds).toEqual(["deleted_1"]); + expect(result?.definition.trigger?.config.endingCardIds).toEqual(["end_1"]); + expect(result?.definition).not.toBe(definition); + expect(definition.trigger?.config.endingCardIds).toEqual(["deleted_1", "end_1"]); + }); + + test("reports a change for duplicate-only drift even though nothing is missing", () => { + const result = reconcileDefinitionEndingCardIds(buildDefinition(["end_1", "end_1"]), ["end_1"]); + + expect(result?.definition.trigger?.config.endingCardIds).toEqual(["end_1"]); + expect(result?.removedEndingCardIds).toEqual([]); + }); +}); diff --git a/apps/web/modules/ee/workflows/lib/trigger-ending-cards.ts b/apps/web/modules/ee/workflows/lib/trigger-ending-cards.ts new file mode 100644 index 000000000000..41c13f7e50cc --- /dev/null +++ b/apps/web/modules/ee/workflows/lib/trigger-ending-cards.ts @@ -0,0 +1,64 @@ +import type { TWorkflowDefinition } from "@formbricks/workflows"; + +export interface TEndingCardReconciliation { + /** Stored ids that still resolve to a survey ending, de-duplicated, order preserved. */ + endingCardIds: string[]; + /** Stored ids that no longer resolve to a survey ending. Excludes dropped duplicates — they + * aren't missing endings and would inflate the "N endings removed" message. */ + removedEndingCardIds: string[]; +} + +/** + * Reconciles a trigger's stored `endingCardIds` against the survey's current ending ids. Deleting + * an ending in the survey editor leaves its id on referencing triggers, which the enable pre-flight + * then rejects. Pure — callers pass the survey's ending ids; nothing here fetches. + */ +export const reconcileEndingCardIds = ( + storedEndingCardIds: readonly string[], + surveyEndingIds: readonly string[] +): TEndingCardReconciliation => { + const surveyEndingIdSet = new Set(surveyEndingIds); + const endingCardIds: string[] = []; + const removedEndingCardIds: string[] = []; + const seenEndingCardIds = new Set(); + + for (const endingCardId of storedEndingCardIds) { + if (seenEndingCardIds.has(endingCardId)) continue; + seenEndingCardIds.add(endingCardId); + + if (surveyEndingIdSet.has(endingCardId)) { + endingCardIds.push(endingCardId); + } else { + removedEndingCardIds.push(endingCardId); + } + } + + return { endingCardIds, removedEndingCardIds }; +}; + +/** + * Definition-level wrapper around {@link reconcileEndingCardIds}. Returns `null` when the stored + * selection is already clean, so callers can skip writing and not dirty the editor every render. + */ +export const reconcileDefinitionEndingCardIds = ( + definition: TWorkflowDefinition | null, + surveyEndingIds: readonly string[] +): { definition: TWorkflowDefinition; removedEndingCardIds: string[] } | null => { + const trigger = definition?.trigger; + if (!definition || !trigger) return null; + + const { endingCardIds, removedEndingCardIds } = reconcileEndingCardIds( + trigger.config.endingCardIds, + surveyEndingIds + ); + // Reconciling only drops entries (never adds/reorders), so equal length means already clean. + if (endingCardIds.length === trigger.config.endingCardIds.length) return null; + + return { + definition: { + ...definition, + trigger: { ...trigger, config: { ...trigger.config, endingCardIds } }, + }, + removedEndingCardIds, + }; +}; diff --git a/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.test.ts b/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.test.ts new file mode 100644 index 000000000000..7c1f1bd25b4f --- /dev/null +++ b/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.test.ts @@ -0,0 +1,161 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { type ReactNode, createElement } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useWorkflowSurveyEndings, useWorkflowSurveyOptions } from "./use-trigger-survey-picker"; + +const { listSurveysMock } = vi.hoisted(() => ({ listSurveysMock: vi.fn() })); + +vi.mock("@/modules/survey/list/lib/v3-surveys-client", () => ({ + listSurveys: listSurveysMock, +})); + +function createWrapper(queryClient: QueryClient) { + const Wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + Wrapper.displayName = "TriggerSurveyPickerTestWrapper"; + return Wrapper; +} + +const newQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const page = (data: Array<{ id: string; name: string }>, nextCursor: string | null) => ({ + data, + meta: { nextCursor }, +}); + +describe("useWorkflowSurveyOptions", () => { + beforeEach(() => { + listSurveysMock.mockReset(); + }); + + test("walks every cursor page and flattens the surveys", async () => { + listSurveysMock + .mockResolvedValueOnce(page([{ id: "s1", name: "One" }], "cursor-1")) + .mockResolvedValueOnce(page([{ id: "s2", name: "Two" }], null)); + + const { result } = renderHook(() => useWorkflowSurveyOptions("ws_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.options).toEqual([ + { id: "s1", name: "One" }, + { id: "s2", name: "Two" }, + ]); + expect(listSurveysMock).toHaveBeenCalledTimes(2); + expect(listSurveysMock).toHaveBeenLastCalledWith(expect.objectContaining({ cursor: "cursor-1" })); + }); + + test("truncates and warns when the cursor never exhausts", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + // Always returns a cursor -> the MAX_PAGES break must stop the loop. + listSurveysMock.mockResolvedValue(page([{ id: "s", name: "S" }], "always")); + + const { result } = renderHook(() => useWorkflowSurveyOptions("ws_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(listSurveysMock).toHaveBeenCalledTimes(20); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("truncated")); + warnSpy.mockRestore(); + }); +}); + +describe("useWorkflowSurveyEndings", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + + test("is disabled (no fetch) when surveyId is falsy", async () => { + const fetchMock = vi.mocked(global.fetch); + const { result } = renderHook(() => useWorkflowSurveyEndings(null), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.endings).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("derives labels for endScreen, redirectToUrl, and fallback endings", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: { + defaultLanguage: "en", + endings: [ + { id: "end-1", type: "endScreen", headline: { en: "

Thanks!

" } }, + // default-language slot missing -> falls back to any other string value + { id: "end-2", type: "endScreen", headline: { de: "Danke" } }, + { id: "end-3", type: "redirectToUrl", label: " Go home " }, + // unknown type -> falls back to the id + { id: "end-4", type: "other" }, + // endScreen whose headline has no usable string -> falls back to the id + { id: "end-5", type: "endScreen", headline: { en: " " } }, + // missing id -> filtered out entirely + { type: "endScreen", headline: { en: "no id" } }, + ], + }, + }) + ); + + const { result } = renderHook(() => useWorkflowSurveyEndings("survey_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.endings).toEqual([ + { id: "end-1", label: "Thanks!" }, + { id: "end-2", label: "Danke" }, + { id: "end-3", label: "Go home" }, + { id: "end-4", label: "end-4" }, + { id: "end-5", label: "end-5" }, + ]); + }); + + // Errors rather than resolving to []: an empty success is indistinguishable from "every ending + // was deleted", and callers that prune stored ids against this list would delete a valid + // selection. Landing in the error branch keeps them on the "leave it alone" path. + test("errors when endings is not an array", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { endings: "nope" } })); + + const { result } = renderHook(() => useWorkflowSurveyEndings("survey_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.isSuccess).toBe(false); + expect(result.current.endings).toEqual([]); + expect(result.current.resolvedSurveyId).toBeNull(); + }); + + test("throws a parsed error when the response is not ok", async () => { + const fetchMock = vi.mocked(global.fetch); + fetchMock.mockResolvedValueOnce(jsonResponse({ status: 404, detail: "Survey not found" }, 404)); + + const { result } = renderHook(() => useWorkflowSurveyEndings("survey_1"), { + wrapper: createWrapper(newQueryClient()), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.endings).toEqual([]); + }); +}); diff --git a/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.ts b/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.ts new file mode 100644 index 000000000000..4fc0a81c276d --- /dev/null +++ b/apps/web/modules/ee/workflows/list/hooks/use-trigger-survey-picker.ts @@ -0,0 +1,138 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getTextContent } from "@formbricks/types/surveys/validation"; +import { parseV3ApiError } from "@/modules/api/lib/v3-client"; +import { initialFilters } from "@/modules/survey/list/lib/constants"; +import { listSurveys } from "@/modules/survey/list/lib/v3-surveys-client"; + +// Picker needs every survey, so walk the v3 cursor list until exhausted. +const SURVEY_LIST_PAGE_SIZE = 100; +// Ceiling so a runaway cursor can't spin forever; covers 2,000 surveys, then the picker truncates. +const SURVEY_LIST_MAX_PAGES = 20; + +interface TWorkflowSurveyOption { + id: string; + name: string; +} + +interface TWorkflowSurveyEnding { + id: string; + label: string; +} + +// The v3 survey endpoint serializes i18n strings as a language-keyed map (e.g. +// `headline = { "en-US": "Thanks!" }`), not the canonical `headline.default` shape — so read the +// value under the survey's `defaultLanguage` (falling back to any string), stripped to plain text. +interface RawEnding { + id?: string; + type?: unknown; + headline?: Record; + label?: unknown; +} + +const pickDefaultLanguageString = (value: unknown, defaultLanguage: string): string | null => { + if (!value || typeof value !== "object") return null; + const map = value as Record; + const direct = map[defaultLanguage]; + if (typeof direct === "string" && direct.trim()) return direct; + for (const entry of Object.values(map)) { + if (typeof entry === "string" && entry.trim()) return entry; + } + return null; +}; + +const endingDisplayLabel = (raw: RawEnding, defaultLanguage: string): string => { + const id = typeof raw.id === "string" ? raw.id : ""; + if (raw.type === "endScreen") { + const headlineText = pickDefaultLanguageString(raw.headline, defaultLanguage); + if (headlineText) { + const stripped = getTextContent(headlineText); + if (stripped) return stripped; + } + } else if (raw.type === "redirectToUrl") { + const label = typeof raw.label === "string" ? raw.label.trim() : ""; + if (label) return label; + } + return id; +}; + +const isEndingArray = (value: unknown): value is RawEnding[] => + Array.isArray(value) && value.every((entry) => entry && typeof entry === "object"); + +export const useWorkflowSurveyOptions = (workspaceId: string) => { + const query = useQuery({ + queryKey: ["workflow-trigger", "surveys", workspaceId], + queryFn: async ({ signal }) => { + const options: TWorkflowSurveyOption[] = []; + let cursor: string | null = null; + let pages = 0; + do { + const page = await listSurveys({ + workspaceId, + limit: SURVEY_LIST_PAGE_SIZE, + cursor, + filters: initialFilters, + signal, + }); + for (const survey of page.data) { + options.push({ id: survey.id, name: survey.name }); + } + cursor = page.meta.nextCursor; + pages += 1; + if (pages >= SURVEY_LIST_MAX_PAGES && cursor) { + // eslint-disable-next-line no-console + console.warn( + `Workflow trigger survey picker truncated at ${SURVEY_LIST_MAX_PAGES * SURVEY_LIST_PAGE_SIZE} surveys.` + ); + break; + } + } while (cursor); + return options; + }, + }); + return { ...query, options: query.data ?? [] }; +}; + +export const useWorkflowSurveyEndings = (surveyId: string | null | undefined) => { + const query = useQuery({ + queryKey: ["workflow-trigger", "survey-endings", surveyId], + enabled: Boolean(surveyId), + queryFn: async ({ signal }): Promise<{ surveyId: string | null; endings: TWorkflowSurveyEnding[] }> => { + // Unreachable (`enabled` gates on a truthy id); keeps the resolved id non-optional for callers. + if (!surveyId) return { surveyId: null, endings: [] }; + + const response = await fetch(`/api/v3/surveys/${surveyId}`, { + method: "GET", + cache: "no-store", + signal, + }); + if (!response.ok) { + throw await parseV3ApiError(response); + } + const body = (await response.json()) as { + data: { defaultLanguage?: unknown; endings: unknown }; + }; + const defaultLanguage = + typeof body.data.defaultLanguage === "string" && body.data.defaultLanguage.length > 0 + ? body.data.defaultLanguage + : "default"; + // Throw on a malformed shape rather than returning an empty list: a "successful" empty result + // is indistinguishable from "all endings deleted" and would trigger a destructive auto-prune. + if (!isEndingArray(body.data.endings)) { + throw new Error(`Unexpected survey endings response shape for survey ${surveyId}`); + } + const endings: TWorkflowSurveyEnding[] = body.data.endings + .filter((raw): raw is RawEnding & { id: string } => typeof raw.id === "string" && raw.id.length > 0) + .map((raw) => ({ id: raw.id, label: endingDisplayLabel(raw, defaultLanguage) })); + return { surveyId, endings }; + }, + }); + return { + ...query, + endings: query.data?.endings ?? [], + // The survey the cached endings belong to. Callers pruning stored ids must check it — reading a + // previous survey's (or unsettled) response as the current one would delete a valid selection. + resolvedSurveyId: query.data?.surveyId ?? null, + }; +}; diff --git a/apps/web/modules/ee/workflows/list/hooks/use-workflows.ts b/apps/web/modules/ee/workflows/list/hooks/use-workflows.ts new file mode 100644 index 000000000000..38a0ed15f84d --- /dev/null +++ b/apps/web/modules/ee/workflows/list/hooks/use-workflows.ts @@ -0,0 +1,30 @@ +"use client"; + +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { flattenWorkflowPages, workflowKeys } from "@/modules/ee/workflows/list/lib/query"; +import { listWorkflows } from "@/modules/ee/workflows/list/lib/v3-workflows-client"; + +interface UseWorkflowsArgs { + workspaceId: string; + limit: number; + enabled?: boolean; +} + +export const useWorkflows = ({ workspaceId, limit, enabled = true }: UseWorkflowsArgs) => { + const queryKey = workflowKeys.list({ workspaceId, limit }); + + const query = useInfiniteQuery({ + queryKey, + enabled, + initialPageParam: null as string | null, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => listWorkflows({ workspaceId, limit, cursor: pageParam, signal }), + getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined, + }); + + return { + ...query, + queryKey, + workflows: flattenWorkflowPages(query.data), + }; +}; diff --git a/apps/web/modules/ee/workflows/list/lib/query.ts b/apps/web/modules/ee/workflows/list/lib/query.ts new file mode 100644 index 000000000000..1e28ffa4385e --- /dev/null +++ b/apps/web/modules/ee/workflows/list/lib/query.ts @@ -0,0 +1,18 @@ +import type { InfiniteData } from "@tanstack/react-query"; +import type { TWorkflowListItem } from "@formbricks/workflows"; +import type { TWorkflowListPage } from "./v3-workflows-client"; + +interface TWorkflowListKeyInput { + workspaceId: string; + limit: number; +} + +export const workflowKeys = { + all: ["workflows"] as const, + lists: () => [...workflowKeys.all, "list"] as const, + list: (input: TWorkflowListKeyInput) => [...workflowKeys.lists(), input] as const, +}; + +export function flattenWorkflowPages(data?: InfiniteData): TWorkflowListItem[] { + return data?.pages.flatMap((page) => page.data) ?? []; +} diff --git a/apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts b/apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts new file mode 100644 index 000000000000..b2d102e48bc2 --- /dev/null +++ b/apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts @@ -0,0 +1,53 @@ +import type { TWorkflowListItem } from "@formbricks/workflows"; +import { parseV3ApiError } from "@/modules/api/lib/v3-client"; + +export interface TWorkflowListPage { + data: TWorkflowListItem[]; + meta: { limit: number; nextCursor: string | null }; +} + +interface TV3WorkflowListResponse { + data: TWorkflowListItem[]; + meta: TWorkflowListPage["meta"]; +} + +interface BuildSearchParamsArgs { + workspaceId: string; + limit: number; + cursor?: string | null; +} + +export function buildWorkflowListSearchParams({ + workspaceId, + limit, + cursor, +}: BuildSearchParamsArgs): URLSearchParams { + const params = new URLSearchParams(); + params.set("workspaceId", workspaceId); + params.set("limit", String(limit)); + if (cursor) params.set("cursor", cursor); + return params; +} + +interface ListWorkflowsArgs extends BuildSearchParamsArgs { + signal?: AbortSignal; +} + +export async function listWorkflows({ + workspaceId, + limit, + cursor, + signal, +}: ListWorkflowsArgs): Promise { + const response = await fetch( + `/api/v3/workflows?${buildWorkflowListSearchParams({ workspaceId, limit, cursor }).toString()}`, + { method: "GET", cache: "no-store", signal } + ); + + if (!response.ok) { + throw await parseV3ApiError(response); + } + + const body = (await response.json()) as TV3WorkflowListResponse; + return { data: body.data, meta: body.meta }; +} diff --git a/apps/web/modules/ee/workflows/loading.tsx b/apps/web/modules/ee/workflows/loading.tsx new file mode 100644 index 000000000000..935ff9782b8f --- /dev/null +++ b/apps/web/modules/ee/workflows/loading.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Skeleton } from "@/modules/ui/components/skeleton"; + +export const RunsTableSkeleton = () => ( +
+
+ + + + +
+ {[0, 1, 2].map((row) => ( +
+ + + + +
+ ))} +
+); + +export const WorkflowsListBodyLoading = () => ( +
+
+ + + + + +
+ {[0, 1, 2].map((row) => ( +
+
+ +
+ + + + +
+ ))} +
+); + +export const WorkspaceWorkflowRunsBodyLoading = () => ; + +export const WorkflowBuilderBodyLoading = () => { + // The inspector column only exists while a node's config is open (deep-linked via ?node=), so + // the skeleton follows the same rule — otherwise the canvas visibly narrows/widens on load. + const searchParams = useSearchParams(); + const showInspector = searchParams.has("node"); + + return ( + // Same flex sizing as the loaded editor (see workflow-builder-page), so the canvas occupies the + // same box before and after hydration and nothing shifts. +
+
+
+
+ + +
+
+ + + + +
+
+ {showInspector ? ( + + ) : null} +
+
+ ); +}; + +export const WorkflowRunsBodyLoading = () => ; diff --git a/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx b/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx new file mode 100644 index 000000000000..d2a8db9ef966 --- /dev/null +++ b/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { WorkflowCanvas } from "@/modules/ee/workflows/components/canvas/workflow-canvas"; +import { WorkflowInspectorPanel } from "@/modules/ee/workflows/components/inspector/workflow-inspector-panel"; +import { WorkflowEmailAuthoringProvider } from "@/modules/ee/workflows/components/workflow-email-authoring-context"; +import { useReconcileTriggerEndingCards } from "@/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards"; +import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder"; +import { useWorkflowNodeUrlSync } from "@/modules/ee/workflows/hooks/use-workflow-node-url-sync"; +import { resolveBoundTriggerSurvey } from "@/modules/ee/workflows/lib/bound-survey"; +import { useWorkflowSurveyOptions } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker"; +import { WorkflowBuilderBodyLoading } from "@/modules/ee/workflows/loading"; +import { hasBoundTriggerSurveyAtom } from "@/modules/ee/workflows/state/editor"; +import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context"; + +interface WorkflowBuilderPageProps { + workspaceId: string; + workflowId: string; + isReadOnly: boolean; + emailAuthoringContext: TWorkflowEmailAuthoringContext; +} + +export const WorkflowBuilderPage = ({ + workspaceId, + workflowId, + isReadOnly, + emailAuthoringContext, +}: Readonly) => { + const { t } = useTranslation(); + const builder = useWorkflowBuilder({ workspaceId, workflowId, isReadOnly }); + const setHasBoundTriggerSurvey = useSetAtom(hasBoundTriggerSurveyAtom); + const surveyOptionsQuery = useWorkflowSurveyOptions(workspaceId); + + // This page owns pushing the "does the trigger's survey resolve" fact into the shared atom the + // validity + canvas checks read. Two sources, so the flag flips the moment a survey is picked: + // the server-resolved authoring context (authoritative — catches deleted surveys) still lags a + // save + refresh behind the draft, so membership in the workspace survey list (the same query + // the trigger's picker offers choices from) vouches for a just-picked id immediately. + // Keyed on the inputs (not the computed boolean): hydration resets the atom to its optimistic + // default, and a boolean-keyed effect would skip re-syncing when the computed value happens to + // match its pre-hydration result. + const definition = builder.definition; + const surveyOptions = surveyOptionsQuery.options; + useEffect(() => { + const triggerSurveyId = definition?.trigger?.config.surveyId ?? null; + const isBound = + Boolean(resolveBoundTriggerSurvey(emailAuthoringContext, definition)) || + (triggerSurveyId !== null && surveyOptions.some((option) => option.id === triggerSurveyId)); + setHasBoundTriggerSurvey(isBound); + }, [emailAuthoringContext, definition, surveyOptions, setHasBoundTriggerSurvey]); + + // Prune trigger ending-card ids whose endings were deleted. On the page (not the trigger form) so + // the canvas summary and enable gate stay correct without opening the inspector. + useReconcileTriggerEndingCards({ definition, isEditable: builder.canEditDefinition }); + + // Deep-link the inspected node (?node=…) once the editor is hydrated. + useWorkflowNodeUrlSync({ isEnabled: Boolean(builder.workflow) }); + + if (builder.isLoading) { + return ; + } + + if (!builder.workflow) { + return ( +
+ {builder.loadError ?? t("workspace.workflows.load_failed")} +
+ ); + } + + return ( + // Claims the height the page layout hands down (`min-h-0` so it may shrink below its content + // rather than push the page taller), and both columns stretch to fill it. +
+ + {/* Canvas and inspector share one height, derived from this row rather than computed from + the viewport. Each owns its own overflow: the canvas clips (it pans), the inspector + scrolls — so however long a config form runs, the page itself never grows. */} +
+ + +
+
+
+ ); +}; diff --git a/apps/web/modules/ee/workflows/pages/workflow-runs-page.tsx b/apps/web/modules/ee/workflows/pages/workflow-runs-page.tsx new file mode 100644 index 000000000000..2b9cb49d5eed --- /dev/null +++ b/apps/web/modules/ee/workflows/pages/workflow-runs-page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { WorkflowRunsTable } from "@/modules/ee/workflows/components/runs/workflow-runs-table"; +import { useWorkflowRuns } from "../hooks/use-workflow-runs"; + +const RUNS_PER_PAGE = 20; + +interface WorkflowRunsPageProps { + workspaceId: string; + workflowId: string; +} + +export const WorkflowRunsPage = ({ workspaceId, workflowId }: Readonly) => { + const { + runs, + isLoading, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + } = useWorkflowRuns({ workspaceId, limit: RUNS_PER_PAGE, filters: { workflowId } }); + + return ( + refetch()} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + isFetchNextPageError={isFetchNextPageError} + onLoadMore={() => fetchNextPage()} + /> + ); +}; diff --git a/apps/web/modules/ee/workflows/pages/workflows-list-page.tsx b/apps/web/modules/ee/workflows/pages/workflows-list-page.tsx new file mode 100644 index 000000000000..d5171c7e94f8 --- /dev/null +++ b/apps/web/modules/ee/workflows/pages/workflows-list-page.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { TFunction } from "i18next"; +import { X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { TWorkflowSortBy, TWorkflowStatus } from "@formbricks/workflows"; +import { ZWorkflowStatus } from "@formbricks/workflows"; +import { FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS } from "@/lib/localStorage"; +import { timeSince } from "@/lib/time"; +import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; +import { Button } from "@/modules/ui/components/button"; +import { CardTableHeader, CardTableRow } from "@/modules/ui/components/card-table"; +import { SearchBar } from "@/modules/ui/components/search-bar"; +import { + type TWorkflowStatusFilterOption, + WorkflowFilterDropdown, +} from "../components/workflow-filter-dropdown"; +import { WorkflowListActions } from "../components/workflow-list-actions"; +import { WorkflowSortDropdown } from "../components/workflow-sort-dropdown"; +import { WorkflowStatusPill } from "../components/workflow-status-pill"; +import { WorkflowsEmptyState } from "../components/workflows-empty-state"; +import { useDebouncedValue } from "../hooks/use-debounced-value"; +import { useWorkflows } from "../hooks/use-workflows"; +import { computeStatusIn, parseStoredWorkflowFilters } from "../lib/list-filters"; +import { WorkflowsListBodyLoading } from "../loading"; + +interface WorkflowsListPageProps { + workspaceId: string; + isReadOnly: boolean; + workflowsPerPage: number; +} + +// Status filter options. Archived is set apart by a divider and unchecked by default, so archived +// workflows stay hidden until the user explicitly opts in. +const getStatusFilterOptions = (t: TFunction): TWorkflowStatusFilterOption[] => [ + { label: t("common.draft"), value: "draft" }, + { label: t("common.enabled"), value: "enabled" }, + { label: t("common.disabled"), value: "disabled" }, + { label: t("common.archived"), value: "archived", separatorBefore: true }, +]; + +export const WorkflowsListPage = ({ + workspaceId, + isReadOnly, + workflowsPerPage, +}: Readonly) => { + const { t, i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US"; + const [animationParent] = useAutoAnimate(); + + const [searchValue, setSearchValue] = useState(""); + const debouncedSearchValue = useDebouncedValue(searchValue, 300); + const [selectedStatuses, setSelectedStatuses] = useState([]); + const [sortBy, setSortBy] = useState("updatedAt"); + const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false); + const [isFilterInitialized, setIsFilterInitialized] = useState(false); + + const statusIn = useMemo(() => computeStatusIn(selectedStatuses), [selectedStatuses]); + + // Hydrate the toolbar filters from localStorage once on mount (mirrors the surveys list). Reading + // happens post-mount because localStorage is unavailable during SSR. + useEffect(() => { + if (globalThis.window === undefined) return; + const stored = globalThis.window.localStorage.getItem(FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS); + const parsed = parseStoredWorkflowFilters(stored); + if (stored && !parsed) { + globalThis.window.localStorage.removeItem(FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS); + } else if (parsed) { + setSearchValue(parsed.searchValue); + setSelectedStatuses(parsed.selectedStatuses); + setSortBy(parsed.sortBy); + } + setIsFilterInitialized(true); + }, []); + + // Persist on change, but only after hydration so the empty defaults don't overwrite the stored + // value before it has been read. + useEffect(() => { + if (!isFilterInitialized || globalThis.window === undefined) return; + globalThis.window.localStorage.setItem( + FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS, + JSON.stringify({ searchValue, selectedStatuses, sortBy }) + ); + }, [searchValue, selectedStatuses, sortBy, isFilterInitialized]); + + const toggleStatus = (value: TWorkflowStatus) => { + setSelectedStatuses((prev) => + prev.includes(value) ? prev.filter((status) => status !== value) : [...prev, value] + ); + }; + + const clearFilters = () => { + setSelectedStatuses([]); + setSearchValue(""); + }; + + const { + workflows, + isLoading, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + queryKey, + } = useWorkflows({ + workspaceId, + limit: workflowsPerPage, + nameContains: debouncedSearchValue.trim(), + statusIn, + sortBy, + }); + + const showInitialLoading = isLoading && workflows.length === 0; + const hasActiveFilters = selectedStatuses.length > 0 || searchValue.length > 0; + + const isListEmpty = !showInitialLoading && !isError && workflows.length === 0; + + // Probe for ANY workflow including archived (the default query excludes archived) so an all-archived + // workspace isn't mistaken for an empty one — which would hide the toolbar and with it the archived filter. + const { workflows: anyWorkflows, isLoading: isProbingAnyWorkflows } = useWorkflows({ + workspaceId, + limit: 1, + nameContains: "", + statusIn: [...ZWorkflowStatus.options], + enabled: isListEmpty, + }); + + // Mirror the surveys list: only a genuinely empty workspace hides the toolbar. If any workflow exists + // (even only archived ones), keep the toolbar so the filters stay reachable. + const isWorkspaceEmpty = isListEmpty && !isProbingAnyWorkflows && anyWorkflows.length === 0; + + if (isWorkspaceEmpty) { + return ( +
+ +
+ ); + } + + let listContent: React.ReactNode; + + if (showInitialLoading) { + listContent = ; + } else if (isError && workflows.length === 0) { + listContent = ( +
+

{getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))}

+ +
+ ); + } else if (workflows.length === 0) { + // Workspace has workflows but the active filters match none (a truly empty workspace exits early). + listContent = ; + } else { + listContent = ( +
+
+ +
{t("common.name")}
+
{t("common.status")}
+
{t("common.runs")}
+
{t("common.created_at")}
+
{t("common.updated_at")}
+
{t("common.created_by")}
+
+ + {workflows.map((workflow) => ( + + }> +
+
{workflow.name}
+
+
+ +
+
{workflow.runCount}
+
+ {timeSince(workflow.createdAt, locale)} +
+
+ {timeSince(workflow.updatedAt, locale)} +
+
+ {workflow.creator?.name ?? "-"} +
+
+ ))} +
+ + {hasNextPage ? ( +
+ +
+ ) : null} +
+ ); + } + + return ( +
+
+
+ + + {hasActiveFilters ? ( + + ) : null} +
+ +
+ {listContent} +
+ ); +}; diff --git a/apps/web/modules/ee/workflows/pages/workspace-workflow-runs-page.tsx b/apps/web/modules/ee/workflows/pages/workspace-workflow-runs-page.tsx new file mode 100644 index 000000000000..82e4c643b01a --- /dev/null +++ b/apps/web/modules/ee/workflows/pages/workspace-workflow-runs-page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { WorkflowRunsTable } from "@/modules/ee/workflows/components/runs/workflow-runs-table"; +import { useWorkflowRuns } from "../hooks/use-workflow-runs"; + +const RUNS_PER_PAGE = 20; + +interface WorkspaceWorkflowRunsPageProps { + workspaceId: string; +} + +export const WorkspaceWorkflowRunsPage = ({ workspaceId }: Readonly) => { + const { + runs, + isLoading, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + } = useWorkflowRuns({ workspaceId, limit: RUNS_PER_PAGE }); + + return ( + refetch()} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + isFetchNextPageError={isFetchNextPageError} + onLoadMore={() => fetchNextPage()} + /> + ); +}; diff --git a/apps/web/modules/ee/workflows/state/editor.test.ts b/apps/web/modules/ee/workflows/state/editor.test.ts new file mode 100644 index 000000000000..1edab91fe313 --- /dev/null +++ b/apps/web/modules/ee/workflows/state/editor.test.ts @@ -0,0 +1,921 @@ +import { createStore } from "jotai"; +import { describe, expect, test } from "vitest"; +import type { TWorkflowDefinition, TWorkflowResource } from "@formbricks/workflows"; +import { + addWorkflowTriggerAtom, + appendSendEmailAfterNodeAtom, + canEditWorkflowDefinitionAtom, + canMutateCanvasAtom, + clearWorkflowNodeFieldFocusAtom, + closeWorkflowNodeConfigModalAtom, + deleteWorkflowNodeAtom, + deriveTriggerEndingProblems, + deriveWorkflowValidation, + hasBoundTriggerSurveyAtom, + hasWorkflowSaveFailedAtom, + hydrateWorkflowEditorAtom, + insertSendEmailAfterEdgeAtom, + isCanvasLockedAtom, + isWorkflowDirtyAtom, + isWorkflowInspectorCollapsedAtom, + isWorkflowNodeConfigModalOpenAtom, + isWorkflowSavingAtom, + isWorkflowSnapToCanvasEnabledAtom, + isWorkflowTransitioningAtom, + markWorkflowDraftSavedAtom, + openWorkflowNodeConfigModalAtom, + requestWorkflowNodeFieldFocusAtom, + selectedWorkflowNodeIdAtom, + setSelectedWorkflowNodeIdAtom, + setWorkflowAtom, + setWorkflowDefinitionAtom, + setWorkflowDescriptionAtom, + setWorkflowNameAtom, + setWorkflowSaveErrorAtom, + setWorkflowSavingAtom, + setWorkflowSnapToCanvasEnabledAtom, + setWorkflowTransitioningAtom, + toggleWorkflowInspectorAtom, + workflowAtom, + workflowDefinitionAtom, + workflowDescriptionAtom, + workflowDraftSignatureAtom, + workflowEditorAtom, + workflowFlowNodesAtom, + workflowLastSavedAtAtom, + workflowNameAtom, + workflowNodeFieldFocusRequestAtom, + workflowSaveErrorAtom, + workflowValidationProblemsAtom, + workflowValidityAtom, +} from "./editor"; + +const definition = { + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { id: "trigger-1", type: "trigger" }, + nodes: [], + edges: [], +} as unknown as TWorkflowDefinition; + +const workflow = { + id: "wf1", + name: "Hello", + description: "Desc", + status: "draft", + definition, +} as unknown as TWorkflowResource; + +describe("hydrateWorkflowEditorAtom", () => { + test("seeds name, description, definition, selection from the workflow", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + + expect(store.get(workflowAtom)).toBe(workflow); + expect(store.get(workflowNameAtom)).toBe("Hello"); + expect(store.get(workflowDescriptionAtom)).toBe("Desc"); + expect(store.get(workflowDefinitionAtom)).toBe(definition); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("trigger-1"); + }); + + test("description falls back to empty string when null", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { + workflow: { ...workflow, description: null } as unknown as TWorkflowResource, + flowNodes: [], + }); + + expect(store.get(workflowDescriptionAtom)).toBe(""); + }); +}); + +describe("simple setters", () => { + test("name / description / saving / transitioning / snap update their slice", () => { + const store = createStore(); + store.set(setWorkflowNameAtom, "n"); + store.set(setWorkflowDescriptionAtom, "d"); + store.set(setWorkflowSavingAtom, true); + store.set(setWorkflowTransitioningAtom, true); + store.set(setWorkflowSnapToCanvasEnabledAtom, false); + store.set(setSelectedWorkflowNodeIdAtom, "node-x"); + + expect(store.get(workflowNameAtom)).toBe("n"); + expect(store.get(workflowDescriptionAtom)).toBe("d"); + expect(store.get(isWorkflowSavingAtom)).toBe(true); + expect(store.get(isWorkflowTransitioningAtom)).toBe(true); + expect(store.get(isWorkflowSnapToCanvasEnabledAtom)).toBe(false); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("node-x"); + }); +}); + +describe("definition + flow node updaters accept value or callback", () => { + test("setWorkflowDefinitionAtom resolves SetStateAction", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, definition); + expect(store.get(workflowDefinitionAtom)).toBe(definition); + + store.set(setWorkflowDefinitionAtom, (current) => ({ ...current!, entryNodeId: "z" })); + expect(store.get(workflowDefinitionAtom)?.entryNodeId).toBe("z"); + }); + + test("workflowFlowNodesAtom resolves SetStateAction", () => { + const store = createStore(); + store.set(workflowFlowNodesAtom, [{ id: "n1" } as never]); + expect(store.get(workflowFlowNodesAtom)).toHaveLength(1); + + store.set(workflowFlowNodesAtom, (current) => [...current, { id: "n2" } as never]); + expect(store.get(workflowFlowNodesAtom).map((n) => n.id)).toEqual(["n1", "n2"]); + }); + + test("flow nodes stay mutable after immer-produced writes (ReactFlow mutates measured dimensions)", () => { + // Regression: flowNodes used to live inside the immer-produced editor state, so immer's + // auto-freeze made ReactFlow's applyNodeChanges throw "Cannot assign to read only property + // 'width'" as soon as it measured a node. + const store = createStore(); + const node = { id: "n1", measured: { width: 100, height: 40 } }; + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [node as never] }); + store.set(setWorkflowNameAtom, "renamed"); + + const [storedNode] = store.get(workflowFlowNodesAtom); + expect(Object.isFrozen(storedNode)).toBe(false); + expect(() => { + (storedNode as unknown as { measured: { width: number } }).measured.width = 120; + }).not.toThrow(); + expect(node.measured.width).toBe(120); + }); +}); + +describe("inspector + node-config modal", () => { + test("toggle flips the collapsed flag", () => { + const store = createStore(); + expect(store.get(isWorkflowInspectorCollapsedAtom)).toBe(false); + store.set(toggleWorkflowInspectorAtom); + expect(store.get(isWorkflowInspectorCollapsedAtom)).toBe(true); + + store.set(toggleWorkflowInspectorAtom); + expect(store.get(isWorkflowInspectorCollapsedAtom)).toBe(false); + }); + + test("open sets the selected node + flag, close just lowers the flag", () => { + const store = createStore(); + store.set(openWorkflowNodeConfigModalAtom, "node-1"); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("node-1"); + + store.set(closeWorkflowNodeConfigModalAtom); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(false); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("node-1"); + }); +}); + +describe("setWorkflowAtom", () => { + test("replaces the workflow snapshot without overwriting in-flight edits", () => { + const store = createStore(); + store.set(setWorkflowNameAtom, "draft name"); + store.set(setWorkflowAtom, workflow); + + expect(store.get(workflowAtom)).toBe(workflow); + expect(store.get(workflowNameAtom)).toBe("draft name"); + }); +}); + +const workflowWithStatus = (status: string) => ({ ...workflow, status }) as unknown as TWorkflowResource; + +describe("canEditWorkflowDefinitionAtom", () => { + test.each([ + ["draft", true], + ["disabled", true], + ["enabled", false], + ["archived", false], + ])("status %s -> %s", (status, expected) => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow: workflowWithStatus(status), flowNodes: [] }); + expect(store.get(canEditWorkflowDefinitionAtom)).toBe(expected); + }); + + test("is false when there is no workflow", () => { + const store = createStore(); + expect(store.get(canEditWorkflowDefinitionAtom)).toBe(false); + }); +}); + +describe("canMutateCanvasAtom", () => { + test("requires an editable status AND an unlocked canvas", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow: workflowWithStatus("draft"), flowNodes: [] }); + + // Canvas starts in pointer mode (unlocked), so a draft is mutable right away. + expect(store.get(isCanvasLockedAtom)).toBe(false); + expect(store.get(canMutateCanvasAtom)).toBe(true); + + // Switching to pan mode blocks mutations even on an editable status. + store.set(isCanvasLockedAtom, true); + expect(store.get(canMutateCanvasAtom)).toBe(false); + store.set(isCanvasLockedAtom, false); + expect(store.get(canMutateCanvasAtom)).toBe(true); + + // Editable status removed -> blocked again even while unlocked. + store.set(hydrateWorkflowEditorAtom, { workflow: workflowWithStatus("enabled"), flowNodes: [] }); + store.set(isCanvasLockedAtom, false); + expect(store.get(canMutateCanvasAtom)).toBe(false); + }); +}); + +describe("openWorkflowNodeConfigModalAtom", () => { + test("expands a collapsed inspector when a node is opened", () => { + const store = createStore(); + store.set(toggleWorkflowInspectorAtom); // collapse + expect(store.get(isWorkflowInspectorCollapsedAtom)).toBe(true); + + store.set(openWorkflowNodeConfigModalAtom, "node-9"); + expect(store.get(isWorkflowInspectorCollapsedAtom)).toBe(false); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("node-9"); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true); + }); + + test("drops a field focus request a form never consumed", () => { + const store = createStore(); + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "if-else-1", field: "condition" }); + + store.set(openWorkflowNodeConfigModalAtom, "node-9"); + expect(store.get(workflowNodeFieldFocusRequestAtom)).toBeNull(); + }); +}); + +describe("workflow node field focus requests", () => { + test("opens the target node's panel alongside the request", () => { + const store = createStore(); + store.set(toggleWorkflowInspectorAtom); // collapse + + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "email-1", field: "subject" }); + + expect(store.get(workflowNodeFieldFocusRequestAtom)).toEqual({ + nodeId: "email-1", + field: "subject", + }); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("email-1"); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true); + expect(store.get(isWorkflowInspectorCollapsedAtom)).toBe(false); + }); + + test("clearing leaves the panel open and is a no-op when nothing is pending", () => { + const store = createStore(); + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "email-1", field: "body" }); + + store.set(clearWorkflowNodeFieldFocusAtom); + expect(store.get(workflowNodeFieldFocusRequestAtom)).toBeNull(); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("email-1"); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true); + + store.set(clearWorkflowNodeFieldFocusAtom); + expect(store.get(workflowNodeFieldFocusRequestAtom)).toBeNull(); + }); + + test("a second request to the same field re-fires (it was cleared in between)", () => { + const store = createStore(); + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "email-1", field: "subject" }); + store.set(clearWorkflowNodeFieldFocusAtom); + + store.set(requestWorkflowNodeFieldFocusAtom, { nodeId: "email-1", field: "subject" }); + expect(store.get(workflowNodeFieldFocusRequestAtom)).toEqual({ + nodeId: "email-1", + field: "subject", + }); + }); +}); + +const graphDefinition = () => + ({ + schemaVersion: 1, + entryNodeId: "trigger-1", + trigger: { id: "trigger-1", type: "trigger", ui: { position: { x: 0, y: 0 } } }, + nodes: [ + { id: "n1", type: "action", ui: { position: { x: 100, y: 100 } } }, + { id: "n2", type: "action", ui: { position: { x: 200, y: 300 } } }, + ], + edges: [ + { id: "e1", source: "trigger-1", target: "n1", sourceHandle: "true" }, + { id: "e2", source: "n1", target: "n2" }, + ], + }) as unknown as TWorkflowDefinition; + +describe("deleteWorkflowNodeAtom", () => { + test("no-ops when there is no definition", () => { + const store = createStore(); + store.set(deleteWorkflowNodeAtom, "n1"); + expect(store.get(workflowDefinitionAtom)).toBeNull(); + }); + + test("refuses to delete the trigger", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(deleteWorkflowNodeAtom, "trigger-1"); + expect(store.get(workflowDefinitionAtom)?.nodes).toHaveLength(2); + }); + + test("bridges incoming to outgoing edges and preserves sourceHandle", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(setSelectedWorkflowNodeIdAtom, "n1"); + store.set(openWorkflowNodeConfigModalAtom, "n1"); + + store.set(deleteWorkflowNodeAtom, "n1"); + + const def = store.get(workflowDefinitionAtom)!; + expect(def.nodes.map((n) => n.id)).toEqual(["n2"]); + // e1 + e2 dropped; a bridge trigger-1 -> n2 added, carrying e1's sourceHandle. + const bridge = def.edges.find((e) => e.source === "trigger-1" && e.target === "n2"); + expect(bridge).toBeDefined(); + expect(bridge?.sourceHandle).toBe("true"); + expect(def.edges).toHaveLength(1); + // Selection falls back to the trigger and the config modal closes. + expect(store.get(selectedWorkflowNodeIdAtom)).toBe("trigger-1"); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(false); + }); +}); + +describe("appendSendEmailAfterNodeAtom", () => { + test("no-ops when there is no definition", () => { + const store = createStore(); + store.set(appendSendEmailAfterNodeAtom, "trigger-1"); + expect(store.get(workflowDefinitionAtom)).toBeNull(); + }); + + test("appends a send_email node below the trigger and selects it", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(appendSendEmailAfterNodeAtom, "trigger-1"); + + const def = store.get(workflowDefinitionAtom)!; + expect(def.nodes).toHaveLength(3); + const added = def.nodes[def.nodes.length - 1]; + expect(added.ui?.position).toEqual({ x: 0, y: 120 }); + expect(def.edges.some((e) => e.source === "trigger-1" && e.target === added.id)).toBe(true); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe(added.id); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true); + }); + + test("falls back to a default position when the source node is unknown", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(appendSendEmailAfterNodeAtom, "missing"); + + const def = store.get(workflowDefinitionAtom)!; + const added = def.nodes[def.nodes.length - 1]; + expect(added.ui?.position).toEqual({ x: 220, y: 200 }); + }); +}); + +describe("insertSendEmailAfterEdgeAtom", () => { + test("no-ops when there is no definition", () => { + const store = createStore(); + store.set(insertSendEmailAfterEdgeAtom, "e1"); + expect(store.get(workflowDefinitionAtom)).toBeNull(); + }); + + test("no-ops when the edge id is unknown", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(insertSendEmailAfterEdgeAtom, "missing-edge"); + expect(store.get(workflowDefinitionAtom)?.edges).toHaveLength(2); + }); + + test("splits the edge at the midpoint, preserving sourceHandle", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(insertSendEmailAfterEdgeAtom, "e1"); + + const def = store.get(workflowDefinitionAtom)!; + expect(def.nodes).toHaveLength(3); + const added = def.nodes[def.nodes.length - 1]; + // midpoint of trigger (0,0) and n1 (100,100) + expect(added.ui?.position).toEqual({ x: 50, y: 50 }); + // e1 replaced by two edges; the first carries the original sourceHandle. + expect(def.edges.some((e) => e.id === "e1")).toBe(false); + const intoNew = def.edges.find((e) => e.source === "trigger-1" && e.target === added.id); + expect(intoNew?.sourceHandle).toBe("true"); + expect(def.edges.some((e) => e.source === added.id && e.target === "n1")).toBe(true); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe(added.id); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true); + }); + + test("splits an edge with no sourceHandle (omits the handle on the first segment)", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(insertSendEmailAfterEdgeAtom, "e2"); // n1 -> n2, no sourceHandle + + const def = store.get(workflowDefinitionAtom)!; + const added = def.nodes[def.nodes.length - 1]; + const intoNew = def.edges.find((e) => e.source === "n1" && e.target === added.id); + expect(intoNew).toBeDefined(); + expect(intoNew?.sourceHandle).toBeUndefined(); + }); + + test("falls back to a default midpoint when an endpoint lacks a position", () => { + const store = createStore(); + const def = graphDefinition(); + // Drop n2's position so positionOf(target) returns undefined. + (def.nodes[1] as { ui?: unknown }).ui = undefined; + store.set(setWorkflowDefinitionAtom, def); + store.set(insertSendEmailAfterEdgeAtom, "e2"); + + const next = store.get(workflowDefinitionAtom)!; + const added = next.nodes[next.nodes.length - 1]; + expect(added.ui?.position).toEqual({ x: 220, y: 200 }); + }); +}); + +describe("addWorkflowTriggerAtom", () => { + const emptyDefinition = () => + ({ + schemaVersion: 1, + trigger: null, + nodes: [], + edges: [], + entryNodeId: null, + }) as unknown as TWorkflowDefinition; + + test("no-ops when there is no definition", () => { + const store = createStore(); + store.set(addWorkflowTriggerAtom, "response.completed"); + expect(store.get(workflowDefinitionAtom)).toBeNull(); + }); + + test("seeds a response.completed trigger, entry point, selection, and opens config", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, emptyDefinition()); + store.set(addWorkflowTriggerAtom, "response.completed"); + + const def = store.get(workflowDefinitionAtom)!; + expect(def.trigger?.triggerType).toBe("response.completed"); + expect(def.trigger?.config.endingCardIds).toEqual([]); + expect(def.entryNodeId).toBe(def.trigger?.id); + expect(store.get(selectedWorkflowNodeIdAtom)).toBe(def.trigger?.id); + expect(store.get(isWorkflowNodeConfigModalOpenAtom)).toBe(true); + }); + + test("no-ops when a trigger already exists", () => { + const store = createStore(); + store.set(setWorkflowDefinitionAtom, graphDefinition()); + store.set(addWorkflowTriggerAtom, "response.completed"); + + expect(store.get(workflowDefinitionAtom)?.trigger?.id).toBe("trigger-1"); + }); +}); + +describe("hydrateWorkflowEditorAtom with a trigger-less draft", () => { + test("leaves the selection empty", () => { + const store = createStore(); + const triggerlessWorkflow = { + ...workflow, + definition: { ...definition, trigger: null, entryNodeId: null }, + } as unknown as TWorkflowResource; + store.set(hydrateWorkflowEditorAtom, { workflow: triggerlessWorkflow, flowNodes: [] }); + + expect(store.get(selectedWorkflowNodeIdAtom)).toBeNull(); + }); +}); + +describe("deleteWorkflowNodeAtom bridge without sourceHandle", () => { + test("omits the handle when the incoming edge has none", () => { + const store = createStore(); + const def = graphDefinition(); + // trigger-1 -> n1 (handle "true"), n1 -> n2 (no handle), add n2 -> n3. + def.nodes.push({ id: "n3", type: "action", ui: { position: { x: 300, y: 400 } } } as never); + def.edges.push({ id: "e3", source: "n2", target: "n3" } as never); + store.set(setWorkflowDefinitionAtom, def); + + store.set(deleteWorkflowNodeAtom, "n2"); + + const next = store.get(workflowDefinitionAtom)!; + // incoming e2 (no handle) bridges to outgoing e3 -> n1 -> n3 without a sourceHandle. + const bridge = next.edges.find((e) => e.source === "n1" && e.target === "n3"); + expect(bridge).toBeDefined(); + expect(bridge?.sourceHandle).toBeUndefined(); + }); +}); + +describe("isWorkflowDirtyAtom + markWorkflowDraftSavedAtom", () => { + test("is clean right after hydrate and dirty after an edit", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + + expect(store.get(isWorkflowDirtyAtom)).toBe(false); + + store.set(setWorkflowNameAtom, "Renamed"); + expect(store.get(isWorkflowDirtyAtom)).toBe(true); + }); + + test("trailing whitespace the save flow trims anyway does not count as dirty", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + + store.set(setWorkflowNameAtom, "Hello "); + expect(store.get(isWorkflowDirtyAtom)).toBe(false); + }); + + test("markWorkflowDraftSavedAtom records the sent draft, so mid-flight edits stay dirty", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + store.set(setWorkflowNameAtom, "Renamed"); + // Simulates an edit landing while the PATCH for "Renamed" was in flight. + store.set(setWorkflowDescriptionAtom, "Edited during save"); + + store.set(markWorkflowDraftSavedAtom, { + workflowName: "Renamed", + workflowDescription: "Desc", + definition, + }); + + expect(store.get(isWorkflowDirtyAtom)).toBe(true); + + store.set(markWorkflowDraftSavedAtom, { + workflowName: "Renamed", + workflowDescription: "Edited during save", + definition, + }); + expect(store.get(isWorkflowDirtyAtom)).toBe(false); + }); + + test("definition edits count as dirty", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + + store.set(setWorkflowDefinitionAtom, (current) => + current ? { ...current, edges: [...current.edges] } : current + ); + // Structurally identical definition stays clean (JSON comparison). + expect(store.get(isWorkflowDirtyAtom)).toBe(false); + + store.set(setWorkflowDefinitionAtom, (current) => + current + ? { ...current, edges: [{ id: "new-edge", source: "trigger-1", target: "trigger-1" }] } + : current + ); + expect(store.get(isWorkflowDirtyAtom)).toBe(true); + }); +}); + +describe("workflow save error", () => { + const saveError = { draftSignature: "sig", kind: "unreachable", detail: null } as const; + + test("a successful save clears the failed state in the same write as lastSavedAt", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + store.set(setWorkflowNameAtom, "Renamed"); + store.set(setWorkflowSaveErrorAtom, saveError); + + expect(store.get(hasWorkflowSaveFailedAtom)).toBe(true); + + store.set(markWorkflowDraftSavedAtom, { + workflowName: "Renamed", + workflowDescription: "Desc", + definition, + }); + + expect(store.get(workflowSaveErrorAtom)).toBeNull(); + expect(store.get(workflowLastSavedAtAtom)).not.toBeNull(); + expect(store.get(hasWorkflowSaveFailedAtom)).toBe(false); + }); + + test("reverting the draft clears the alarm without a save", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + store.set(setWorkflowNameAtom, "Renamed"); + store.set(setWorkflowSaveErrorAtom, saveError); + + expect(store.get(hasWorkflowSaveFailedAtom)).toBe(true); + + // Nothing is unsaved any more, so nothing should still read "Save failed". + store.set(setWorkflowNameAtom, workflow.name); + expect(store.get(hasWorkflowSaveFailedAtom)).toBe(false); + expect(store.get(workflowSaveErrorAtom)).toEqual(saveError); + }); + + test("a redundant clear does not churn editor state", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + const before = store.get(workflowEditorAtom); + + store.set(setWorkflowSaveErrorAtom, null); + + expect(store.get(workflowEditorAtom)).toBe(before); + }); +}); + +describe("workflowDraftSignatureAtom", () => { + test("changes with any editable field and is stable otherwise", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow, flowNodes: [] }); + const hydrated = store.get(workflowDraftSignatureAtom); + + store.set(setWorkflowNameAtom, "Renamed"); + const renamed = store.get(workflowDraftSignatureAtom); + expect(renamed).not.toBe(hydrated); + + store.set(setWorkflowDescriptionAtom, "Edited"); + const described = store.get(workflowDraftSignatureAtom); + expect(described).not.toBe(renamed); + + // Untrimmed on purpose: this identifies a draft, it does not decide dirtiness. Compared against + // the signature taken immediately before the whitespace edit — comparing against `renamed` + // would pass on the description edit alone and pin nothing about trimming. + store.set(setWorkflowNameAtom, "Renamed "); + expect(store.get(workflowDraftSignatureAtom)).not.toBe(described); + }); +}); + +describe("workflowValidityAtom", () => { + const executableDefinition = { + schemaVersion: 1, + trigger: { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "cm9zr4mps000008l8btfy1vtz", endingCardIds: [] }, + }, + nodes: [ + { + id: "email-1", + type: "action", + actionType: "send_email", + config: { + to: "jane@example.com", + from: "noreply@example.com", + replyTo: [], + subject: "Thanks", + body: "Thanks for your response.", + attachResponseData: false, + }, + }, + ], + edges: [{ id: "e1", source: "trigger-1", target: "email-1" }], + entryNodeId: "trigger-1", + } as unknown as TWorkflowDefinition; + + const executableWorkflow = { + ...workflow, + definition: executableDefinition, + } as unknown as TWorkflowResource; + + test("is ready when the name is set, the definition is executable, and the survey resolves", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow: executableWorkflow, flowNodes: [] }); + + expect(store.get(workflowValidityAtom)).toEqual({ + isNameValid: true, + isDefinitionExecutable: true, + hasBoundTriggerSurvey: true, + isReady: true, + }); + }); + + test("an empty name makes the workflow not ready", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow: executableWorkflow, flowNodes: [] }); + store.set(setWorkflowNameAtom, " "); + + const validity = store.get(workflowValidityAtom); + expect(validity.isNameValid).toBe(false); + expect(validity.isReady).toBe(false); + }); + + test("an incomplete send_email node makes the definition not executable", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow: executableWorkflow, flowNodes: [] }); + store.set(setWorkflowDefinitionAtom, (current) => + current + ? { + ...current, + nodes: current.nodes.map((node) => + node.type === "action" ? { ...node, config: { ...node.config, to: "" } } : node + ), + } + : current + ); + + const validity = store.get(workflowValidityAtom); + expect(validity.isDefinitionExecutable).toBe(false); + expect(validity.isReady).toBe(false); + }); + + test("an unresolved trigger survey makes the workflow not ready", () => { + const store = createStore(); + store.set(hydrateWorkflowEditorAtom, { workflow: executableWorkflow, flowNodes: [] }); + store.set(hasBoundTriggerSurveyAtom, false); + + const validity = store.get(workflowValidityAtom); + expect(validity.isDefinitionExecutable).toBe(true); + expect(validity.hasBoundTriggerSurvey).toBe(false); + expect(validity.isReady).toBe(false); + }); + + test("a trigger-less draft is not executable", () => { + const store = createStore(); + const triggerless = { + ...workflow, + definition: { ...executableDefinition, trigger: null, nodes: [], edges: [], entryNodeId: null }, + } as unknown as TWorkflowResource; + store.set(hydrateWorkflowEditorAtom, { workflow: triggerless, flowNodes: [] }); + + expect(store.get(workflowValidityAtom).isDefinitionExecutable).toBe(false); + }); +}); + +describe("workflowValidationProblemsAtom", () => { + const executableEmailNode = () => ({ + id: "email-1", + type: "action", + actionType: "send_email", + config: { + to: "jane@example.com", + from: "noreply@example.com", + replyTo: [], + subject: "Thanks", + body: "Thanks for your response.", + attachResponseData: false, + }, + }); + + const executableDefinition = (overrides: Partial = {}) => + ({ + schemaVersion: 1, + trigger: { + id: "trigger-1", + type: "trigger", + triggerType: "response.completed", + config: { surveyId: "cm9zr4mps000008l8btfy1vtz", endingCardIds: [] }, + }, + nodes: [executableEmailNode()], + edges: [{ id: "e1", source: "trigger-1", target: "email-1" }], + entryNodeId: "trigger-1", + ...overrides, + }) as unknown as TWorkflowDefinition; + + const hydrate = (store: ReturnType, definition: TWorkflowDefinition) => { + store.set(hydrateWorkflowEditorAtom, { + workflow: { ...workflow, definition } as unknown as TWorkflowResource, + flowNodes: [], + }); + }; + + test("a ready workflow has no problems (empty exactly when workflowValidityAtom is ready)", () => { + const store = createStore(); + hydrate(store, executableDefinition()); + + expect(store.get(workflowValidationProblemsAtom)).toEqual([]); + expect(store.get(workflowValidityAtom).isReady).toBe(true); + }); + + test("an empty name yields name_missing", () => { + const store = createStore(); + hydrate(store, executableDefinition()); + store.set(setWorkflowNameAtom, " "); + + expect(store.get(workflowValidationProblemsAtom)).toEqual([{ code: "name_missing", field: "name" }]); + expect(store.get(workflowValidityAtom).isReady).toBe(false); + }); + + test("an unresolved trigger survey yields trigger_survey_unbound", () => { + const store = createStore(); + hydrate(store, executableDefinition()); + store.set(hasBoundTriggerSurveyAtom, false); + + expect(store.get(workflowValidationProblemsAtom)).toEqual([ + { code: "trigger_survey_unbound", field: "trigger.config.surveyId" }, + ]); + }); + + test("a trigger-less draft collapses into exactly one trigger_missing", () => { + const store = createStore(); + hydrate(store, executableDefinition({ trigger: null, nodes: [], edges: [], entryNodeId: null })); + // The builder page reports the survey unbound while there is no trigger; trigger_missing + // already says everything, so no trigger_survey_unbound problem should pile on. + store.set(hasBoundTriggerSurveyAtom, false); + + expect(store.get(workflowValidationProblemsAtom)).toEqual([ + { code: "trigger_missing", field: "trigger" }, + ]); + expect(store.get(workflowValidityAtom).isReady).toBe(false); + }); + + test("a trigger without a next step yields trigger_not_connected", () => { + const store = createStore(); + hydrate(store, executableDefinition({ nodes: [], edges: [] })); + + expect(store.get(workflowValidationProblemsAtom)).toEqual([ + { code: "trigger_not_connected", field: "edges" }, + ]); + }); + + test("empty send_email content collapses into one step_incomplete per step", () => { + const store = createStore(); + const emptyEmail = executableEmailNode(); + emptyEmail.config = { ...emptyEmail.config, to: "", subject: "", body: "" }; + hydrate(store, executableDefinition({ nodes: [emptyEmail] } as Partial)); + + // Three empty fields (to/subject/body) count as one unfinished step, not three errors. + expect(store.get(workflowValidationProblemsAtom)).toEqual([ + { code: "step_incomplete", field: "nodes.0.config" }, + ]); + }); + + test("a cycle yields flow_invalid", () => { + const store = createStore(); + const secondEmail = { ...executableEmailNode(), id: "email-2" }; + hydrate( + store, + executableDefinition({ + nodes: [executableEmailNode(), secondEmail], + edges: [ + { id: "e1", source: "trigger-1", target: "email-1" }, + { id: "e2", source: "email-1", target: "email-2" }, + { id: "e3", source: "email-2", target: "email-1" }, + ], + } as Partial) + ); + + expect(store.get(workflowValidationProblemsAtom)).toEqual([{ code: "flow_invalid", field: "edges" }]); + }); + + test("an if_else step yields step_not_executable plus one flow_invalid for its missing branches", () => { + const store = createStore(); + const ifElseNode = { + id: "ifelse-1", + type: "if_else", + config: { + condition: { + id: "group-1", + connector: "and", + conditions: [{ id: "c1", left: { path: "response.email" }, operator: "exists" }], + }, + }, + }; + hydrate( + store, + executableDefinition({ + nodes: [ifElseNode], + edges: [{ id: "e1", source: "trigger-1", target: "ifelse-1" }], + } as Partial) + ); + + const problems = store.get(workflowValidationProblemsAtom); + expect(problems).toHaveLength(2); + expect(problems).toContainEqual({ code: "step_not_executable", field: "nodes.0.type" }); + expect(problems).toContainEqual({ code: "flow_invalid", field: "edges" }); + expect(store.get(workflowValidityAtom).isReady).toBe(false); + }); + + test("independent problems accumulate, so the count matches what the user must fix", () => { + const store = createStore(); + hydrate(store, executableDefinition()); + store.set(setWorkflowNameAtom, ""); + store.set(hasBoundTriggerSurveyAtom, false); + + expect(store.get(workflowValidationProblemsAtom).map((problem) => problem.code)).toEqual([ + "name_missing", + "trigger_survey_unbound", + ]); + }); +}); + +describe("deriveWorkflowValidation", () => { + test("without a definition (editor not hydrated) only the name is checked and isReady stays false", () => { + const unnamed = deriveWorkflowValidation({ + workflowName: "", + definition: null, + hasBoundTriggerSurvey: true, + }); + expect(unnamed.problems).toEqual([{ code: "name_missing", field: "name" }]); + expect(unnamed.validity.isReady).toBe(false); + + // An empty problem list is NOT readiness while unhydrated: isReady is structurally + // "definition present AND nothing to fix", so the two can never disagree once hydrated. + const named = deriveWorkflowValidation({ + workflowName: "n", + definition: null, + hasBoundTriggerSurvey: false, + }); + expect(named.problems).toEqual([]); + expect(named.validity).toEqual({ + isNameValid: true, + isDefinitionExecutable: false, + hasBoundTriggerSurvey: false, + isReady: false, + }); + }); +}); + +describe("deriveTriggerEndingProblems", () => { + test("no problem while every configured ending still exists (or none are configured)", () => { + expect(deriveTriggerEndingProblems(["end-1", "end-2"], ["end-1", "end-2", "end-3"])).toEqual([]); + // Empty endingCardIds = "all endings" mode; nothing to go stale. + expect(deriveTriggerEndingProblems([], [])).toEqual([]); + }); + + test("stale endings collapse into one trigger_ending_not_found problem", () => { + expect(deriveTriggerEndingProblems(["end-1", "gone-1", "gone-2"], ["end-1"])).toEqual([ + { code: "trigger_ending_not_found", field: "trigger.config.endingCardIds" }, + ]); + }); +}); diff --git a/apps/web/modules/ee/workflows/state/editor.ts b/apps/web/modules/ee/workflows/state/editor.ts new file mode 100644 index 000000000000..37e0326c1c72 --- /dev/null +++ b/apps/web/modules/ee/workflows/state/editor.ts @@ -0,0 +1,794 @@ +import { createId } from "@paralleldrive/cuid2"; +import { type Node } from "@xyflow/react"; +import { produce } from "immer"; +import { atom } from "jotai"; +import type { SetStateAction } from "react"; +import { + type TWorkflowDefinition, + type TWorkflowResource, + type TWorkflowTriggerType, + WORKFLOW_ACTIONS, + WORKFLOW_TRIGGERS, + ZWorkflowExecutableDefinition, +} from "@formbricks/workflows"; + +export type TWorkflowNodeCategory = "trigger" | "flow" | "action"; +export type TWorkflowNodeIcon = "trigger" | "ifElse" | "email"; + +export type TWorkflowNodeIssue = { + /** + * "setup" = a draft that simply isn't finished being configured (amber, guiding); + * "error" = a live (or previously live) workflow that can't run as configured (red). + */ + severity: "setup" | "error"; + /** Short user-facing reason rendered on the card in place of the summary. */ + label: string; +}; + +export type TWorkflowNodeData = { + category: TWorkflowNodeCategory; + icon: TWorkflowNodeIcon; + title: string; + summary: string; + isLeaf: boolean; + issue: TWorkflowNodeIssue | null; +}; + +/** + * A pending "take me to the field that's wrong" jump, raised by the validation problems dialog and + * consumed by whichever config form owns `nodeId`. The form focuses the named control and reveals + * that node's field-level errors, then clears the request. + */ +export type TWorkflowNodeFieldFocusRequest = { + nodeId: string; + /** Config key within the node's form, e.g. "to" | "subject" | "body" | "surveyId". */ + field: string; +}; + +/** + * The editable draft fields exactly as last persisted (or hydrated). Compared against the live + * draft to derive dirtiness; kept as a client-side snapshot of what was SENT (not the server + * response) so server-side normalization can never make the editor look permanently dirty. + */ +export type TWorkflowSavedDraft = { + workflowName: string; + workflowDescription: string; + definition: TWorkflowDefinition | null; +}; + +/** + * The last save the server did not accept. Persistent by design: a failed autosave has no other + * channel to report into (silent saves don't toast), so this is what the header pill reads to stay + * red until a save succeeds. It doubles as the autosave no-retry guard — the effect refuses to + * re-send `draftSignature`, so a broken draft can't loop one PATCH per debounce window. + */ +export type TWorkflowSaveError = { + /** The exact draft that failed, as produced by workflowDraftSignatureAtom. */ + draftSignature: string; + /** + * "unreachable" = no response at all (offline, DNS, the 15s AbortSignal.timeout). Worth an + * automatic retry once the browser reports it is back online. + * "rejected" = the API answered with a problem document. Reconnecting changes nothing. + */ + kind: "unreachable" | "rejected"; + /** Server-authored RFC 9457 detail, safe to show. Only populated for "rejected". */ + detail: string | null; +}; + +type TWorkflowEditorState = { + workflow: TWorkflowResource | null; + workflowName: string; + workflowDescription: string; + definition: TWorkflowDefinition | null; + lastSavedDraft: TWorkflowSavedDraft | null; + /** Epoch ms of the last successful save this session; drives the "Changes saved" flash. */ + lastSavedAt: number | null; + saveError: TWorkflowSaveError | null; + selectedNodeId: string | null; + fieldFocusRequest: TWorkflowNodeFieldFocusRequest | null; + isInspectorCollapsed: boolean; + isSnapToCanvasEnabled: boolean; + isNodeConfigModalOpen: boolean; + isSaving: boolean; + isTransitioning: boolean; +}; + +const initialWorkflowEditorState: TWorkflowEditorState = { + workflow: null, + workflowName: "", + workflowDescription: "", + definition: null, + lastSavedDraft: null, + lastSavedAt: null, + saveError: null, + selectedNodeId: null, + fieldFocusRequest: null, + isInspectorCollapsed: false, + isSnapToCanvasEnabled: true, + isNodeConfigModalOpen: false, + isSaving: false, + isTransitioning: false, +}; + +export const workflowEditorAtom = atom(initialWorkflowEditorState); + +export const workflowAtom = atom((get) => get(workflowEditorAtom).workflow); +export const workflowNameAtom = atom((get) => get(workflowEditorAtom).workflowName); +export const workflowDescriptionAtom = atom((get) => get(workflowEditorAtom).workflowDescription); +export const workflowDefinitionAtom = atom((get) => get(workflowEditorAtom).definition); +export const selectedWorkflowNodeIdAtom = atom((get) => get(workflowEditorAtom).selectedNodeId); +export const workflowNodeFieldFocusRequestAtom = atom((get) => get(workflowEditorAtom).fieldFocusRequest); + +// ReactFlow's nodes live OUTSIDE the immer-produced editor state on purpose: immer auto-freezes +// everything it produces, and ReactFlow mutates node internals (measured width/height) inside +// applyNodeChanges — on frozen nodes that throws "Cannot assign to read only property 'width'". +// A plain base atom keeps them mutable; jotai's setter already supports functional updates. +export const workflowFlowNodesAtom = atom>>([]); +export const isWorkflowInspectorCollapsedAtom = atom((get) => get(workflowEditorAtom).isInspectorCollapsed); +export const isWorkflowSnapToCanvasEnabledAtom = atom((get) => get(workflowEditorAtom).isSnapToCanvasEnabled); +export const isWorkflowNodeConfigModalOpenAtom = atom((get) => get(workflowEditorAtom).isNodeConfigModalOpen); +export const isWorkflowSavingAtom = atom((get) => get(workflowEditorAtom).isSaving); +export const isWorkflowTransitioningAtom = atom((get) => get(workflowEditorAtom).isTransitioning); + +// Whether the trigger's surveyId resolves to a real survey. Owned by the builder page, which is +// the only place holding the server-resolved email authoring context; defaults to true so nothing +// flashes as broken before the page syncs it. +export const hasBoundTriggerSurveyAtom = atom(true); + +// Trigger ending-card ids the reconcile dropped this session (see useReconcileTriggerEndingCards). +// Client-only, not part of the definition: it records that the user's "specific endings" intent was +// emptied by now-deleted ids — which an empty `endingCardIds` ("all endings") can't express — so the +// trigger form keeps "specific" scope and asks for a fresh pick. +export const prunedTriggerEndingCardIdsAtom = atom([]); + +const toSavedDraft = (state: TWorkflowEditorState): TWorkflowSavedDraft => ({ + workflowName: state.workflowName.trim(), + workflowDescription: state.workflowDescription.trim(), + definition: state.definition, +}); + +// Records the just-persisted draft. The save flow passes the exact values it sent (captured +// before the request) so edits landing while the PATCH was in flight still read as dirty. +export const markWorkflowDraftSavedAtom = atom(null, (get, set, savedDraft: TWorkflowSavedDraft) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.lastSavedDraft = savedDraft; + draft.lastSavedAt = Date.now(); + // Cleared in the same write as lastSavedAt so the pill can never render the failed state and + // the "Changes saved" flash across two commits. Covers every save path at once: autosave, + // the unmount flush and the pre-transition flush all land here on success. + draft.saveError = null; + }) + ); +}); + +export const workflowLastSavedAtAtom = atom((get) => get(workflowEditorAtom).lastSavedAt); + +// True while the editable draft (name, description, definition) differs from what was last +// persisted. Trimmed comparison so trailing whitespace the save flow strips anyway never counts. +export const isWorkflowDirtyAtom = atom((get) => { + const state = get(workflowEditorAtom); + if (!state.workflow || !state.lastSavedDraft) return false; + const current = toSavedDraft(state); + return ( + current.workflowName !== state.lastSavedDraft.workflowName || + current.workflowDescription !== state.lastSavedDraft.workflowDescription || + JSON.stringify(current.definition) !== JSON.stringify(state.lastSavedDraft.definition) + ); +}); + +// Identity of the current editable draft. Derived (rather than recomputed per render by each +// consumer) so the value save() records as failed and the value the autosave effect compares +// against are the same string by construction. Raw, untrimmed: this identifies a draft, it does not +// decide dirtiness — isWorkflowDirtyAtom above owns that and trims. +export const workflowDraftSignatureAtom = atom((get) => { + const state = get(workflowEditorAtom); + return JSON.stringify({ + workflowName: state.workflowName, + workflowDescription: state.workflowDescription, + definition: state.definition, + }); +}); + +export const workflowSaveErrorAtom = atom((get) => get(workflowEditorAtom).saveError); + +// Drives the header pill's failed state. Gated on dirtiness so reverting the draft back to what was +// last persisted clears the alarm: nothing is unsaved, so nothing should read "Save failed". Retry +// semantics are unaffected — the autosave effect guards on the raw signature, not this flag. +export const hasWorkflowSaveFailedAtom = atom( + (get) => get(workflowEditorAtom).saveError !== null && get(isWorkflowDirtyAtom) +); + +export type TWorkflowValidity = { + /** The workflow has a non-empty name (required by the PATCH contract). */ + isNameValid: boolean; + /** The definition passes the same executable-subset schema enable/test enforce server-side. */ + isDefinitionExecutable: boolean; + /** The trigger's surveyId resolves to a real survey (see hasBoundTriggerSurveyAtom). */ + hasBoundTriggerSurvey: boolean; + /** + * The editor is hydrated and the derived problem list is empty. Matches the server's enable + * pre-flight except its ending-cards check, which needs server data (see the component-level + * merge in WorkflowValidationStatus) — the server still rejects enable on stale endings. + */ + isReady: boolean; +}; + +export type TWorkflowValidationProblemCode = + | "name_missing" + | "trigger_missing" + | "trigger_survey_unbound" + | "trigger_ending_not_found" + | "trigger_not_connected" + | "flow_invalid" + | "step_not_executable" + | "step_incomplete" + | "definition_invalid"; + +export type TWorkflowValidationProblem = { + /** Machine-readable category the problems dialog localizes per code. */ + code: TWorkflowValidationProblemCode; + /** Dotted path into the definition (or "name"), mirroring the server's test-problem fields. */ + field: string; +}; + +/** + * The `field` paths a validation problem can carry, as builders + matchers rather than bare + * strings. The problems dialog resolves these back to a control to focus (see + * `getWorkflowValidationProblemFocusTarget`), so producer and consumer must agree — with literals + * on both sides, renaming one silently stops the dialog's "Fix" jump from resolving, with nothing + * failing to catch it. Anything that reads or writes a problem `field` goes through here. + */ +export const workflowProblemFields = { + name: "name", + trigger: "trigger", + triggerSurveyId: "trigger.config.surveyId", + triggerEndingCardIds: "trigger.config.endingCardIds", + /** The step-level path `step_incomplete` groups a send_email node's blank fields under. */ + nodeConfig: (nodeIndex: number) => `nodes.${nodeIndex}.config`, + /** Index back out of a `nodeConfig` path; null for any other shape (`nodes.N.type`, `edges`, …). */ + parseNodeConfigIndex: (field: string): number | null => { + const match = /^nodes\.(\d+)\.config$/.exec(field); + return match ? Number(match[1]) : null; + }, +} as const; + +// Buckets one ZWorkflowExecutableDefinition issue by its path. Total: every issue maps to some +// code (worst case the generic fallback), so a failed parse always yields at least one problem +// and the problem list can never read "valid" while workflowValidityAtom says not executable. +const categorizeDefinitionIssue = ( + issuePath: ReadonlyArray, + context: { hasTrigger: boolean; isTriggerConnected: boolean } +): TWorkflowValidationProblem => { + const path = issuePath.map(String); + const field = path.join(".") || "definition"; + const [root, , third] = path; + + if (root === "trigger" || root === "entryNodeId") { + // Without a trigger both fields fail together; normalizing the field collapses them into a + // single "add a trigger" problem. With a trigger present this is an unexpected shape issue. + return context.hasTrigger + ? { code: "definition_invalid", field } + : { code: "trigger_missing", field: workflowProblemFields.trigger }; + } + + if (root === "edges") { + // Several rules report at the bare `edges` path: the exactly-one-trigger-edge check, the + // if_else then/else branch counts, and the acyclic check. While the trigger has no outgoing + // edge they are all attributed to the unconnected trigger — a known conflation, but a bounded + // one: the editor cannot author branch/cycle failures in that state, and the API-authored + // definitions that can also carry companion problems (step_not_executable, unreachable + // nodes) that keep the count honest until the trigger is connected and these resurface as + // flow_invalid. + if (path.length === 1 && !context.isTriggerConnected) { + return { code: "trigger_not_connected", field }; + } + return { code: "flow_invalid", field }; + } + + if (root === "nodes") { + if (path.length === 1) { + // Unreachable or duplicated nodes — the graph shape is broken. + return { code: "flow_invalid", field }; + } + if (third === "type") { + // if_else nodes persist fine but cannot run in this version of workflows. + return { code: "step_not_executable", field }; + } + if (third === "config") { + // Missing required content (send_email to/subject/body). Grouped per step, not per empty + // field, so the badge count matches what the user perceives as one unfinished step. + return { code: "step_incomplete", field: workflowProblemFields.nodeConfig(Number(path[1])) }; + } + } + + return { code: "definition_invalid", field }; +}; + +export type TWorkflowValidation = { + problems: TWorkflowValidationProblem[]; + validity: TWorkflowValidity; +}; + +/** + * The single implementation of the editor's live readiness rules (name, survey binding, + * executable-subset schema): one safeParse produces both the typed problem list the canvas + * status indicator counts and the boolean validity the header's Enable gate reads. `isReady` + * is defined AS "hydrated with an empty problem list", so the two views can never disagree. + */ +export const deriveWorkflowValidation = ({ + workflowName, + definition, + hasBoundTriggerSurvey, +}: { + workflowName: string; + definition: TWorkflowDefinition | null; + hasBoundTriggerSurvey: boolean; +}): TWorkflowValidation => { + const problems: TWorkflowValidationProblem[] = []; + + const isNameValid = workflowName.trim().length > 0; + if (!isNameValid) { + problems.push({ code: "name_missing", field: workflowProblemFields.name }); + } + + // No definition means the editor isn't hydrated yet; there is nothing to validate (the status + // indicator doesn't render before hydration either) and the workflow cannot be ready. + let isDefinitionExecutable = false; + if (definition) { + const hasTrigger = definition.trigger !== null; + // The survey binding is only meaningful once a trigger exists — for trigger-less drafts the + // builder page reports it unbound, but `trigger_missing` already says everything. + if (hasTrigger && !hasBoundTriggerSurvey) { + problems.push({ code: "trigger_survey_unbound", field: workflowProblemFields.triggerSurveyId }); + } + + const parsed = ZWorkflowExecutableDefinition.safeParse(definition); + isDefinitionExecutable = parsed.success; + if (!parsed.success) { + const isTriggerConnected = definition.edges.some((edge) => edge.source === definition.trigger?.id); + for (const issue of parsed.error.issues) { + problems.push(categorizeDefinitionIssue(issue.path, { hasTrigger, isTriggerConnected })); + } + } + } + + // Categorization intentionally funnels related issues into one bucket (e.g. a missing trigger, + // a step's empty fields); dropping the duplicates keeps the badge count honest. + const seen = new Set(); + const dedupedProblems = problems.filter((problem) => { + const key = `${problem.code}:${problem.field}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + return { + problems: dedupedProblems, + validity: { + isNameValid, + isDefinitionExecutable, + hasBoundTriggerSurvey, + isReady: definition !== null && dedupedProblems.length === 0, + }, + }; +}; + +/** + * The one readiness rule that needs server data: whether the trigger's configured ending cards + * still exist on the bound survey. Server data lives in the TanStack Query cache (never mirrored + * into Jotai), so WorkflowValidationStatus merges this at the component level instead of inside + * workflowValidationProblemsAtom — meaning workflowValidityAtom.isReady (the header's Enable + * gate) deliberately does not include it; the server's enable pre-flight still rejects stale + * endings. Grouped like step_incomplete: however many endings went stale, it is one problem. + */ +export const deriveTriggerEndingProblems = ( + endingCardIds: readonly string[], + availableEndingIds: readonly string[] +): TWorkflowValidationProblem[] => { + const available = new Set(availableEndingIds); + const hasMissingEnding = endingCardIds.some((endingCardId) => !available.has(endingCardId)); + return hasMissingEnding + ? [{ code: "trigger_ending_not_found", field: workflowProblemFields.triggerEndingCardIds }] + : []; +}; + +// Live validation, recomputed on every draft edit. Mirrors the server's enable/test checks +// (ZWorkflowExecutableDefinition plus the survey binding) so the editor can flag an unready +// workflow immediately instead of after a round-trip. Private: consumers read the slices below. +const workflowValidationAtom = atom((get) => { + const state = get(workflowEditorAtom); + return deriveWorkflowValidation({ + workflowName: state.workflowName, + definition: state.definition, + hasBoundTriggerSurvey: get(hasBoundTriggerSurveyAtom), + }); +}); + +export const workflowValidityAtom = atom((get) => get(workflowValidationAtom).validity); + +// The problem list behind the canvas's validation status indicator and its dialog. +export const workflowValidationProblemsAtom = atom((get) => get(workflowValidationAtom).problems); + +// Locked = pan mode (pan/browse; nodes are inert), unlocked = pointer mode (select/inspect, +// edit when status/permissions allow). Pointer is the default tool and the toggle is purely +// user-driven — nothing auto-switches it; mutations are gated separately by canMutateCanvasAtom. +export const isCanvasLockedAtom = atom(false); + +// Derived: the workflow's definition is editable when its status allows it (the API rejects +// definition PATCHes on enabled / archived workflows). The auth-derived `isReadOnly` flag is +// applied separately by the hook layer; this atom is the status check the canvas + inspector +// share for hiding/disabling structural controls. +export const canEditWorkflowDefinitionAtom = atom((get) => { + const status = get(workflowEditorAtom).workflow?.status; + return status === "draft" || status === "disabled"; +}); + +// Combined gate for any interactive canvas affordance (add + delete + drag + auto-layout). +// Both the API-side status check AND the user-driven lock must allow it. +export const canMutateCanvasAtom = atom( + (get) => get(canEditWorkflowDefinitionAtom) && !get(isCanvasLockedAtom) +); + +export const setWorkflowSaveErrorAtom = atom(null, (get, set, saveError: TWorkflowSaveError | null) => { + // Bail when nothing changes so a redundant clear (e.g. an `online` event with no failure pending) + // doesn't produce a new state object and re-render every editor subscriber. + if (get(workflowEditorAtom).saveError === saveError) return; + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.saveError = saveError; + }) + ); +}); + +export const setWorkflowSavingAtom = atom(null, (get, set, isSaving: boolean) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.isSaving = isSaving; + }) + ); +}); + +export const setWorkflowTransitioningAtom = atom(null, (get, set, isTransitioning: boolean) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.isTransitioning = isTransitioning; + }) + ); +}); + +export const hydrateWorkflowEditorAtom = atom( + null, + ( + _get, + set, + { + workflow, + flowNodes, + }: { + workflow: TWorkflowResource; + flowNodes: Array>; + } + ) => { + // Optimistic default until the builder page re-syncs it from the authoring context; + // without the reset, a previous workflow's "unbound" state would flash on the next one. + set(hasBoundTriggerSurveyAtom, true); + // Same reasoning: a previous workflow's pruned ids must not leak into this one's trigger form. + set(prunedTriggerEndingCardIdsAtom, []); + // Set outside the produce below so the nodes stay unfrozen (see workflowFlowNodesAtom). + set(workflowFlowNodesAtom, flowNodes); + set( + workflowEditorAtom, + produce(initialWorkflowEditorState, (draft) => { + draft.workflow = workflow; + draft.workflowName = workflow.name; + draft.workflowDescription = workflow.description ?? ""; + draft.definition = workflow.definition; + // Freshly hydrated means nothing is dirty yet; the saved snapshot is the loaded state. + draft.lastSavedDraft = { + workflowName: workflow.name.trim(), + workflowDescription: (workflow.description ?? "").trim(), + definition: workflow.definition, + }; + draft.selectedNodeId = workflow.definition.trigger?.id ?? null; + }) + ); + } +); + +// Updates the server-owned workflow snapshot only; leaves the editable draft fields +// (workflowName, workflowDescription, definition) intact so unsaved edits aren't wiped +// out by save responses or lifecycle transitions that race with the user's typing. +export const setWorkflowAtom = atom(null, (get, set, workflow: TWorkflowResource) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.workflow = workflow; + }) + ); +}); + +export const setWorkflowNameAtom = atom(null, (get, set, workflowName: string) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.workflowName = workflowName; + }) + ); +}); + +export const setWorkflowDescriptionAtom = atom(null, (get, set, workflowDescription: string) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.workflowDescription = workflowDescription; + }) + ); +}); + +export const setWorkflowDefinitionAtom = atom( + null, + (get, set, update: SetStateAction) => { + const currentState = get(workflowEditorAtom); + const nextDefinition = typeof update === "function" ? update(currentState.definition) : update; + + set( + workflowEditorAtom, + produce(currentState, (draft) => { + draft.definition = nextDefinition; + }) + ); + } +); + +export const setSelectedWorkflowNodeIdAtom = atom(null, (get, set, selectedNodeId: string | null) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.selectedNodeId = selectedNodeId; + }) + ); +}); + +export const openWorkflowNodeConfigModalAtom = atom(null, (get, set, nodeId: string) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.selectedNodeId = nodeId; + draft.isNodeConfigModalOpen = true; + // Clicking a node opens the inspector even if the user previously collapsed it — + // otherwise the config view stays hidden and the click looks broken. + draft.isInspectorCollapsed = false; + // A plain node click is not a jump: drop any request a form never got to consume (e.g. one + // aimed at a node whose type has no config form) so it can't fire later out of context. + draft.fieldFocusRequest = null; + }) + ); +}); + +// Jump to a specific field: same panel-opening effect as clicking the node, plus the pending +// focus request its config form consumes. Raised by the validation problems dialog. +export const requestWorkflowNodeFieldFocusAtom = atom( + null, + (get, set, request: TWorkflowNodeFieldFocusRequest) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.selectedNodeId = request.nodeId; + draft.isNodeConfigModalOpen = true; + draft.isInspectorCollapsed = false; + draft.fieldFocusRequest = request; + }) + ); + } +); + +// Cleared by the form once it has focused the control, so remounting the panel later (e.g. after +// switching nodes and back) doesn't re-fire a stale jump. +export const clearWorkflowNodeFieldFocusAtom = atom(null, (get, set) => { + if (!get(workflowEditorAtom).fieldFocusRequest) return; + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.fieldFocusRequest = null; + }) + ); +}); + +export const closeWorkflowNodeConfigModalAtom = atom(null, (get, set) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.isNodeConfigModalOpen = false; + }) + ); +}); + +export const toggleWorkflowInspectorAtom = atom(null, (get, set) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.isInspectorCollapsed = !draft.isInspectorCollapsed; + }) + ); +}); + +// Drop a node + its incident edges; bridge each incoming edge to each outgoing one so the +// graph stays connected (preserving the incoming sourceHandle for if_else branches). Refuses +// to delete the trigger. +export const deleteWorkflowNodeAtom = atom(null, (get, set, nodeId: string) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + const definition = draft.definition; + if (!definition) return; + if (definition.trigger?.id === nodeId) return; + + const incoming = definition.edges.filter((edge) => edge.target === nodeId); + const outgoing = definition.edges.filter((edge) => edge.source === nodeId); + + definition.nodes = definition.nodes.filter((node) => node.id !== nodeId); + definition.edges = definition.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId); + + for (const inEdge of incoming) { + for (const outEdge of outgoing) { + definition.edges.push({ + id: createId(), + source: inEdge.source, + target: outEdge.target, + ...(inEdge.sourceHandle ? { sourceHandle: inEdge.sourceHandle } : {}), + }); + } + } + + if (draft.selectedNodeId === nodeId) { + draft.selectedNodeId = definition.trigger?.id ?? null; + draft.isNodeConfigModalOpen = false; + } + }) + ); +}); + +// Seed the trigger on an empty draft canvas (the "Add trigger" picker). The placeholder +// surveyId keeps the config schema-valid until the user binds a real survey; opening the +// config panel right away walks them into doing exactly that. +export const addWorkflowTriggerAtom = atom(null, (get, set, triggerType: TWorkflowTriggerType) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + const definition = draft.definition; + if (!definition || definition.trigger) return; + if (triggerType !== WORKFLOW_TRIGGERS.RESPONSE_COMPLETED) return; + + const triggerId = createId(); + definition.trigger = { + id: triggerId, + type: "trigger", + triggerType, + config: { + surveyId: createId(), + endingCardIds: [], + }, + // Matches WORKFLOW_CANVAS_START_POSITION in definition-to-flow (not imported to avoid a cycle). + ui: { position: { x: 220, y: 80 } }, + }; + definition.entryNodeId = triggerId; + + draft.selectedNodeId = triggerId; + draft.isNodeConfigModalOpen = true; + }) + ); +}); + +// Fallback canvas position when the reference node/edge carries no stored position. +const SEND_EMAIL_FALLBACK_POSITION = { x: 220, y: 200 }; + +// The default send_email node both insertion atoms create (append-after-node and split-edge). +// The position is cloned so no two nodes ever share one mutable position object. +const createDefaultSendEmailNode = (position: { + x: number; + y: number; +}): TWorkflowDefinition["nodes"][number] => ({ + id: createId(), + type: "action", + actionType: WORKFLOW_ACTIONS.SEND_EMAIL, + // No hardcoded label: the node title falls back to the translated + // `workspace.workflows.send_email` key (see node-registry). A label is only set when the + // user renames the node, so the default stays localized. + config: { + to: "", + from: "team@example.com", + replyTo: [], + subject: "", + body: "", + attachResponseData: false, + }, + ui: { position: { ...position } }, +}); + +// Append a fresh send_email node after `sourceNodeId` (used when the source has no outgoing +// edge yet — i.e. the user is starting the chain). +export const appendSendEmailAfterNodeAtom = atom(null, (get, set, sourceNodeId: string) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + const definition = draft.definition; + if (!definition) return; + + const sourcePosition = + definition.trigger?.id === sourceNodeId + ? definition.trigger.ui?.position + : definition.nodes.find((node) => node.id === sourceNodeId)?.ui?.position; + const newPosition = sourcePosition + ? { x: sourcePosition.x, y: sourcePosition.y + 120 } + : SEND_EMAIL_FALLBACK_POSITION; + + const newNode = createDefaultSendEmailNode(newPosition); + definition.nodes.push(newNode); + + definition.edges.push({ id: createId(), source: sourceNodeId, target: newNode.id }); + + draft.selectedNodeId = newNode.id; + draft.isNodeConfigModalOpen = true; + }) + ); +}); + +// Split an existing edge with a fresh send_email node positioned between its endpoints. +export const insertSendEmailAfterEdgeAtom = atom(null, (get, set, edgeId: string) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + const definition = draft.definition; + if (!definition) return; + const edgeIndex = definition.edges.findIndex((edge) => edge.id === edgeId); + if (edgeIndex < 0) return; + const edge = definition.edges[edgeIndex]; + + const positionOf = (nodeId: string) => { + if (definition.trigger?.id === nodeId) return definition.trigger.ui?.position; + return definition.nodes.find((node) => node.id === nodeId)?.ui?.position; + }; + const sourcePosition = positionOf(edge.source); + const targetPosition = positionOf(edge.target); + const midpoint = + sourcePosition && targetPosition + ? { + x: Math.round((sourcePosition.x + targetPosition.x) / 2), + y: Math.round((sourcePosition.y + targetPosition.y) / 2), + } + : SEND_EMAIL_FALLBACK_POSITION; + + const newNode = createDefaultSendEmailNode(midpoint); + definition.nodes.push(newNode); + + definition.edges.splice( + edgeIndex, + 1, + { + id: createId(), + source: edge.source, + target: newNode.id, + ...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}), + }, + { id: createId(), source: newNode.id, target: edge.target } + ); + + draft.selectedNodeId = newNode.id; + draft.isNodeConfigModalOpen = true; + }) + ); +}); + +export const setWorkflowSnapToCanvasEnabledAtom = atom(null, (get, set, isSnapToCanvasEnabled: boolean) => { + set( + workflowEditorAtom, + produce(get(workflowEditorAtom), (draft) => { + draft.isSnapToCanvasEnabled = isSnapToCanvasEnabled; + }) + ); +}); diff --git a/apps/web/modules/ee/workflows/types.ts b/apps/web/modules/ee/workflows/types.ts new file mode 100644 index 000000000000..311ce723994f --- /dev/null +++ b/apps/web/modules/ee/workflows/types.ts @@ -0,0 +1,9 @@ +// Run shapes the UI consumes, re-exported from the workflows contract package so components import +// them from one module. The list endpoint returns the slim summary plus the joined workflow name +// (TWorkflowRunListItem); the detail endpoint returns the full resource with step logs, trigger +// payload, and run data (TWorkflowRunDetail). +export type { + TWorkflowRunListItem, + TWorkflowRunResource as TWorkflowRunDetail, + TWorkflowRunSummary, +} from "@formbricks/workflows"; diff --git a/apps/web/modules/ee/workflows/types/email-authoring-context.ts b/apps/web/modules/ee/workflows/types/email-authoring-context.ts new file mode 100644 index 000000000000..0ee56c5ed937 --- /dev/null +++ b/apps/web/modules/ee/workflows/types/email-authoring-context.ts @@ -0,0 +1,16 @@ +import type { TSurvey } from "@formbricks/types/surveys/types"; +import type { TUserLocale } from "@formbricks/types/user"; +import type { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up"; + +/** + * Data the workflow `send_email` inspector form needs to author an email with survey Follow-Ups + * parity. Resolved server-side and threaded to the client editor as props (see + * `getWorkflowEmailAuthoringContext`). `survey` is `null` when no survey is bound / it was deleted. + */ +export interface TWorkflowEmailAuthoringContext { + survey: TSurvey | null; + teamMemberDetails: TFollowUpEmailToUser[]; + userEmail: string; + mailFrom: string; + locale: TUserLocale; +} diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index b678184f3672..36006058c2d3 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -63,10 +63,13 @@ const legalProps: TEmailTemplateLegalProps = { interface SendEmailDataProps { to: string; + from?: string; replyTo?: string; subject: string; text?: string; html: string; + /** Optional RFC 5322 Message-ID; nodemailer emits it as the `Message-ID` header. */ + messageId?: string; } export type TResponseFinishedEmailSurvey = TElementResponseMappingSurvey & @@ -101,7 +104,11 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise const emailDefaults = { from: `${MAIL_FROM_NAME ?? "Formbricks"} <${MAIL_FROM ?? "noreply@formbricks.com"}>`, }; - await transporter.sendMail({ ...emailDefaults, ...emailData }); + await transporter.sendMail({ + ...emailDefaults, + ...emailData, + from: emailData.from ?? emailDefaults.from, + }); return true; } catch (error) { @@ -338,14 +345,11 @@ export const sendResponseFinishedEmail = async ( await sendEmail({ to: email, - subject: personEmail - ? t("emails.response_finished_email_subject_with_email", { - personEmail, - surveyName: survey.name, - }) - : t("emails.response_finished_email_subject", { - surveyName: survey.name, - }), + // Never put the respondent's email address in the subject — it's PII that ends up in + // notification previews and mailbox lists; replying still reaches them via replyTo. + subject: t("emails.response_finished_email_subject", { + surveyName: survey.name, + }), replyTo: personEmail?.toString() ?? MAIL_FROM, html, }); diff --git a/apps/web/modules/email/lib/survey-response-email.test.ts b/apps/web/modules/email/lib/survey-response-email.test.ts new file mode 100644 index 000000000000..68596ae9516a --- /dev/null +++ b/apps/web/modules/email/lib/survey-response-email.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TResponse } from "@formbricks/types/responses"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import { buildSurveyResponseEmailHtml, resolveResponseRecipient } from "./survey-response-email"; + +const { + mockRenderFollowUpEmail, + mockGetElementResponseMapping, + mockParseRecallInfo, + mockResolveStorageUrl, + mockGetTranslate, +} = vi.hoisted(() => ({ + mockRenderFollowUpEmail: vi.fn(), + mockGetElementResponseMapping: vi.fn(), + mockParseRecallInfo: vi.fn(), + mockResolveStorageUrl: vi.fn(), + mockGetTranslate: vi.fn(), +})); + +vi.mock("@formbricks/email", () => ({ + renderFollowUpEmail: mockRenderFollowUpEmail, +})); + +vi.mock("@/lib/responses", () => ({ + getElementResponseMapping: mockGetElementResponseMapping, +})); + +vi.mock("@/lib/utils/recall", () => ({ + parseRecallInfo: mockParseRecallInfo, +})); + +vi.mock("@/modules/storage/utils", () => ({ + resolveStorageUrl: mockResolveStorageUrl, +})); + +vi.mock("@/lingodotdev/server", () => ({ + getTranslate: mockGetTranslate, +})); + +const response = { + id: "cm9zr4rsp000708l8bqccpfrx", + surveyId: "cm9zr4mps000008l8btfy1vtz", + data: { email: "jane@example.com", name: "Jane", utm: "newsletter" }, + variables: { var1: "pro" }, + language: "en-US", +} as unknown as TResponse; + +const survey = { + id: "cm9zr4mps000008l8btfy1vtz", + blocks: [], + languages: [], + variables: [{ id: "var1", name: "plan", type: "text" }], + hiddenFields: { enabled: true, fieldIds: ["utm"] }, +} as unknown as TSurvey; + +describe("resolveResponseRecipient", () => { + test("uses a literal email `to` directly", () => { + expect(resolveResponseRecipient("teammate@example.com", response)).toEqual({ + ok: true, + email: "teammate@example.com", + }); + }); + + test("resolves a question/hidden-field id to a string email in the response", () => { + expect(resolveResponseRecipient("email", response)).toEqual({ ok: true, email: "jane@example.com" }); + }); + + test("resolves a contact-info array element using index [2]", () => { + const contactResponse = { + ...response, + data: { contact: ["Jane", "Doe", "jane@example.com", "+123"] }, + } as unknown as TResponse; + expect(resolveResponseRecipient("contact", contactResponse)).toEqual({ + ok: true, + email: "jane@example.com", + }); + }); + + test("fails when the id is missing from the response data", () => { + const result = resolveResponseRecipient("missing", response); + expect(result.ok).toBe(false); + }); + + test("fails when the resolved string is not a valid email", () => { + const badResponse = { ...response, data: { email: "not-an-email" } } as unknown as TResponse; + const result = resolveResponseRecipient("email", badResponse); + expect(result.ok).toBe(false); + }); + + test("fails when the contact-info array has no email at index [2]", () => { + const contactResponse = { ...response, data: { contact: ["Jane", "Doe"] } } as unknown as TResponse; + const result = resolveResponseRecipient("contact", contactResponse); + expect(result.ok).toBe(false); + }); +}); + +describe("buildSurveyResponseEmailHtml", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTranslate.mockResolvedValue((key: string) => key); + mockRenderFollowUpEmail.mockResolvedValue("rendered"); + mockParseRecallInfo.mockImplementation((body: string) => body); + mockGetElementResponseMapping.mockReturnValue([]); + mockResolveStorageUrl.mockImplementation((url: string) => `https://cdn.example.com/${url}`); + }); + + const sanitizedBodyOf = (html: string): Promise => { + mockParseRecallInfo.mockReturnValue(html); + return buildSurveyResponseEmailHtml({ + body: "irrelevant", + survey, + response, + attachResponseData: false, + }).then(() => mockRenderFollowUpEmail.mock.calls[0][0].body as string); + }; + + test("sanitizes the recall-parsed body before rendering (drops disallowed markup)", async () => { + mockParseRecallInfo.mockReturnValue('

Hi Jane

'); + + await buildSurveyResponseEmailHtml({ + body: "#recall:name/fallback:there#", + survey, + response, + attachResponseData: false, + }); + + // The trailing `true` is `escapeValues`: recall values are escaped as they are substituted. The + // allowlist below permits `` for author-written body HTML, so without escaping a + // respondent could smuggle a clickable link through an open-text answer into an owner-facing + // email. The locale is passed explicitly so recalled date answers aren't formatted as en-US. + expect(mockParseRecallInfo).toHaveBeenCalledWith( + "#recall:name/fallback:there#", + response.data, + response.variables, + false, + "en-US", + undefined, + true + ); + const rendered = mockRenderFollowUpEmail.mock.calls[0][0]; + expect(rendered.body).toBe("

Hi Jane

"); + expect(rendered.body).not.toContain("" + + '
cell
' + ); + + expect(body).not.toMatch(/<(script|style|iframe|img|table|tr|td)\b/); + expect(body).not.toContain("onerror"); + }); + + test("omits response data / variables / hidden fields when attachResponseData is off", async () => { + await buildSurveyResponseEmailHtml({ + body: "Body", + survey, + response, + attachResponseData: false, + includeVariables: true, + includeHiddenFields: true, + }); + + const rendered = mockRenderFollowUpEmail.mock.calls[0][0]; + expect(rendered.responseData).toEqual([]); + expect(rendered.variables).toEqual([]); + expect(rendered.hiddenFields).toEqual([]); + expect(mockGetElementResponseMapping).not.toHaveBeenCalled(); + }); + + test("includes response data and resolves storage URLs for file/picture elements", async () => { + mockGetElementResponseMapping.mockReturnValue([ + { element: "Upload", response: ["file1.png"], type: TSurveyElementTypeEnum.FileUpload }, + { element: "Name", response: "Jane", type: TSurveyElementTypeEnum.OpenText }, + ]); + + await buildSurveyResponseEmailHtml({ + body: "Body", + survey, + response, + attachResponseData: true, + }); + + const rendered = mockRenderFollowUpEmail.mock.calls[0][0]; + expect(rendered.responseData).toEqual([ + { + element: "Upload", + response: ["https://cdn.example.com/file1.png"], + type: TSurveyElementTypeEnum.FileUpload, + }, + { element: "Name", response: "Jane", type: TSurveyElementTypeEnum.OpenText }, + ]); + }); + + test("gates variables behind includeVariables and hidden fields behind includeHiddenFields", async () => { + const withVars = await buildSurveyResponseEmailHtml({ + body: "Body", + survey, + response, + attachResponseData: true, + includeVariables: true, + includeHiddenFields: false, + }); + expect(withVars).toBe("rendered"); + + let rendered = mockRenderFollowUpEmail.mock.calls[0][0]; + expect(rendered.variables).toEqual([{ id: "var1", name: "plan", type: "text", value: "pro" }]); + expect(rendered.hiddenFields).toEqual([]); + + mockRenderFollowUpEmail.mockClear(); + + await buildSurveyResponseEmailHtml({ + body: "Body", + survey, + response, + attachResponseData: true, + includeVariables: false, + includeHiddenFields: true, + }); + rendered = mockRenderFollowUpEmail.mock.calls[0][0]; + expect(rendered.variables).toEqual([]); + expect(rendered.hiddenFields).toEqual([{ id: "utm", value: "newsletter" }]); + }); + + test("falls back to the default locale when none is provided", async () => { + await buildSurveyResponseEmailHtml({ body: "Body", survey, response, attachResponseData: false }); + expect(mockGetTranslate).toHaveBeenCalledWith("en-US"); + }); +}); diff --git a/apps/web/modules/email/lib/survey-response-email.ts b/apps/web/modules/email/lib/survey-response-email.ts new file mode 100644 index 000000000000..f0387a6819cb --- /dev/null +++ b/apps/web/modules/email/lib/survey-response-email.ts @@ -0,0 +1,223 @@ +import "server-only"; +import sanitizeHtml from "sanitize-html"; +import { z } from "zod"; +import { + ProcessedHiddenField, + ProcessedResponseElement, + ProcessedVariable, + renderFollowUpEmail, +} from "@formbricks/email"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { DEFAULT_LOCALE, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/constants"; +import { getElementResponseMapping } from "@/lib/responses"; +import { parseRecallInfo } from "@/lib/utils/recall"; +import { getTranslate } from "@/lingodotdev/server"; +import { resolveStorageUrl } from "@/modules/storage/utils"; + +/** + * Sanitize a recall-templated body the same way the survey Follow-Ups email does. Recall tags are + * expanded against the response first, then only a narrow HTML allowlist survives — so + * respondent-controlled recall values cannot inject markup or non-http(s) schemes. + * + * Recall values are escaped as they are substituted (`escapeValues`). The allowlist below legitimately + * permits `
` for author-written body HTML, so the sanitizer cannot tell that markup apart from + * markup a respondent smuggled in through an open-text answer — without escaping, an anonymous + * respondent could place an arbitrary clickable link into a Formbricks-branded email sent to the + * survey owner. + * + * `ul`/`ol`/`li` are on the list because `sanitize-html` drops a disallowed tag but keeps its text: + * without them the Body editor's list buttons produced items run together on one line, unnumbered. + */ +const sanitizeBody = (body: string, response: TResponse, locale?: TUserLocale): string => + sanitizeHtml( + // Pass the resolved locale rather than letting it default to "en-US": recall values include date + // answers, which parseRecallInfo formats per locale, so defaulting would render US dates in an + // otherwise correctly localized email. + parseRecallInfo( + body, + response.data, + response.variables, + false, + locale ?? DEFAULT_LOCALE, + undefined, + true + ), + { + allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br", "ul", "ol", "li"], + allowedAttributes: { + a: ["href", "rel", "target"], + // Lexical numbers list items explicitly; keep both so an `
    ` that starts at something other + // than 1 keeps its numbering instead of restarting. + ol: ["start"], + li: ["value"], + "*": ["dir", "class"], + }, + allowedSchemes: ["http", "https"], + allowedSchemesByTag: { + a: ["http", "https"], + }, + } + ); + +const buildResponseData = ( + survey: TSurvey, + response: TResponse, + attachResponseData: boolean +): ProcessedResponseElement[] => { + if (!attachResponseData) return []; + + return getElementResponseMapping(survey, response).map((e) => { + // Resolve relative storage URLs to absolute URLs for picture selection and file upload responses. + if ( + (e.type === TSurveyElementTypeEnum.PictureSelection || e.type === TSurveyElementTypeEnum.FileUpload) && + Array.isArray(e.response) + ) { + return { + element: e.element, + response: e.response.map((url) => resolveStorageUrl(url)), + type: e.type, + }; + } + return { + element: e.element, + response: e.response, + type: e.type, + }; + }); +}; + +const buildVariables = ( + survey: TSurvey, + response: TResponse, + attachResponseData: boolean, + includeVariables: boolean +): ProcessedVariable[] => { + if (!attachResponseData || !includeVariables) return []; + + return survey.variables + .filter((variable) => { + const variableResponse = response.variables[variable.id]; + return ( + (typeof variableResponse === "string" || typeof variableResponse === "number") && + variableResponse !== undefined + ); + }) + .map((variable) => ({ + id: variable.id, + name: variable.name, + type: variable.type, + value: response.variables[variable.id], + })); +}; + +const buildHiddenFields = ( + survey: TSurvey, + response: TResponse, + attachResponseData: boolean, + includeHiddenFields: boolean +): ProcessedHiddenField[] => { + if (!attachResponseData || !includeHiddenFields) return []; + + return ( + survey.hiddenFields.fieldIds + ?.filter((hiddenFieldId) => { + const hiddenFieldResponse = response.data[hiddenFieldId]; + return hiddenFieldResponse && typeof hiddenFieldResponse === "string"; + }) + .map((hiddenFieldId) => ({ + id: hiddenFieldId, + value: response.data[hiddenFieldId] as string, + })) ?? [] + ); +}; + +/** + * Builds the branded HTML for a survey-response email (Follow-Ups template): recall-expanded + + * sanitized body, plus the optional response-data / variables / hidden-fields blocks. Pure of any + * transport concern — the caller performs the actual send. Shared by survey Follow-Ups and the + * workflow `send_email` action so both render identically. + */ +export const buildSurveyResponseEmailHtml = async ({ + body, + survey, + response, + attachResponseData, + includeVariables = false, + includeHiddenFields = false, + logoUrl, + locale, +}: { + body: string; + survey: TSurvey; + response: TResponse; + attachResponseData: boolean; + includeVariables?: boolean; + includeHiddenFields?: boolean; + logoUrl?: string; + locale?: TUserLocale; +}): Promise => { + // Worker context (no request scope) — pass explicit locale to skip headers()/cookies(). + // Falls back to DEFAULT_LOCALE when the respondent locale wasn't captured at submission. + const t = await getTranslate(locale ?? DEFAULT_LOCALE); + + return renderFollowUpEmail({ + body: sanitizeBody(body, response, locale), + responseData: buildResponseData(survey, response, attachResponseData), + variables: buildVariables(survey, response, attachResponseData, includeVariables), + hiddenFields: buildHiddenFields(survey, response, attachResponseData, includeHiddenFields), + logoUrl, + t, + privacyUrl: PRIVACY_URL || undefined, + termsUrl: TERMS_URL || undefined, + imprintUrl: IMPRINT_URL || undefined, + imprintAddress: IMPRINT_ADDRESS || undefined, + }); +}; + +/** Outcome of resolving a `to` value (literal email or question/hidden-field id) against a response. */ +export type ResolveRecipientResult = { ok: true; email: string } | { ok: false; error: string }; + +/** + * Resolves an email recipient from a `to` value the same way survey Follow-Ups do: + * - if `to` is itself a valid email, use it directly (teammate/user address); + * - otherwise treat `to` as a question / hidden-field id and read `response.data[to]`: + * - a string is parsed as an email, + * - an array (contact-info element) uses index `[2]` as the email, + * - anything missing/invalid returns `{ ok: false }` with a clear reason. + */ +export const resolveResponseRecipient = (to: string, response: TResponse): ResolveRecipientResult => { + const parsedEmailTo = z.email().safeParse(to); + if (parsedEmailTo.success) { + return { ok: true, email: parsedEmailTo.data }; + } + + const toValueFromResponse = response.data[to]; + if (!toValueFromResponse) { + return { ok: false, error: "Recipient value not found in response data" }; + } + + if (typeof toValueFromResponse === "string") { + const parsedResult = z.email().safeParse(toValueFromResponse); + if (parsedResult.success) { + return { ok: true, email: parsedResult.data }; + } + return { ok: false, error: "Resolved email recipient is not a valid address" }; + } + + if (Array.isArray(toValueFromResponse)) { + const emailAddress = toValueFromResponse[2]; + if (!emailAddress) { + return { ok: false, error: "Email address not found in response data" }; + } + const parsedResult = z.email().safeParse(emailAddress); + if (parsedResult.success) { + return { ok: true, email: parsedResult.data }; + } + return { ok: false, error: "Resolved email recipient is not a valid address" }; + } + + return { ok: false, error: "Resolved email recipient is not a valid address" }; +}; diff --git a/apps/web/modules/entitlements/lib/checks.test.ts b/apps/web/modules/entitlements/lib/checks.test.ts index 7175bd454520..224102084f1b 100644 --- a/apps/web/modules/entitlements/lib/checks.test.ts +++ b/apps/web/modules/entitlements/lib/checks.test.ts @@ -27,7 +27,7 @@ const baseContext: TOrganizationEntitlementsContext = { organizationId: "org1", source: "cloud_stripe", features: ["rbac", "spam-protection"], - limits: { workspaces: 3, monthlyResponses: 500 }, + limits: { workspaces: 3, monthlyResponses: 500, monthlyWorkflowRuns: null }, licenseStatus: "no-license", licenseFeatures: null, stripeCustomerId: "cus_1", @@ -145,6 +145,26 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => { expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(false); }); + test("returns true when license active and workflows mapped feature enabled", async () => { + mockGetContext.mockResolvedValue({ + ...baseContext, + features: ["workflows"], + licenseStatus: "active", + licenseFeatures: { workflows: true } as TOrganizationEntitlementsContext["licenseFeatures"], + }); + expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "workflows")).toBe(true); + }); + + test("returns false when license active but workflows mapped feature disabled", async () => { + mockGetContext.mockResolvedValue({ + ...baseContext, + features: ["workflows"], + licenseStatus: "active", + licenseFeatures: { workflows: false } as TOrganizationEntitlementsContext["licenseFeatures"], + }); + expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "workflows")).toBe(false); + }); + test("returns true when license active and feature has no license mapping", async () => { mockGetContext.mockResolvedValue({ ...baseContext, @@ -172,6 +192,7 @@ describe("getOrganizationEntitlementLimits", () => { expect(await getOrganizationEntitlementLimits("org1")).toEqual({ workspaces: 3, monthlyResponses: 500, + monthlyWorkflowRuns: null, }); }); }); diff --git a/apps/web/modules/entitlements/lib/checks.ts b/apps/web/modules/entitlements/lib/checks.ts index 665573d5189d..8ce27ebef31f 100644 --- a/apps/web/modules/entitlements/lib/checks.ts +++ b/apps/web/modules/entitlements/lib/checks.ts @@ -13,6 +13,7 @@ const LICENSE_GUARDED_ENTITLEMENTS: Partial ({ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({ getOrganizationBillingWithReadThroughSync: vi.fn(), getDefaultOrganizationBilling: () => ({ - limits: { workspaces: 1, monthly: { responses: 250 } }, + limits: { workspaces: 1, monthly: { responses: 250, workflowRuns: null } }, stripeCustomerId: null, usageCycleAnchor: null, }), @@ -32,6 +32,7 @@ const createBillingFixture = (overrides: Partial = {}): TO workspaces: null, monthly: { responses: null, + workflowRuns: null, }, }, usageCycleAnchor: null, @@ -53,7 +54,7 @@ describe("getCloudOrganizationEntitlementsContext", () => { organizationId: "org1", source: "cloud_stripe", features: [], - limits: { workspaces: 1, monthlyResponses: 250 }, + limits: { workspaces: 1, monthlyResponses: 250, monthlyWorkflowRuns: null }, licenseStatus: "no-license", licenseFeatures: null, stripeCustomerId: null, @@ -67,7 +68,7 @@ describe("getCloudOrganizationEntitlementsContext", () => { mockGetBilling.mockResolvedValue( createBillingFixture({ stripeCustomerId: "cus_1", - limits: { workspaces: 5, monthly: { responses: 1000 } }, + limits: { workspaces: 5, monthly: { responses: 1000, workflowRuns: null } }, usageCycleAnchor, stripe: { features: ["rbac", "spam-protection"], plan: "pro" }, }) @@ -80,7 +81,7 @@ describe("getCloudOrganizationEntitlementsContext", () => { organizationId: "org1", source: "cloud_stripe", features: ["rbac", "spam-protection"], - limits: { workspaces: 5, monthlyResponses: 1000 }, + limits: { workspaces: 5, monthlyResponses: 1000, monthlyWorkflowRuns: null }, licenseStatus: "no-license", licenseFeatures: null, stripeCustomerId: "cus_1", @@ -96,7 +97,7 @@ describe("getCloudOrganizationEntitlementsContext", () => { const result = await getCloudOrganizationEntitlementsContext("org1"); expect(result.features).toEqual([]); - expect(result.limits).toEqual({ workspaces: null, monthlyResponses: null }); + expect(result.limits).toEqual({ workspaces: null, monthlyResponses: null, monthlyWorkflowRuns: null }); expect(result.stripeCustomerId).toBeNull(); expect(result.subscriptionStatus).toBeNull(); expect(result.usageCycleAnchor).toBeNull(); @@ -133,7 +134,7 @@ describe("getCloudOrganizationEntitlementsContext", () => { mockGetBilling.mockResolvedValue( createBillingFixture({ stripeCustomerId: "cus_1", - limits: { workspaces: 5, monthly: { responses: 1000 } }, + limits: { workspaces: 5, monthly: { responses: 1000, workflowRuns: null } }, stripe: { features: ["follow-ups"], subscriptionStatus: "trialing" }, }) ); diff --git a/apps/web/modules/entitlements/lib/cloud-provider.ts b/apps/web/modules/entitlements/lib/cloud-provider.ts index f4cc80dda187..a48804da12a0 100644 --- a/apps/web/modules/entitlements/lib/cloud-provider.ts +++ b/apps/web/modules/entitlements/lib/cloud-provider.ts @@ -32,6 +32,7 @@ export const getCloudOrganizationEntitlementsContext = async ( limits: { workspaces: defaultBilling.limits?.workspaces ?? null, monthlyResponses: defaultBilling.limits?.monthly?.responses ?? null, + monthlyWorkflowRuns: defaultBilling.limits?.monthly?.workflowRuns ?? null, }, licenseStatus: license.status, licenseFeatures: license.features, @@ -48,6 +49,7 @@ export const getCloudOrganizationEntitlementsContext = async ( limits: { workspaces: billing.limits?.workspaces ?? null, monthlyResponses: billing.limits?.monthly?.responses ?? null, + monthlyWorkflowRuns: billing.limits?.monthly?.workflowRuns ?? null, }, licenseStatus: license.status, licenseFeatures: license.features, diff --git a/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts b/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts index 96dd677fafb9..c41c2de006e5 100644 --- a/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts +++ b/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts @@ -39,7 +39,7 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => { organizationId: "org1", source: "self_hosted_license", features: [], - limits: { workspaces: 3, monthlyResponses: null }, + limits: { workspaces: 3, monthlyResponses: null, monthlyWorkflowRuns: null }, licenseStatus: "no-license", licenseFeatures: null, stripeCustomerId: null, @@ -141,6 +141,33 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => { expect(result.features).not.toContain("feedback-directories"); }); + test("maps workflows feature to workflows entitlement", async () => { + mockGetOrg.mockResolvedValue({ id: "org1" } as any); + mockGetLicense.mockResolvedValue({ + status: "active", + active: true, + features: { workflows: true }, + } as any); + + const result = await getSelfHostedOrganizationEntitlementsContext("org1"); + + expect(result.features).toContain("workflows"); + expect(result.features).not.toContain("dashboards"); + }); + + test("does not map workflows entitlement when the license flag is off", async () => { + mockGetOrg.mockResolvedValue({ id: "org1" } as any); + mockGetLicense.mockResolvedValue({ + status: "active", + active: true, + features: { workflows: false, dashboards: true }, + } as any); + + const result = await getSelfHostedOrganizationEntitlementsContext("org1"); + + expect(result.features).not.toContain("workflows"); + }); + test("maps both Hub features when all enabled", async () => { mockGetOrg.mockResolvedValue({ id: "org1" } as any); mockGetLicense.mockResolvedValue({ diff --git a/apps/web/modules/entitlements/lib/self-hosted-provider.ts b/apps/web/modules/entitlements/lib/self-hosted-provider.ts index 2f1d0903fff5..723306907f46 100644 --- a/apps/web/modules/entitlements/lib/self-hosted-provider.ts +++ b/apps/web/modules/entitlements/lib/self-hosted-provider.ts @@ -36,6 +36,9 @@ const mapLicenseFeaturesToEntitlements = ( if (features.dashboards) { entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.DASHBOARDS); } + if (features.workflows) { + entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.WORKFLOWS); + } return entitlementKeys; }; @@ -60,6 +63,8 @@ export const getSelfHostedOrganizationEntitlementsContext = async ( workspaces: license.active ? (license.features?.workspaces ?? 3) : 3, // Self-hosted response limits are not license-server-managed today. monthlyResponses: null, + // Self-hosted workflows are gated by the boolean license feature, not metered (ENG-1936). + monthlyWorkflowRuns: null, }, licenseStatus: license.status, licenseFeatures: license.features, diff --git a/apps/web/modules/entitlements/lib/types.ts b/apps/web/modules/entitlements/lib/types.ts index e3d9decf86c4..c92a42f817bf 100644 --- a/apps/web/modules/entitlements/lib/types.ts +++ b/apps/web/modules/entitlements/lib/types.ts @@ -26,6 +26,9 @@ export const isEntitlementFeature = (feature: string): feature is TEntitlementFe export type TEntitlementLimits = { workspaces: number | null; monthlyResponses: number | null; + // Included monthly workflow runs (ENG-1936). null = not metered / unlimited (e.g. self-hosted, + // where workflows are gated by the boolean license feature rather than metered). + monthlyWorkflowRuns: number | null; }; export type TOrganizationEntitlementsContext = { diff --git a/apps/web/modules/mcp/auth.test.ts b/apps/web/modules/mcp/auth.test.ts index 2e5b76797d2b..bc1372762352 100644 --- a/apps/web/modules/mcp/auth.test.ts +++ b/apps/web/modules/mcp/auth.test.ts @@ -53,10 +53,19 @@ vi.mock("@/modules/auth/lib/oauth-urls", () => ({ "offline_access", "surveys:read", "surveys:write", + "workflows:read", + "workflows:write", + "feedbackRecords:read", + "feedbackRecords:write", + ], + MCP_RESOURCE_SCOPES: [ + "surveys:read", + "surveys:write", + "workflows:read", + "workflows:write", "feedbackRecords:read", "feedbackRecords:write", ], - MCP_RESOURCE_SCOPES: ["surveys:read", "surveys:write", "feedbackRecords:read", "feedbackRecords:write"], getAuthIssuerUrl: vi.fn(() => "https://app.example.com/api/auth"), getMcpOrigin: vi.fn(() => "https://app.example.com"), getMcpProtectedResourceMetadataUrl: vi.fn( @@ -117,7 +126,7 @@ describe("authenticateMcpRequest", () => { // The challenge must advertise every resource scope so clients request them at consent and can // reach the write tools (advertising only read is why write was unreachable — ENG-1055 QA). expect(result.response.headers.get("WWW-Authenticate")).toContain( - 'scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' + 'scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' ); expect(await result.response.json()).toMatchObject({ code: "not_authenticated", @@ -251,7 +260,7 @@ describe("authenticateMcpRequest", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.authInfo.scopes).toEqual(["surveys:read", "feedbackRecords:read"]); + expect(result.authInfo.scopes).toEqual(["surveys:read", "workflows:read", "feedbackRecords:read"]); } }); @@ -370,7 +379,7 @@ describe("authenticateMcpRequest", () => { expect(result.response.status).toBe(403); expect(result.response.headers.get("WWW-Authenticate")).toContain('error="insufficient_scope"'); expect(result.response.headers.get("WWW-Authenticate")).toContain( - 'scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' + 'scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"' ); } expect(applyRateLimit).not.toHaveBeenCalled(); diff --git a/apps/web/modules/mcp/auth.ts b/apps/web/modules/mcp/auth.ts index 97a66c5cc1bb..ad3f9c73781c 100644 --- a/apps/web/modules/mcp/auth.ts +++ b/apps/web/modules/mcp/auth.ts @@ -95,13 +95,14 @@ function isOriginAllowed(request: NextRequest): boolean { } function getMcpScopes(authentication: TAuthenticationApiKey): string[] { - const scopes = new Set(["surveys:read", "feedbackRecords:read"]); + const scopes = new Set(["surveys:read", "workflows:read", "feedbackRecords:read"]); if ( authentication.workspacePermissions.some( (permission) => permission.permission === "write" || permission.permission === "manage" ) ) { scopes.add("surveys:write"); + scopes.add("workflows:write"); scopes.add("feedbackRecords:write"); } diff --git a/apps/web/modules/mcp/server.ts b/apps/web/modules/mcp/server.ts index 83a3718cd5dd..4caa162589e3 100644 --- a/apps/web/modules/mcp/server.ts +++ b/apps/web/modules/mcp/server.ts @@ -3,11 +3,13 @@ import { createMcpHandler } from "mcp-handler"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "./constants"; import { registerFeedbackRecordTools } from "./tools/feedback-records"; import { registerSurveyTools } from "./tools/surveys"; +import { registerWorkflowTools } from "./tools/workflows"; import { registerWorkspaceTools } from "./tools/workspaces"; export const mcpHandler = createMcpHandler( (server) => { registerSurveyTools(server); + registerWorkflowTools(server); registerWorkspaceTools(server); registerFeedbackRecordTools(server); }, diff --git a/apps/web/modules/mcp/tools/guard-scopes.test.ts b/apps/web/modules/mcp/tools/guard-scopes.test.ts new file mode 100644 index 000000000000..4113ca1240e6 --- /dev/null +++ b/apps/web/modules/mcp/tools/guard-scopes.test.ts @@ -0,0 +1,96 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { describe, expect, test, vi } from "vitest"; +import { registerScopedTool } from "./guard-scopes"; + +// The insufficient-scope shape the guard puts on structuredContent — narrowed from `unknown`. +type ScopeErrorContent = { + error: { status: number; code: string; detail: string; requestId: string }; +}; + +// A minimal stand-in for McpServer that captures what registerScopedTool registers. +function createToolServer() { + const tools = new Map< + string, + { config: Record; handler: (input: unknown, extra: unknown) => Promise } + >(); + const server = { + registerTool: vi.fn((name: string, config: Record, handler: any) => { + tools.set(name, { config, handler }); + }), + }; + return { server, tools }; +} + +function authInfoWithScopes(scopes: string[], requestId = "req_guard"): AuthInfo { + return { + token: "tok", + clientId: "client", + scopes, + extra: { requestId }, + } as unknown as AuthInfo; +} + +const CONFIG = { + title: "Do thing", + description: "A guarded tool", + annotations: { readOnlyHint: false }, +}; + +describe("registerScopedTool", () => { + test("registers the tool under its name with the config untouched", () => { + const { server, tools } = createToolServer(); + + registerScopedTool(server as any, "do_thing", CONFIG, ["surveys:write"], vi.fn() as any); + + expect(server.registerTool).toHaveBeenCalledTimes(1); + expect(server.registerTool.mock.calls[0][0]).toBe("do_thing"); + expect(tools.get("do_thing")?.config).toBe(CONFIG); + }); + + test("runs the handler and returns its result when the token holds every required scope", async () => { + const { server, tools } = createToolServer(); + const handlerResult = { structuredContent: { ok: true } } as unknown as CallToolResult; + const handler = vi.fn().mockResolvedValue(handlerResult); + registerScopedTool(server as any, "do_thing", CONFIG, ["surveys:read", "surveys:write"], handler as any); + + const input = { a: 1 }; + const extra = { authInfo: authInfoWithScopes(["surveys:read", "surveys:write"]) }; + const result = await tools.get("do_thing")!.handler(input, extra); + + expect(handler).toHaveBeenCalledWith(input, extra); + expect(result).toBe(handlerResult); + }); + + test("blocks the handler with a 403 insufficient-scope result when a required scope is missing", async () => { + const { server, tools } = createToolServer(); + const handler = vi.fn(); + registerScopedTool(server as any, "do_thing", CONFIG, ["surveys:write"], handler as any); + + const result = await tools + .get("do_thing")! + .handler({}, { authInfo: authInfoWithScopes(["surveys:read"], "req_denied") }); + + expect(handler).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect((result.structuredContent as ScopeErrorContent).error).toMatchObject({ + status: 403, + code: "forbidden", + detail: "OAuth token does not include the required MCP scope", + requestId: "req_denied", + }); + }); + + test("denies when the token holds only some of several required scopes", async () => { + const { server, tools } = createToolServer(); + const handler = vi.fn(); + registerScopedTool(server as any, "do_thing", CONFIG, ["surveys:read", "surveys:write"], handler as any); + + const result = await tools + .get("do_thing")! + .handler({}, { authInfo: authInfoWithScopes(["surveys:read"]) }); + + expect(handler).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + }); +}); diff --git a/apps/web/modules/mcp/tools/guard-scopes.ts b/apps/web/modules/mcp/tools/guard-scopes.ts index 40e9d2c5fa08..12f41391050e 100644 --- a/apps/web/modules/mcp/tools/guard-scopes.ts +++ b/apps/web/modules/mcp/tools/guard-scopes.ts @@ -1,11 +1,17 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { createMcpInsufficientScopeResponse, hasAnyMcpScope, hasMcpScopes } from "../auth"; +import type { McpServer, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; +import type { ZodRawShape } from "zod"; +import { createMcpInsufficientScopeResponse, getMcpRequestId, hasAnyMcpScope, hasMcpScopes } from "../auth"; import { responseToMcpToolResult } from "../errors"; /** * Shared MCP scope gate: returns `null` when the caller holds all `requiredScopes`, otherwise an * insufficient-scope tool result. Used by every tool before it touches a v3 operation. + * + * Exported for the feedback-record tools, which centralise the gate in their own shared read/write + * handler factories rather than at registration. Prefer `registerScopedTool` for new tools: it makes + * the gate structural (see ENG-1967) instead of something a hand-written handler can omit. */ export async function guardMcpScopes( authInfo: AuthInfo | undefined, @@ -24,7 +30,7 @@ export async function guardMcpScopes( /** * Any-of variant for tools that more than one scope group legitimately depends on — currently the - * workspace discovery tool, which both the survey and feedback-record tools need to resolve a + * workspace discovery tool, which the survey, workflow and feedback-record tools all need to resolve a * `workspaceId`. Returns `null` when the caller holds at least one of `allowedScopes`. The challenge * still advertises the full list, since RFC 6750 has no way to express "any one of these". */ @@ -42,3 +48,52 @@ export async function guardMcpAnyScope( requestId ); } + +type ScopedToolConfig = { + title?: string; + description?: string; + inputSchema?: InputArgs; + annotations?: ToolAnnotations; +}; + +/** + * Register an MCP tool with a MANDATORY OAuth scope gate. `requiredScopes` is a required argument, so a + * tool cannot be registered without declaring the scope it needs — the gate always runs (returning a + * 403 insufficient-scope result) BEFORE the handler, so no tool can reach a v3 operation unguarded. + * + * This is the single registration path for every MCP tool: read tools pass `[":read"]`, + * mutating tools pass `[":write"]`. Enforcing scope structurally (vs. a per-tool + * `guardMcpScopes` call that's easy to forget) is what prevents the ENG-1967 class of gap from + * recurring as new tools are added. + * + * Pass `{ anyOf: [...] }` instead of a plain tuple for the rare tool that more than one scope group + * legitimately reaches (workspace discovery). That keeps such tools on this registration path rather + * than dropping them to a raw `server.registerTool` with a hand-rolled gate. + */ +export function registerScopedTool( + server: McpServer, + name: string, + config: ScopedToolConfig, + // Non-empty tuple: a tool cannot be registered with `[]`, which would gate on nothing. + requiredScopes: [string, ...string[]] | { anyOf: [string, ...string[]] }, + handler: ToolCallback +): void { + const guardedHandler = (async (input: unknown, extra: { authInfo?: AuthInfo }) => { + const requestId = getMcpRequestId(extra.authInfo); + const scopeError = + "anyOf" in requiredScopes + ? await guardMcpAnyScope(extra.authInfo, requiredScopes.anyOf, requestId) + : await guardMcpScopes(extra.authInfo, requiredScopes, requestId); + if (scopeError) { + return scopeError; + } + // Cast needed only because ToolCallback is an overloaded/conditional signature that + // TS can't call with the erased `unknown` params here. It's safe: the SDK validates `input` + // against this tool's inputSchema (InputArgs) BEFORE invoking guardedHandler, and we forward the + // exact same `input`/`extra` through unchanged — so the runtime value already conforms to the + // handler's declared type; nothing is reshaped. + return (handler as (input: unknown, extra: unknown) => Promise)(input, extra); + }) as ToolCallback; + + server.registerTool(name, config, guardedHandler); +} diff --git a/apps/web/modules/mcp/tools/run-mcp-mutation.ts b/apps/web/modules/mcp/tools/run-mcp-mutation.ts new file mode 100644 index 000000000000..d9be6ee669d0 --- /dev/null +++ b/apps/web/modules/mcp/tools/run-mcp-mutation.ts @@ -0,0 +1,63 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { logger } from "@formbricks/logger"; +import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; +import { MCP_API_ROUTE } from "@/modules/mcp/constants"; +import { getMcpAuthentication, getMcpRequestId } from "../auth"; +import { responseToMcpToolResult } from "../errors"; + +type McpMutationExtra = { authInfo?: AuthInfo }; + +/** + * Shared audit lifecycle for every MCP mutation tool, matching what the v3 route wrapper provides: + * derive the authentication + request id, build the audit log, run the operation, mark + * success/failure, and ALWAYS queue the audit log (even on throw) before propagating. + * + * The per-resource runners (survey/workflow) only supply the audit `resource`, optional logger + * context, and a `run` callback that performs the actual v3 operation with the injected + * authentication/requestId/auditLog — so the try/catch/status/queue logic lives in exactly one place. + */ +export async function runMcpMutation( + extra: McpMutationExtra, + { + action, + resource, + logContext = {}, + }: { + action: Parameters[1]; + resource: Parameters[2]; + logContext?: Record; + }, + run: (args: { + authentication: ReturnType; + requestId: string; + auditLog: ReturnType; + }) => Promise +): Promise { + const requestId = getMcpRequestId(extra.authInfo); + const authentication = getMcpAuthentication(extra.authInfo); + const log = logger.withContext({ requestId, ...logContext }); + const auditLog = buildV3AuditLog(authentication, action, resource, MCP_API_ROUTE); + + try { + const response = await run({ authentication, requestId, auditLog }); + + if (auditLog) { + if (response.ok) { + auditLog.status = "success"; + } else { + auditLog.eventId = requestId; + } + } + + await queueV3AuditLog(auditLog, requestId, log); + return await responseToMcpToolResult(response, requestId); + } catch (error) { + if (auditLog) { + auditLog.eventId = requestId; + await queueV3AuditLog(auditLog, requestId, log); + } + + throw error; + } +} diff --git a/apps/web/modules/mcp/tools/surveys.ts b/apps/web/modules/mcp/tools/surveys.ts index dd9197d6ba11..f5b0bef5e75b 100644 --- a/apps/web/modules/mcp/tools/surveys.ts +++ b/apps/web/modules/mcp/tools/surveys.ts @@ -1,6 +1,4 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { logger } from "@formbricks/logger"; -import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; import { createV3SurveyResponseFromRawInput, deleteV3Survey, @@ -12,7 +10,8 @@ import { import { MCP_API_ROUTE } from "@/modules/mcp/constants"; import { getMcpAuthentication, getMcpRequestId } from "../auth"; import { responseToMcpToolResult } from "../errors"; -import { guardMcpScopes } from "./guard-scopes"; +import { registerScopedTool } from "./guard-scopes"; +import { runMcpMutation } from "./run-mcp-mutation"; import { type TMcpCreateSurveyInput, type TMcpDeleteSurveyInput, @@ -62,7 +61,8 @@ export function buildListSurveysSearchParams(input: TMcpListSurveysInput): URLSe } export function registerSurveyTools(server: McpServer): void { - server.registerTool( + registerScopedTool( + server, "list_surveys", { title: "List surveys", @@ -75,13 +75,9 @@ export function registerSurveyTools(server: McpServer): void { openWorldHint: true, }, }, + ["surveys:read"], async (input: TMcpListSurveysInput, extra) => { const requestId = getMcpRequestId(extra.authInfo); - const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:read"], requestId); - if (scopeError) { - return scopeError; - } - const response = await listV3Surveys({ searchParams: buildListSurveysSearchParams(input), authentication: getMcpAuthentication(extra.authInfo), @@ -93,7 +89,8 @@ export function registerSurveyTools(server: McpServer): void { } ); - server.registerTool( + registerScopedTool( + server, "get_survey", { title: "Get survey", @@ -106,13 +103,9 @@ export function registerSurveyTools(server: McpServer): void { openWorldHint: true, }, }, + ["surveys:read"], async (input: TMcpGetSurveyInput, extra) => { const requestId = getMcpRequestId(extra.authInfo); - const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:read"], requestId); - if (scopeError) { - return scopeError; - } - const response = await getV3Survey({ surveyId: input.surveyId, lang: input.lang, @@ -125,7 +118,8 @@ export function registerSurveyTools(server: McpServer): void { } ); - server.registerTool( + registerScopedTool( + server, "create_survey", { title: "Create survey", @@ -138,48 +132,24 @@ export function registerSurveyTools(server: McpServer): void { openWorldHint: true, }, }, - async (input: TMcpCreateSurveyInput, extra) => { - const requestId = getMcpRequestId(extra.authInfo); - const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:write"], requestId); - if (scopeError) { - return scopeError; - } - - const authentication = getMcpAuthentication(extra.authInfo); - const log = logger.withContext({ requestId, workspaceId: input.workspaceId }); - const auditLog = buildV3AuditLog(authentication, "created", "survey", MCP_API_ROUTE); - - try { - const response = await createV3SurveyResponseFromRawInput({ - body: input, - authentication, - requestId, - instance: MCP_API_ROUTE, - auditLog, - }); - - if (auditLog) { - if (response.ok) { - auditLog.status = "success"; - } else { - auditLog.eventId = requestId; - } - } - - await queueV3AuditLog(auditLog, requestId, log); - return await responseToMcpToolResult(response, requestId); - } catch (error) { - if (auditLog) { - auditLog.eventId = requestId; - await queueV3AuditLog(auditLog, requestId, log); - } - - throw error; - } - } + ["surveys:write"], + async (input: TMcpCreateSurveyInput, extra) => + runMcpMutation( + extra, + { action: "created", resource: "survey", logContext: { workspaceId: input.workspaceId } }, + ({ authentication, requestId, auditLog }) => + createV3SurveyResponseFromRawInput({ + body: input, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }) + ) ); - server.registerTool( + registerScopedTool( + server, "validate_survey", { title: "Validate survey", @@ -192,16 +162,12 @@ export function registerSurveyTools(server: McpServer): void { openWorldHint: true, }, }, + ["surveys:read"], async (input: TMcpValidateSurveyInput, extra) => { const requestId = getMcpRequestId(extra.authInfo); // validate_survey never persists changes (readOnlyHint) — a dry-run validation of a create or // patch payload only needs read access. The actual write permission is enforced by the v3 layer // when create_survey / patch_survey run. - const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:read"], requestId); - if (scopeError) { - return scopeError; - } - const response = await validateV3SurveyFromRawInput({ body: input, authentication: getMcpAuthentication(extra.authInfo), @@ -213,7 +179,8 @@ export function registerSurveyTools(server: McpServer): void { } ); - server.registerTool( + registerScopedTool( + server, "patch_survey", { title: "Patch survey", @@ -229,49 +196,25 @@ export function registerSurveyTools(server: McpServer): void { openWorldHint: true, }, }, - async (input: TMcpPatchSurveyInput, extra) => { - const requestId = getMcpRequestId(extra.authInfo); - const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:write"], requestId); - if (scopeError) { - return scopeError; - } - - const authentication = getMcpAuthentication(extra.authInfo); - const log = logger.withContext({ requestId, surveyId: input.surveyId }); - const auditLog = buildV3AuditLog(authentication, "updated", "survey", MCP_API_ROUTE); - - try { - const response = await patchV3SurveyResponse({ - surveyId: input.surveyId, - body: input.data, - authentication, - requestId, - instance: MCP_API_ROUTE, - auditLog, - }); - - if (auditLog) { - if (response.ok) { - auditLog.status = "success"; - } else { - auditLog.eventId = requestId; - } - } - - await queueV3AuditLog(auditLog, requestId, log); - return await responseToMcpToolResult(response, requestId); - } catch (error) { - if (auditLog) { - auditLog.eventId = requestId; - await queueV3AuditLog(auditLog, requestId, log); - } - - throw error; - } - } + ["surveys:write"], + async (input: TMcpPatchSurveyInput, extra) => + runMcpMutation( + extra, + { action: "updated", resource: "survey", logContext: { surveyId: input.surveyId } }, + ({ authentication, requestId, auditLog }) => + patchV3SurveyResponse({ + surveyId: input.surveyId, + body: input.data, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }) + ) ); - server.registerTool( + registerScopedTool( + server, "delete_survey", { title: "Delete survey", @@ -284,44 +227,19 @@ export function registerSurveyTools(server: McpServer): void { openWorldHint: true, }, }, - async (input: TMcpDeleteSurveyInput, extra) => { - const requestId = getMcpRequestId(extra.authInfo); - const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:write"], requestId); - if (scopeError) { - return scopeError; - } - - const authentication = getMcpAuthentication(extra.authInfo); - const log = logger.withContext({ requestId, surveyId: input.surveyId }); - const auditLog = buildV3AuditLog(authentication, "deleted", "survey", MCP_API_ROUTE); - - try { - const response = await deleteV3Survey({ - surveyId: input.surveyId, - authentication, - requestId, - instance: MCP_API_ROUTE, - auditLog, - }); - - if (auditLog) { - if (response.ok) { - auditLog.status = "success"; - } else { - auditLog.eventId = requestId; - } - } - - await queueV3AuditLog(auditLog, requestId, log); - return await responseToMcpToolResult(response, requestId); - } catch (error) { - if (auditLog) { - auditLog.eventId = requestId; - await queueV3AuditLog(auditLog, requestId, log); - } - - throw error; - } - } + ["surveys:write"], + async (input: TMcpDeleteSurveyInput, extra) => + runMcpMutation( + extra, + { action: "deleted", resource: "survey", logContext: { surveyId: input.surveyId } }, + ({ authentication, requestId, auditLog }) => + deleteV3Survey({ + surveyId: input.surveyId, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }) + ) ); } diff --git a/apps/web/modules/mcp/tools/workflow-schemas.ts b/apps/web/modules/mcp/tools/workflow-schemas.ts new file mode 100644 index 000000000000..87cb9cecf43a --- /dev/null +++ b/apps/web/modules/mcp/tools/workflow-schemas.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; +import { + ZCreateWorkflowInput, + ZPatchWorkflowInput, + ZWorkflowRunStatus, + ZWorkflowSortBy, + ZWorkflowStatus, +} from "@formbricks/workflows"; + +export const ZMcpListWorkflowsInput = z.object({ + workspaceId: z.cuid2().describe("Workspace ID whose workflows should be listed."), + limit: z + .number() + .int() + .min(1) + .max(100) + .describe("Maximum number of workflows to return. Defaults to 20.") + .default(20), + cursor: z + .string() + .min(1) + .optional() + .describe("Opaque pagination cursor from a previous list_workflows response."), + filter: z + .object({ + name: z + .object({ + contains: z + .string() + .min(1) + .max(512) + .optional() + .describe("Case-insensitive workflow name substring."), + }) + .describe("Filter by workflow name.") + .optional(), + status: z + .object({ + in: z + .array(ZWorkflowStatus) + .min(1) + .optional() + .describe("Workflow statuses to include. Omitting returns every status except archived."), + }) + .describe("Filter by workflow status.") + .optional(), + }) + .describe("Optional supported v3 workflow filters.") + .optional(), + sortBy: ZWorkflowSortBy.optional().describe( + "Sort field for pagination. Defaults to the v3 API default of updatedAt." + ), +}); +export type TMcpListWorkflowsInput = z.infer; + +export const ZMcpGetWorkflowInput = z.object({ + workflowId: z.cuid2().describe("Workflow ID to fetch."), +}); +export type TMcpGetWorkflowInput = z.infer; + +export const ZMcpListWorkflowRunsInput = z.object({ + workspaceId: z.cuid2().describe("Workspace ID whose workflow runs should be listed."), + limit: z + .number() + .int() + .min(1) + .max(100) + .describe("Maximum number of runs to return. Defaults to 20.") + .default(20), + cursor: z + .string() + .min(1) + .optional() + .describe("Opaque pagination cursor from a previous list_workflow_runs response."), + workflowId: z.cuid2().optional().describe("Return only runs of this workflow."), + responseId: z.cuid2().optional().describe("Return only runs triggered by this survey response."), + filter: z + .object({ + status: z + .object({ + in: z + .array(ZWorkflowRunStatus) + .min(1) + .optional() + .describe("Run statuses to include, for example queued or completed."), + }) + .describe("Filter by run status.") + .optional(), + isDryRun: z.boolean().optional().describe("Filter by dry-run vs real runs. Omit to return both."), + }) + .describe("Optional supported v3 workflow run filters.") + .optional(), +}); +export type TMcpListWorkflowRunsInput = z.infer; + +export const ZMcpGetWorkflowRunInput = z.object({ + runId: z.cuid2().describe("Workflow run ID to fetch, including its ordered step logs."), +}); +export type TMcpGetWorkflowRunInput = z.infer; + +export const ZMcpTestWorkflowInput = z.object({ + workflowId: z.cuid2().describe("Workflow ID to dry-run (validate + mock execute, no side effects)."), +}); +export type TMcpTestWorkflowInput = z.infer; + +// --- Mutations --- + +// Reuse the v3 create contract verbatim: workspaceId, name, optional description, and the full +// workflow definition graph. Workflows are always created as drafts (enable makes them live). +export const ZMcpCreateWorkflowInput = ZCreateWorkflowInput; +export type TMcpCreateWorkflowInput = z.infer; + +export const ZMcpPatchWorkflowInput = z.object({ + workflowId: z.cuid2().describe("Workflow ID to update."), + data: ZPatchWorkflowInput.describe( + "Partial update. Provided top-level fields replace that whole subtree; definition edits are only accepted while draft or disabled." + ), +}); +export type TMcpPatchWorkflowInput = z.infer; + +export const ZMcpDuplicateWorkflowInput = z.object({ + workflowId: z.cuid2().describe("Workflow ID to duplicate as a new draft."), + name: z + .string() + .min(1) + .max(120) + .optional() + .describe("Optional name for the copy. If omitted, the server picks a non-conflicting name."), +}); +export type TMcpDuplicateWorkflowInput = z.infer; + +// Shared shape for the id-only lifecycle mutations (delete / enable / disable / archive / unarchive). +export const ZMcpWorkflowIdInput = z.object({ + workflowId: z.cuid2().describe("Workflow ID."), +}); +export type TMcpWorkflowIdInput = z.infer; diff --git a/apps/web/modules/mcp/tools/workflows.test.ts b/apps/web/modules/mcp/tools/workflows.test.ts new file mode 100644 index 000000000000..5ab87b1503d3 --- /dev/null +++ b/apps/web/modules/mcp/tools/workflows.test.ts @@ -0,0 +1,541 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ApiKeyPermission } from "@formbricks/database/prisma"; +import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; +import { + createdResponse, + noContentResponse, + problemForbidden, + successListResponse, + successResponse, +} from "@/app/api/v3/lib/response"; +import { buildWorkflowApiContext, workflowsHandlers } from "@/app/api/v3/workflows/lib/context"; +import { + buildListWorkflowRunsSearchParams, + buildListWorkflowsSearchParams, + registerWorkflowTools, +} from "./workflows"; + +vi.mock("@/app/api/v3/workflows/lib/context", () => ({ + buildWorkflowApiContext: vi.fn(() => ({ __ctx: true })), + workflowsHandlers: { + list: vi.fn(), + get: vi.fn(), + listRuns: vi.fn(), + getRun: vi.fn(), + testWorkflow: vi.fn(), + create: vi.fn(), + patch: vi.fn(), + duplicate: vi.fn(), + delete: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + archive: vi.fn(), + unarchive: vi.fn(), + }, +})); + +vi.mock("@/app/api/v3/lib/audit", () => ({ + buildV3AuditLog: vi.fn(), + queueV3AuditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: vi.fn(() => ({ error: vi.fn(), warn: vi.fn() })) }, +})); + +const WORKSPACE_ID = "clxx1234567890123456789012"; +const WORKFLOW_ID = "wf1234567890123456789012ab"; +const RUN_ID = "run234567890123456789012ab"; + +const apiKeyAuth = { + type: "apiKey" as const, + apiKeyId: "key_1", + organizationId: "org_1", + organizationAccess: { accessControl: { read: true, write: true } }, + workspacePermissions: [ + { workspaceId: WORKSPACE_ID, workspaceName: "Workspace", permission: ApiKeyPermission.write }, + ], +}; + +// Full-scope token used by the behavior tests below. +const authInfo = { + token: "key_1", + clientId: "key_1", + scopes: ["workflows:read", "workflows:write"], + extra: { formbricksAuthentication: apiKeyAuth, requestId: "req_tool" }, +}; + +// A write-capable user's OAuth token that was only granted read scope — the ENG-1967 case: workspace +// permissions would allow the write, but the token scope must not. +const readOnlyAuthInfo = { + ...authInfo, + token: "oauth:user_1:client_1", + clientId: "client_1", + scopes: ["workflows:read"], + extra: { ...authInfo.extra, authMethod: "oauth" }, +}; +const noScopeAuthInfo = { ...readOnlyAuthInfo, scopes: [] as string[] }; + +function createToolServer() { + const tools = new Map< + string, + { config: Record; handler: (input: any, extra: any) => Promise } + >(); + const server = { + registerTool: vi.fn((name: string, config: Record, handler: any) => { + tools.set(name, { config, handler }); + }), + }; + registerWorkflowTools(server as any); + return { server, tools }; +} + +describe("buildListWorkflowsSearchParams", () => { + test("applies defensive defaults", () => { + const params = buildListWorkflowsSearchParams({ + workspaceId: WORKSPACE_ID, + } as unknown as Parameters[0]); + + expect(params.get("workspaceId")).toBe(WORKSPACE_ID); + expect(params.get("limit")).toBe("20"); + }); + + test("maps structured MCP filters to v3 query parameters", () => { + const params = buildListWorkflowsSearchParams({ + workspaceId: WORKSPACE_ID, + limit: 50, + cursor: "cursor_1", + sortBy: "name", + filter: { name: { contains: "Onboarding" }, status: { in: ["draft", "disabled"] } }, + }); + + expect(params.get("limit")).toBe("50"); + expect(params.get("cursor")).toBe("cursor_1"); + expect(params.get("sortBy")).toBe("name"); + expect(params.get("filter[name][contains]")).toBe("Onboarding"); + expect(params.getAll("filter[status][in]")).toEqual(["draft", "disabled"]); + }); +}); + +describe("buildListWorkflowRunsSearchParams", () => { + test("maps run filters including isDryRun to v3 query parameters", () => { + const params = buildListWorkflowRunsSearchParams({ + workspaceId: WORKSPACE_ID, + limit: 20, + workflowId: WORKFLOW_ID, + responseId: "resp234567890123456789012a", + filter: { status: { in: ["completed"] }, isDryRun: false }, + }); + + expect(params.get("workflowId")).toBe(WORKFLOW_ID); + expect(params.get("responseId")).toBe("resp234567890123456789012a"); + expect(params.getAll("filter[status][in]")).toEqual(["completed"]); + expect(params.get("filter[isDryRun][eq]")).toBe("false"); + }); +}); + +describe("registerWorkflowTools", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(buildWorkflowApiContext).mockReturnValue({ __ctx: true } as any); + vi.mocked(queueV3AuditLog).mockResolvedValue(undefined); + }); + + test("registers all read + mutation tools with correct annotations", () => { + const { server, tools } = createToolServer(); + + expect(server.registerTool).toHaveBeenCalledTimes(13); + for (const name of [ + "list_workflows", + "get_workflow", + "list_workflow_runs", + "get_workflow_run", + "test_workflow", + ]) { + expect(tools.get(name)?.config).toMatchObject({ + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }); + } + // Mutations are not read-only; patch/delete/enable are the destructive ones. + expect(tools.get("create_workflow")?.config).toMatchObject({ + annotations: { readOnlyHint: false, destructiveHint: false }, + }); + expect(tools.get("patch_workflow")?.config).toMatchObject({ + annotations: { readOnlyHint: false, destructiveHint: true }, + }); + expect(tools.get("delete_workflow")?.config).toMatchObject({ + annotations: { readOnlyHint: false, destructiveHint: true }, + }); + expect(tools.get("enable_workflow")?.config).toMatchObject({ + annotations: { readOnlyHint: false, destructiveHint: true }, + }); + expect(tools.get("disable_workflow")?.config).toMatchObject({ + annotations: { readOnlyHint: false, destructiveHint: false }, + }); + // Archive soft-deletes (hidden from default reads) -> destructive; unarchive restores. + expect(tools.get("archive_workflow")?.config).toMatchObject({ + annotations: { readOnlyHint: false, destructiveHint: true }, + }); + expect(tools.get("unarchive_workflow")?.config).toMatchObject({ + annotations: { readOnlyHint: false, destructiveHint: false }, + }); + }); + + test("list_workflows delegates to the handler with a synthetic query request", async () => { + const { tools } = createToolServer(); + vi.mocked(workflowsHandlers.list).mockResolvedValue( + successListResponse([{ id: WORKFLOW_ID }], { limit: 20, nextCursor: null }, { requestId: "req_tool" }) + ); + + const result = await tools + .get("list_workflows")! + .handler({ workspaceId: WORKSPACE_ID, limit: 20 }, { authInfo }); + + expect(buildWorkflowApiContext).toHaveBeenCalledWith(apiKeyAuth, "req_tool", "/api/mcp"); + const callArg = vi.mocked(workflowsHandlers.list).mock.calls[0][0]; + expect(callArg.ctx).toEqual({ __ctx: true }); + expect(new URL(callArg.req.url).searchParams.get("workspaceId")).toBe(WORKSPACE_ID); + expect(result.structuredContent).toEqual({ + data: [{ id: WORKFLOW_ID }], + meta: { limit: 20, nextCursor: null }, + requestId: "req_tool", + }); + }); + + test("list_workflow_runs delegates to listRuns with a synthetic query request", async () => { + const { tools } = createToolServer(); + vi.mocked(workflowsHandlers.listRuns).mockResolvedValue( + successListResponse([{ id: RUN_ID }], { limit: 20, nextCursor: null }, { requestId: "req_tool" }) + ); + + const result = await tools + .get("list_workflow_runs")! + .handler({ workspaceId: WORKSPACE_ID, limit: 20, workflowId: WORKFLOW_ID }, { authInfo }); + + const callArg = vi.mocked(workflowsHandlers.listRuns).mock.calls[0][0]; + expect(callArg.ctx).toEqual({ __ctx: true }); + const params = new URL(callArg.req.url).searchParams; + expect(params.get("workspaceId")).toBe(WORKSPACE_ID); + expect(params.get("workflowId")).toBe(WORKFLOW_ID); + expect(result.structuredContent).toEqual({ + data: [{ id: RUN_ID }], + meta: { limit: 20, nextCursor: null }, + requestId: "req_tool", + }); + }); + + test("get_workflow delegates to the handler with params (no request)", async () => { + const { tools } = createToolServer(); + vi.mocked(workflowsHandlers.get).mockResolvedValue( + successResponse({ id: WORKFLOW_ID }, { requestId: "req_tool" }) + ); + + const result = await tools.get("get_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(workflowsHandlers.get).toHaveBeenCalledWith({ + ctx: { __ctx: true }, + params: { workflowId: WORKFLOW_ID }, + }); + expect(result.structuredContent).toEqual({ data: { id: WORKFLOW_ID }, requestId: "req_tool" }); + }); + + test("get_workflow_run delegates to getRun with the run id", async () => { + const { tools } = createToolServer(); + vi.mocked(workflowsHandlers.getRun).mockResolvedValue( + successResponse({ id: RUN_ID }, { requestId: "req_tool" }) + ); + + await tools.get("get_workflow_run")!.handler({ runId: RUN_ID }, { authInfo }); + + expect(workflowsHandlers.getRun).toHaveBeenCalledWith({ + ctx: { __ctx: true }, + params: { runId: RUN_ID }, + }); + }); + + test("test_workflow delegates to testWorkflow with the workflow id", async () => { + const { tools } = createToolServer(); + vi.mocked(workflowsHandlers.testWorkflow).mockResolvedValue( + successResponse({ ok: true, problems: [] }, { requestId: "req_tool" }) + ); + + await tools.get("test_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(workflowsHandlers.testWorkflow).toHaveBeenCalledWith({ + ctx: { __ctx: true }, + params: { workflowId: WORKFLOW_ID }, + }); + }); + + test("maps v3 problem responses to MCP tool errors without leaking existence", async () => { + const { tools } = createToolServer(); + vi.mocked(workflowsHandlers.get).mockResolvedValue( + problemForbidden("req_forbidden", "You are not authorized to access this resource", "/api/mcp") + ); + + const result = await tools.get("get_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toMatchObject({ + status: 403, + code: "forbidden", + requestId: "req_forbidden", + }); + }); + + test("create_workflow sends a body request and queues a success audit log", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.create).mockResolvedValue( + createdResponse({ id: WORKFLOW_ID }, { requestId: "req_tool", location: "/wf" }) + ); + const body = { workspaceId: WORKSPACE_ID, name: "WF", definition: { nodes: [], edges: [] } }; + + const result = await tools.get("create_workflow")!.handler(body, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "created", "workflow", "/api/mcp"); + expect(buildWorkflowApiContext).toHaveBeenCalledWith(apiKeyAuth, "req_tool", "/api/mcp", auditLog); + const callArg = vi.mocked(workflowsHandlers.create).mock.calls[0][0]; + expect(callArg.ctx).toEqual({ __ctx: true }); + expect(JSON.parse(await callArg.req.text())).toMatchObject({ workspaceId: WORKSPACE_ID, name: "WF" }); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + expect(result.structuredContent).toEqual({ data: { id: WORKFLOW_ID }, requestId: "req_tool" }); + }); + + test("enable_workflow delegates by id and queues an updated audit log", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.enable).mockResolvedValue( + successResponse({ id: WORKFLOW_ID, status: "enabled" }, { requestId: "req_tool" }) + ); + + await tools.get("enable_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "updated", "workflow", "/api/mcp"); + expect(workflowsHandlers.enable).toHaveBeenCalledWith({ + ctx: { __ctx: true }, + params: { workflowId: WORKFLOW_ID }, + }); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + }); + + test("delete_workflow queues a deleted audit log", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.delete).mockResolvedValue(noContentResponse({ requestId: "req_tool" })); + + const result = await tools.get("delete_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "deleted", "workflow", "/api/mcp"); + expect(auditLog.status).toBe("success"); + expect(result.structuredContent).toEqual({ requestId: "req_tool" }); + }); + + test("a failed mutation marks the audit eventId and returns an error (no leak)", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.enable).mockResolvedValue( + problemForbidden("req_forbidden", "You are not authorized to access this resource", "/api/mcp") + ); + + const result = await tools.get("enable_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(result.isError).toBe(true); + // Audit defaults to "failure" (buildAuditLogBaseObject); only success flips it, so a failed + // mutation is queued with failure status + the requestId as eventId. + expect(auditLog).toMatchObject({ status: "failure", eventId: "req_tool" }); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + }); + + test("a thrown mutation still queues the audit log and re-propagates the error", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + const boom = new Error("workflow operation exploded"); + vi.mocked(workflowsHandlers.create).mockRejectedValue(boom); + const body = { workspaceId: WORKSPACE_ID, name: "WF", definition: { nodes: [], edges: [] } }; + + // The shared runner must not swallow errors: it flags the audit eventId, queues it, then rethrows. + await expect(tools.get("create_workflow")!.handler(body, { authInfo })).rejects.toThrow(boom); + expect(auditLog).toMatchObject({ eventId: "req_tool" }); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + }); + + test("a null audit log is tolerated (still queues, no crash)", async () => { + const { tools } = createToolServer(); + // buildV3AuditLog can return null (auditing disabled); the runner must not dereference it. + vi.mocked(buildV3AuditLog).mockReturnValue(null as any); + vi.mocked(workflowsHandlers.enable).mockResolvedValue( + successResponse({ id: WORKFLOW_ID, status: "enabled" }, { requestId: "req_tool" }) + ); + + const result = await tools.get("enable_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(queueV3AuditLog).toHaveBeenCalledWith(null, "req_tool", expect.any(Object)); + expect(result.structuredContent).toEqual({ + data: { id: WORKFLOW_ID, status: "enabled" }, + requestId: "req_tool", + }); + }); + + test("patch_workflow sends a body request with params and queues an updated audit log", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.patch).mockResolvedValue( + successResponse({ id: WORKFLOW_ID, name: "Renamed" }, { requestId: "req_tool" }) + ); + + await tools + .get("patch_workflow")! + .handler({ workflowId: WORKFLOW_ID, data: { name: "Renamed" } }, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "updated", "workflow", "/api/mcp"); + const callArg = vi.mocked(workflowsHandlers.patch).mock.calls[0][0]; + expect(callArg.params).toEqual({ workflowId: WORKFLOW_ID }); + expect(JSON.parse(await callArg.req.text())).toEqual({ name: "Renamed" }); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.any(Object)); + }); + + test("duplicate_workflow sends the optional name body and queues a created audit log", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.duplicate).mockResolvedValue( + createdResponse({ id: "wf2222222222222222222222ab" }, { requestId: "req_tool", location: "/wf" }) + ); + + await tools.get("duplicate_workflow")!.handler({ workflowId: WORKFLOW_ID, name: "Copy" }, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "created", "workflow", "/api/mcp"); + const callArg = vi.mocked(workflowsHandlers.duplicate).mock.calls[0][0]; + expect(callArg.params).toEqual({ workflowId: WORKFLOW_ID }); + expect(JSON.parse(await callArg.req.text())).toEqual({ name: "Copy" }); + expect(auditLog.status).toBe("success"); + }); + + test("archive_workflow delegates by id and queues an updated audit log", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.archive).mockResolvedValue( + successResponse({ id: WORKFLOW_ID, status: "archived" }, { requestId: "req_tool" }) + ); + + await tools.get("archive_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "updated", "workflow", "/api/mcp"); + expect(workflowsHandlers.archive).toHaveBeenCalledWith({ + ctx: { __ctx: true }, + params: { workflowId: WORKFLOW_ID }, + }); + expect(auditLog.status).toBe("success"); + }); + + test("unarchive_workflow delegates by id and queues an updated audit log", async () => { + const { tools } = createToolServer(); + const auditLog: any = { status: "failure" }; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(workflowsHandlers.unarchive).mockResolvedValue( + successResponse({ id: WORKFLOW_ID, status: "draft" }, { requestId: "req_tool" }) + ); + + await tools.get("unarchive_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "updated", "workflow", "/api/mcp"); + expect(workflowsHandlers.unarchive).toHaveBeenCalledWith({ + ctx: { __ctx: true }, + params: { workflowId: WORKFLOW_ID }, + }); + expect(auditLog.status).toBe("success"); + }); +}); + +describe("MCP scope enforcement (ENG-1967)", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(buildWorkflowApiContext).mockReturnValue({ __ctx: true } as any); + vi.mocked(queueV3AuditLog).mockResolvedValue(undefined); + }); + + const INSUFFICIENT_SCOPE = { + status: 403, + code: "forbidden", + detail: "OAuth token does not include the required MCP scope", + }; + + // Every mutating tool + a representative valid input for it. + const writeTools: { name: string; handlerKey: keyof typeof workflowsHandlers; input: unknown }[] = [ + { + name: "create_workflow", + handlerKey: "create", + input: { workspaceId: WORKSPACE_ID, name: "WF", definition: { nodes: [], edges: [] } }, + }, + { name: "patch_workflow", handlerKey: "patch", input: { workflowId: WORKFLOW_ID, data: { name: "x" } } }, + { name: "duplicate_workflow", handlerKey: "duplicate", input: { workflowId: WORKFLOW_ID } }, + { name: "delete_workflow", handlerKey: "delete", input: { workflowId: WORKFLOW_ID } }, + { name: "enable_workflow", handlerKey: "enable", input: { workflowId: WORKFLOW_ID } }, + { name: "disable_workflow", handlerKey: "disable", input: { workflowId: WORKFLOW_ID } }, + { name: "archive_workflow", handlerKey: "archive", input: { workflowId: WORKFLOW_ID } }, + { name: "unarchive_workflow", handlerKey: "unarchive", input: { workflowId: WORKFLOW_ID } }, + ]; + + test.each(writeTools)( + "$name is denied for a read-scoped token before the mutation runs", + async ({ name, handlerKey, input }) => { + const { tools } = createToolServer(); + + const result = await tools.get(name)!.handler(input, { authInfo: readOnlyAuthInfo }); + + // The mutation never runs, and no audit log is queued for a request that was blocked at the gate. + expect(workflowsHandlers[handlerKey]).not.toHaveBeenCalled(); + expect(queueV3AuditLog).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toMatchObject({ ...INSUFFICIENT_SCOPE, requestId: "req_tool" }); + } + ); + + test("write tools succeed once the token carries workflows:write", async () => { + const { tools } = createToolServer(); + vi.mocked(buildV3AuditLog).mockReturnValue({ status: "failure" } as any); + vi.mocked(workflowsHandlers.enable).mockResolvedValue( + successResponse({ id: WORKFLOW_ID, status: "enabled" }, { requestId: "req_tool" }) + ); + + await tools.get("enable_workflow")!.handler({ workflowId: WORKFLOW_ID }, { authInfo }); + + expect(workflowsHandlers.enable).toHaveBeenCalledTimes(1); + }); + + test("read tools require workflows:read", async () => { + const { tools } = createToolServer(); + + const result = await tools + .get("list_workflows")! + .handler({ workspaceId: WORKSPACE_ID, limit: 20 }, { authInfo: noScopeAuthInfo }); + + expect(workflowsHandlers.list).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toMatchObject({ ...INSUFFICIENT_SCOPE, requestId: "req_tool" }); + }); + + test("read tools succeed for a read-only token", async () => { + const { tools } = createToolServer(); + vi.mocked(workflowsHandlers.list).mockResolvedValue( + successListResponse([{ id: WORKFLOW_ID }], { limit: 20, nextCursor: null }, { requestId: "req_tool" }) + ); + + await tools + .get("list_workflows")! + .handler({ workspaceId: WORKSPACE_ID, limit: 20 }, { authInfo: readOnlyAuthInfo }); + + expect(workflowsHandlers.list).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/modules/mcp/tools/workflows.ts b/apps/web/modules/mcp/tools/workflows.ts new file mode 100644 index 000000000000..98fb07556bb0 --- /dev/null +++ b/apps/web/modules/mcp/tools/workflows.ts @@ -0,0 +1,452 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { buildV3AuditLog } from "@/app/api/v3/lib/audit"; +import { buildWorkflowApiContext, workflowsHandlers } from "@/app/api/v3/workflows/lib/context"; +import { MCP_API_ROUTE } from "@/modules/mcp/constants"; +import { getMcpAuthentication, getMcpRequestId } from "../auth"; +import { responseToMcpToolResult } from "../errors"; +import { registerScopedTool } from "./guard-scopes"; +import { runMcpMutation } from "./run-mcp-mutation"; +import { + type TMcpCreateWorkflowInput, + type TMcpDuplicateWorkflowInput, + type TMcpGetWorkflowInput, + type TMcpGetWorkflowRunInput, + type TMcpListWorkflowRunsInput, + type TMcpListWorkflowsInput, + type TMcpPatchWorkflowInput, + type TMcpTestWorkflowInput, + type TMcpWorkflowIdInput, + ZMcpCreateWorkflowInput, + ZMcpDuplicateWorkflowInput, + ZMcpGetWorkflowInput, + ZMcpGetWorkflowRunInput, + ZMcpListWorkflowRunsInput, + ZMcpListWorkflowsInput, + ZMcpPatchWorkflowInput, + ZMcpTestWorkflowInput, + ZMcpWorkflowIdInput, +} from "./workflow-schemas"; + +type WorkflowApiContext = ReturnType; + +export function buildListWorkflowsSearchParams(input: TMcpListWorkflowsInput): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set("workspaceId", input.workspaceId); + searchParams.set("limit", String(input.limit ?? 20)); + + if (input.cursor) { + searchParams.set("cursor", input.cursor); + } + + if (input.sortBy) { + searchParams.set("sortBy", input.sortBy); + } + + if (input.filter?.name?.contains) { + searchParams.set("filter[name][contains]", input.filter.name.contains); + } + + input.filter?.status?.in?.forEach((status) => { + searchParams.append("filter[status][in]", status); + }); + + return searchParams; +} + +export function buildListWorkflowRunsSearchParams(input: TMcpListWorkflowRunsInput): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set("workspaceId", input.workspaceId); + searchParams.set("limit", String(input.limit ?? 20)); + + if (input.cursor) { + searchParams.set("cursor", input.cursor); + } + + if (input.workflowId) { + searchParams.set("workflowId", input.workflowId); + } + + if (input.responseId) { + searchParams.set("responseId", input.responseId); + } + + input.filter?.status?.in?.forEach((status) => { + searchParams.append("filter[status][in]", status); + }); + + if (input.filter?.isDryRun !== undefined) { + searchParams.set("filter[isDryRun][eq]", String(input.filter.isDryRun)); + } + + return searchParams; +} + +/** + * The framework-agnostic workflow list handlers read query params off a `Request` (via + * `new URL(req.url).searchParams`). MCP has no incoming HTTP request, so build a synthetic one that + * is never dispatched — the handler only reads the search params, so the scheme/host are inert + * placeholders (an absolute base is required solely to satisfy `new URL`). + */ +function buildWorkflowsListRequest(searchParams: URLSearchParams): Request { + const url = new URL(MCP_API_ROUTE, "https://mcp.internal"); + url.search = searchParams.toString(); + return new Request(url); +} + +/** + * The create/patch/duplicate handlers read + validate a JSON body off `req.text()`. Build a + * synthetic (never-dispatched) POST carrying the body; an empty object is sent for optional-body + * operations so the handler never sees an empty string. + */ +function buildWorkflowsBodyRequest(body: unknown): Request { + return new Request(new URL(MCP_API_ROUTE, "https://mcp.internal"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); +} + +/** + * Run a workflow mutation with the same audit lifecycle the v3 route wrapper provides: build the + * audit log, inject it into the context (the handlers populate it via ctx.recordAudit post-mutation), + * mark success/failure, and always queue it. Mirrors the survey mutation tools. + */ +async function runWorkflowMutation( + extra: { authInfo?: NonNullable[0]> }, + action: Parameters[1], + run: (ctx: WorkflowApiContext) => Promise +): Promise { + // The shared runner owns the audit lifecycle; workflows only differ in building their API context + // (which threads the same auditLog through) before delegating to the caller's operation. + return runMcpMutation(extra, { action, resource: "workflow" }, ({ authentication, requestId, auditLog }) => + run(buildWorkflowApiContext(authentication, requestId, MCP_API_ROUTE, auditLog ?? undefined)) + ); +} + +export function registerWorkflowTools(server: McpServer): void { + registerScopedTool( + server, + "list_workflows", + { + title: "List workflows", + description: "List workflows in a Formbricks workspace using the v3 Workflows API contract.", + inputSchema: ZMcpListWorkflowsInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + ["workflows:read"], + async (input: TMcpListWorkflowsInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const ctx = buildWorkflowApiContext(getMcpAuthentication(extra.authInfo), requestId, MCP_API_ROUTE); + const response = await workflowsHandlers.list({ + req: buildWorkflowsListRequest(buildListWorkflowsSearchParams(input)), + ctx, + }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + registerScopedTool( + server, + "get_workflow", + { + title: "Get workflow", + description: "Get one Formbricks workflow using the v3 Workflows API contract.", + inputSchema: ZMcpGetWorkflowInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + ["workflows:read"], + async (input: TMcpGetWorkflowInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const ctx = buildWorkflowApiContext(getMcpAuthentication(extra.authInfo), requestId, MCP_API_ROUTE); + const response = await workflowsHandlers.get({ ctx, params: { workflowId: input.workflowId } }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + registerScopedTool( + server, + "list_workflow_runs", + { + title: "List workflow runs", + description: + "List workflow runs for a Formbricks workspace (newest first) using the v3 Workflows API contract.", + inputSchema: ZMcpListWorkflowRunsInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + ["workflows:read"], + async (input: TMcpListWorkflowRunsInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const ctx = buildWorkflowApiContext(getMcpAuthentication(extra.authInfo), requestId, MCP_API_ROUTE); + const response = await workflowsHandlers.listRuns({ + req: buildWorkflowsListRequest(buildListWorkflowRunsSearchParams(input)), + ctx, + }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + registerScopedTool( + server, + "get_workflow_run", + { + title: "Get workflow run", + description: + "Get one Formbricks workflow run with its ordered step logs using the v3 Workflows API contract.", + inputSchema: ZMcpGetWorkflowRunInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + ["workflows:read"], + async (input: TMcpGetWorkflowRunInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const ctx = buildWorkflowApiContext(getMcpAuthentication(extra.authInfo), requestId, MCP_API_ROUTE); + const response = await workflowsHandlers.getRun({ ctx, params: { runId: input.runId } }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + registerScopedTool( + server, + "test_workflow", + { + title: "Test workflow (dry-run)", + description: [ + "Dry-run a Formbricks workflow using the v3 Workflows API contract:", + "validate its live definition would execute, resolve the trigger's survey + ending cards, and confirm every literal send_email recipient is an organization member.", + "No run is persisted and no side effects occur; the response reports { ok, problems }.", + ].join(" "), + annotations: { + // Dry-run: validates + mock-executes with all side effects suppressed, so no world mutation. + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema: ZMcpTestWorkflowInput.shape, + }, + ["workflows:read"], + async (input: TMcpTestWorkflowInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const ctx = buildWorkflowApiContext(getMcpAuthentication(extra.authInfo), requestId, MCP_API_ROUTE); + const response = await workflowsHandlers.testWorkflow({ + ctx, + params: { workflowId: input.workflowId }, + }); + + return await responseToMcpToolResult(response, requestId); + } + ); + + registerScopedTool( + server, + "create_workflow", + { + title: "Create workflow", + description: "Create a Formbricks workflow (always as a draft) using the v3 Workflows API contract.", + inputSchema: ZMcpCreateWorkflowInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpCreateWorkflowInput, extra) => + runWorkflowMutation(extra, "created", (ctx) => + workflowsHandlers.create({ req: buildWorkflowsBodyRequest(input), ctx }) + ) + ); + + registerScopedTool( + server, + "patch_workflow", + { + title: "Patch workflow", + description: [ + "Update a Formbricks workflow using the v3 Workflows API patch contract.", + "Provided top-level fields replace that whole subtree; definition edits are only accepted while draft or disabled.", + ].join(" "), + inputSchema: ZMcpPatchWorkflowInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpPatchWorkflowInput, extra) => + runWorkflowMutation(extra, "updated", (ctx) => + workflowsHandlers.patch({ + req: buildWorkflowsBodyRequest(input.data), + ctx, + params: { workflowId: input.workflowId }, + }) + ) + ); + + registerScopedTool( + server, + "duplicate_workflow", + { + title: "Duplicate workflow", + description: + "Duplicate a Formbricks workflow as a new draft (empty run + version history) using the v3 Workflows API contract.", + inputSchema: ZMcpDuplicateWorkflowInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpDuplicateWorkflowInput, extra) => + runWorkflowMutation(extra, "created", (ctx) => + workflowsHandlers.duplicate({ + req: buildWorkflowsBodyRequest(input.name ? { name: input.name } : {}), + ctx, + params: { workflowId: input.workflowId }, + }) + ) + ); + + registerScopedTool( + server, + "delete_workflow", + { + title: "Delete workflow", + description: "Delete a Formbricks workflow using the v3 Workflows API contract.", + inputSchema: ZMcpWorkflowIdInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpWorkflowIdInput, extra) => + runWorkflowMutation(extra, "deleted", (ctx) => + workflowsHandlers.delete({ ctx, params: { workflowId: input.workflowId } }) + ) + ); + + registerScopedTool( + server, + "enable_workflow", + { + title: "Enable workflow", + description: [ + "Enable a Formbricks workflow using the v3 Workflows API contract:", + "validate executability, snapshot an immutable version, and make it live.", + "Once live it runs on matching survey responses and can send emails.", + ].join(" "), + inputSchema: ZMcpWorkflowIdInput.shape, + annotations: { + readOnlyHint: false, + // Enabling activates a live, email-sending workflow — high impact. + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpWorkflowIdInput, extra) => + runWorkflowMutation(extra, "updated", (ctx) => + workflowsHandlers.enable({ ctx, params: { workflowId: input.workflowId } }) + ) + ); + + registerScopedTool( + server, + "disable_workflow", + { + title: "Disable workflow", + description: + "Disable a live Formbricks workflow (stops future runs) using the v3 Workflows API contract.", + inputSchema: ZMcpWorkflowIdInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpWorkflowIdInput, extra) => + runWorkflowMutation(extra, "updated", (ctx) => + workflowsHandlers.disable({ ctx, params: { workflowId: input.workflowId } }) + ) + ); + + registerScopedTool( + server, + "archive_workflow", + { + title: "Archive workflow", + description: "Archive a Formbricks workflow using the v3 Workflows API contract.", + inputSchema: ZMcpWorkflowIdInput.shape, + annotations: { + readOnlyHint: false, + // Archiving soft-deletes and excludes the workflow from default reads. + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpWorkflowIdInput, extra) => + runWorkflowMutation(extra, "updated", (ctx) => + workflowsHandlers.archive({ ctx, params: { workflowId: input.workflowId } }) + ) + ); + + registerScopedTool( + server, + "unarchive_workflow", + { + title: "Unarchive workflow", + description: "Unarchive a Formbricks workflow (back to draft) using the v3 Workflows API contract.", + inputSchema: ZMcpWorkflowIdInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + ["workflows:write"], + async (input: TMcpWorkflowIdInput, extra) => + runWorkflowMutation(extra, "updated", (ctx) => + workflowsHandlers.unarchive({ ctx, params: { workflowId: input.workflowId } }) + ) + ); +} diff --git a/apps/web/modules/mcp/tools/workspaces.test.ts b/apps/web/modules/mcp/tools/workspaces.test.ts index 75e862d1249e..0bc6bdeeaa0f 100644 --- a/apps/web/modules/mcp/tools/workspaces.test.ts +++ b/apps/web/modules/mcp/tools/workspaces.test.ts @@ -25,6 +25,7 @@ const readAuthInfo = { const writeOnlyAuthInfo = { ...readAuthInfo, scopes: ["surveys:write"] }; const feedbackReadAuthInfo = { ...readAuthInfo, scopes: ["feedbackRecords:read"] }; +const workflowReadAuthInfo = { ...readAuthInfo, scopes: ["workflows:read"] }; function createToolServer() { const tools = new Map< @@ -99,4 +100,18 @@ describe("registerWorkspaceTools", () => { expect(listV3Workspaces).toHaveBeenCalled(); expect(result.isError).toBeUndefined(); }); + + // Same for the workflow tools: auth.ts admits a token holding only workflows:read, so it must be + // able to resolve the workspaceId every workflow tool requires. + test("allows a workflows-only token to discover workspaces", async () => { + const { tools } = createToolServer(); + vi.mocked(listV3Workspaces).mockResolvedValue( + successListResponse([], { nextCursor: null, totalCount: 0 }, { requestId: "req_tool" }) + ); + + const result = await tools.get("list_workspaces")!.handler({}, { authInfo: workflowReadAuthInfo }); + + expect(listV3Workspaces).toHaveBeenCalled(); + expect(result.isError).toBeUndefined(); + }); }); diff --git a/apps/web/modules/mcp/tools/workspaces.ts b/apps/web/modules/mcp/tools/workspaces.ts index ad36bccb9c28..bf69ea2c276a 100644 --- a/apps/web/modules/mcp/tools/workspaces.ts +++ b/apps/web/modules/mcp/tools/workspaces.ts @@ -3,16 +3,23 @@ import { listV3Workspaces } from "@/app/api/v3/workspaces/lib/operations"; import { MCP_API_ROUTE } from "@/modules/mcp/constants"; import { getMcpAuthentication, getMcpRequestId } from "../auth"; import { responseToMcpToolResult } from "../errors"; -import { guardMcpAnyScope } from "./guard-scopes"; +import { registerScopedTool } from "./guard-scopes"; import { type TMcpListWorkspacesInput, ZMcpListWorkspacesInput } from "./schemas"; export function registerWorkspaceTools(server: McpServer): void { - server.registerTool( + // list_workspaces is the workspaceId-discovery prerequisite for the survey, workflow AND + // feedback-record tools, so it gates on ANY resource read scope rather than a single one. auth.ts's + // baseline is now "at least one resource scope" (MCP_MINIMUM_SCOPES), so a workflows-only or + // feedbackRecords-only token is a legitimate client and must still be able to discover its + // workspaceId. The result is derived from the caller's own memberships/key grants, so admitting any + // read scope exposes nothing extra. + registerScopedTool( + server, "list_workspaces", { title: "List workspaces", description: - "List the Formbricks workspaces the authenticated user can access. Use this to discover the workspaceId required by the survey and feedback-record tools.", + "List the Formbricks workspaces the authenticated user can access. Use this to discover the workspaceId required by the survey, workflow and feedback-record tools.", inputSchema: ZMcpListWorkspacesInput.shape, annotations: { readOnlyHint: true, @@ -21,20 +28,9 @@ export function registerWorkspaceTools(server: McpServer): void { openWorldHint: true, }, }, + { anyOf: ["surveys:read", "workflows:read", "feedbackRecords:read"] }, async (_input: TMcpListWorkspacesInput, extra) => { const requestId = getMcpRequestId(extra.authInfo); - // Workspace discovery is the read-prerequisite for every other tool group, so any read scope is - // enough — a feedbackRecords-only token still needs a workspaceId. The result is derived from the - // caller's own memberships/key grants, so it exposes nothing extra either way. - const scopeError = await guardMcpAnyScope( - extra.authInfo, - ["surveys:read", "feedbackRecords:read"], - requestId - ); - if (scopeError) { - return scopeError; - } - const response = await listV3Workspaces({ authentication: getMcpAuthentication(extra.authInfo), requestId, diff --git a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts index 246a6a322fa6..632b52856679 100644 --- a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts +++ b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts @@ -8,6 +8,7 @@ const { mockCaptureSurveyResponsePostHogEvent, mockCreatePinnedDispatcher, mockDispatcherDestroy, + mockEnqueueResponseCompletedWorkflowRuns, mockGetIntegrations, mockGetFinishedResponseCountBySurveyId, mockGetResponseCountBySurveyId, @@ -34,6 +35,7 @@ const { mockCaptureSurveyResponsePostHogEvent: vi.fn(), mockCreatePinnedDispatcher: vi.fn(() => ({ destroy: dispatcherDestroy })), mockDispatcherDestroy: dispatcherDestroy, + mockEnqueueResponseCompletedWorkflowRuns: vi.fn(), mockGetIntegrations: vi.fn(), mockGetFinishedResponseCountBySurveyId: vi.fn(), mockGetResponseCountBySurveyId: vi.fn(), @@ -136,6 +138,14 @@ vi.mock("@/modules/survey/follow-ups/lib/follow-ups", () => ({ sendFollowUpsForResponse: mockSendFollowUpsForResponse, })); +vi.mock("@/modules/ee/workflows/lib/runner/enqueue-response-completed-runs", () => ({ + enqueueResponseCompletedWorkflowRuns: mockEnqueueResponseCompletedWorkflowRuns, +})); + +vi.mock("@/modules/ee/workflows/lib/runner/dispatch", () => ({ + dispatchWorkflowRunViaJobs: vi.fn(), +})); + vi.mock("@formbricks/logger", () => ({ logger: { debug: vi.fn(), @@ -226,6 +236,7 @@ describe("processResponsePipelineJob", () => { mockRecordResponseCreatedMeterEvent.mockResolvedValue(undefined); mockSendResponseFinishedEmail.mockResolvedValue(undefined); mockSendFollowUpsForResponse.mockResolvedValue({ ok: true, data: [] }); + mockEnqueueResponseCompletedWorkflowRuns.mockResolvedValue(undefined); mockSendTelemetryEvents.mockResolvedValue(undefined); mockPrismaSurveyUpdate.mockResolvedValue(undefined); mockFetch.mockResolvedValue({ @@ -240,6 +251,51 @@ describe("processResponsePipelineJob", () => { mockFetch.mockReset(); }); + test("invokes the workflow runner on responseFinished", async () => { + await expect( + processResponsePipelineJob({ ...baseData, event: "responseFinished" }, baseContext) + ).resolves.toBeUndefined(); + + expect(mockEnqueueResponseCompletedWorkflowRuns).toHaveBeenCalledTimes(1); + expect(mockEnqueueResponseCompletedWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ + response: expect.objectContaining({ id: "response_123" }), + workspaceId: "workspace_123", + // The runner gates on the organization's workflows entitlement, so the pipeline must + // thread the resolved organization through. + organizationId: "org_123", + }) + ); + }); + + test("does not invoke the workflow runner on responseCreated", async () => { + await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined(); + expect(mockEnqueueResponseCompletedWorkflowRuns).not.toHaveBeenCalled(); + }); + + test("rethrows a transient DB pool-exhaustion error from the workflow runner so the job retries", async () => { + mockEnqueueResponseCompletedWorkflowRuns.mockRejectedValue( + new Error("Timed out fetching a new connection from the connection pool") + ); + + await expect( + processResponsePipelineJob({ ...baseData, event: "responseFinished" }, baseContext) + ).rejects.toThrow(/connection pool/i); + }); + + test("isolates a workflow runner failure from the response pipeline", async () => { + mockEnqueueResponseCompletedWorkflowRuns.mockRejectedValue(new Error("runner boom")); + + await expect( + processResponsePipelineJob({ ...baseData, event: "responseFinished" }, baseContext) + ).resolves.toBeUndefined(); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + "Response pipeline workflow run enqueue failed" + ); + }); + test("processes responseCreated jobs with webhook, metering, and telemetry side effects", async () => { mockPrismaWebhookFindMany.mockResolvedValue([ { diff --git a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts index 889f675efc45..ebcaced63e6c 100644 --- a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts +++ b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts @@ -4,17 +4,19 @@ import { prisma } from "@formbricks/database"; import { PipelineTriggers, Prisma, type Webhook } from "@formbricks/database/prisma"; import { type JobHandler, type TResponsePipelineJobData, UnrecoverableError } from "@formbricks/jobs"; import { logger } from "@formbricks/logger"; -import { DatabaseError } from "@formbricks/types/errors"; import { type TUserLocale, ZUserLocale } from "@formbricks/types/user"; import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants"; import { generateStandardWebhookSignature } from "@/lib/crypto"; import { handleFeedbackSourcePipeline } from "@/lib/feedback-source/pipeline-handler"; import { getIntegrations } from "@/lib/integration/service"; +import { isDatabasePoolExhaustionError } from "@/lib/jobs/pool-exhaustion"; import { getResponseCountBySurveyId } from "@/lib/response/service"; import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler"; import { type TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering"; +import { dispatchWorkflowRunViaJobs } from "@/modules/ee/workflows/lib/runner/dispatch"; +import { enqueueResponseCompletedWorkflowRuns } from "@/modules/ee/workflows/lib/runner/enqueue-response-completed-runs"; import { sendResponseFinishedEmail } from "@/modules/email"; import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog"; import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; @@ -113,20 +115,6 @@ const toUserLocale = (locale: string): TUserLocale => { return parsedLocale.success ? parsedLocale.data : DEFAULT_NOTIFICATION_LOCALE; }; -export const isPipelinePoolExhaustionError = (error: unknown): boolean => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2024") { - return true; - } - - if (error instanceof DatabaseError || error instanceof Error) { - return /Timed out fetching a new connection from the connection pool|connection pool timeout/i.test( - error.message - ); - } - - return false; -}; - const createWebhookMessageId = ({ event, jobId, @@ -632,12 +620,14 @@ const runResponseFinishedSideEffects = async ({ data, logContext, organizationId, + stripeCustomerId, survey, workspaceId, }: { data: TResponsePipelineJobData; logContext: ReturnType; organizationId: string; + stripeCustomerId: string | null | undefined; survey: TPipelineSurvey; workspaceId: string; }) => { @@ -724,6 +714,27 @@ const runResponseFinishedSideEffects = async ({ organizationId, survey, }); + + // Workflow runner (producer): enqueue runs for matching enabled workflows. Isolated so a runner + // failure never breaks the response pipeline job, its retries, or the other side-effects above. + try { + await enqueueResponseCompletedWorkflowRuns({ + response: data.response, + workspaceId, + organizationId, + stripeCustomerId, + dispatch: dispatchWorkflowRunViaJobs, + logContext, + }); + } catch (error) { + // Transient DB pool exhaustion must propagate so the job retries (same contract as the outer + // pipeline catch); otherwise a completed-response trigger is silently lost. Only non-retryable + // failures are swallowed, so they never break the other responseFinished side-effects above. + if (isDatabasePoolExhaustionError(error)) { + throw error; + } + logger.error({ ...logContext, err: error }, "Response pipeline workflow run enqueue failed"); + } }; const runResponseCreatedSideEffects = async ({ @@ -825,6 +836,7 @@ export const processResponsePipelineJob: JobHandler = data, logContext, organizationId: organization.id, + stripeCustomerId: organization.billing?.stripeCustomerId, survey, workspaceId: data.workspaceId, }); @@ -840,7 +852,7 @@ export const processResponsePipelineJob: JobHandler = }); } } catch (error) { - if (isPipelinePoolExhaustionError(error)) { + if (isDatabasePoolExhaustionError(error)) { logger.warn( { ...logContext, diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-action-multi-email-input.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-action-multi-email-input.tsx index 2d3288e944e5..4dedd319c9b1 100644 --- a/apps/web/modules/survey/follow-ups/components/follow-up-action-multi-email-input.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-action-multi-email-input.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; import { isValidEmail } from "@/lib/utils/email"; import { cn } from "@/modules/ui/lib/utils"; @@ -6,13 +7,16 @@ interface FollowUpActionMultiEmailInputProps { emails: string[]; setEmails: React.Dispatch>; isInvalid?: boolean; + disabled?: boolean; } const FollowUpActionMultiEmailInput = ({ emails, setEmails, isInvalid, + disabled, }: FollowUpActionMultiEmailInputProps) => { + const { t } = useTranslation(); const [inputValue, setInputValue] = useState(""); const [error, setError] = useState(""); @@ -22,12 +26,12 @@ const FollowUpActionMultiEmailInput = ({ if (!email) return; if (!isValidEmail(email)) { - setError("Please enter a valid email address"); + setError(t("workspace.surveys.edit.follow_ups_modal_action_email_invalid")); return; } if (emails.includes(email)) { - setError("This email has already been added"); + setError(t("workspace.surveys.edit.follow_ups_modal_action_email_already_added")); return; } @@ -66,7 +70,8 @@ const FollowUpActionMultiEmailInput = ({
    {emails.map((email, index) => (
    {email}
    ))} setInputValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleInputBlur} - placeholder={emails.length === 0 ? "Write an email & press space bar" : ""} + placeholder={ + emails.length === 0 + ? t("workspace.surveys.edit.follow_ups_modal_action_email_input_placeholder") + : "" + } className="min-w-[180px] flex-1 border-none p-0 py-1 text-sm placeholder:text-slate-400 focus:ring-0" />
    diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-modal.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-modal.tsx index 9c07330d2929..6f345d6b10bb 100644 --- a/apps/web/modules/survey/follow-ups/components/follow-up-modal.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-modal.tsx @@ -16,7 +16,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; -import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/types/surveys/follow-up"; import { TSurvey } from "@formbricks/types/surveys/types"; import { getTextContent } from "@formbricks/types/surveys/validation"; @@ -29,7 +28,10 @@ import { ZCreateSurveyFollowUpFormSchema, } from "@/modules/survey/editor/types/survey-follow-up"; import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input"; -import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; +import { + type EmailSendToOption, + buildEmailSendToOptions, +} from "@/modules/survey/follow-ups/lib/email-send-to-options"; import { getElementIconMap } from "@/modules/survey/lib/elements"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Alert, AlertTitle } from "@/modules/ui/components/alert"; @@ -78,12 +80,6 @@ interface AddFollowUpModalProps { locale: TUserLocale; } -type EmailSendToOption = { - type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user" | "verifiedEmail"; - label: string; - id: string; -}; - export const FollowUpModal = ({ localSurvey, open, @@ -102,81 +98,17 @@ export const FollowUpModal = ({ const containerRef = useRef(null); const [firstRender, setFirstRender] = useState(true); - const emailSendToOptions: EmailSendToOption[] = useMemo(() => { - const elements = getElementsFromBlocks(localSurvey.blocks); - - const openTextAndContactElements = elements.filter((element) => { - if (element.type === TSurveyElementTypeEnum.ContactInfo) { - return element.email.show; - } - - if (element.type === TSurveyElementTypeEnum.OpenText) { - if (element.inputType === "email") { - return true; - } - - return false; - } - - return false; - }); - - const hiddenFields = localSurvey.hiddenFields.fieldIds - ? { fieldIds: localSurvey.hiddenFields.fieldIds } - : { fieldIds: [] }; - - const updatedTeamMemberDetails = teamMemberDetails.map((teamMemberDetail) => { - if (teamMemberDetail.email === userEmail) { - return { name: "Yourself", email: userEmail }; - } - - return teamMemberDetail; - }); - - const isUserEmailInTeamMemberDetails = updatedTeamMemberDetails.some( - (teamMemberDetail) => teamMemberDetail.email === userEmail - ); - - const updatedTeamMembers = isUserEmailInTeamMemberDetails - ? updatedTeamMemberDetails - : [...updatedTeamMemberDetails, { email: userEmail, name: "Yourself" }]; - - const verifiedEmailOption = localSurvey.isVerifyEmailEnabled - ? [ - { - label: t("common.verified_email"), - id: "verifiedEmail", - type: "verifiedEmail" as EmailSendToOption["type"], - }, - ] - : []; - - return [ - ...verifiedEmailOption, - ...openTextAndContactElements.map((element) => ({ - label: getTextContent( - recallToHeadline(element.headline, localSurvey, false, selectedLanguageCode)[selectedLanguageCode] - ), - id: element.id, - type: - element.type === TSurveyElementTypeEnum.OpenText - ? "openTextElement" - : ("contactInfoElement" as EmailSendToOption["type"]), - })), - - ...hiddenFields.fieldIds.map((fieldId: string) => ({ - label: fieldId, - id: fieldId, - type: "hiddenField" as EmailSendToOption["type"], - })), - - ...updatedTeamMembers.map((member) => ({ - label: `${member.name} (${member.email})`, - id: member.email, - type: "user" as EmailSendToOption["type"], - })), - ] satisfies EmailSendToOption[]; - }, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail, t]); + const emailSendToOptions: EmailSendToOption[] = useMemo( + () => + buildEmailSendToOptions({ + survey: localSurvey, + teamMemberDetails, + userEmail, + selectedLanguageCode, + t, + }), + [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail, t] + ); const form = useForm({ defaultValues: { diff --git a/apps/web/modules/survey/follow-ups/components/follow-ups-view.tsx b/apps/web/modules/survey/follow-ups/components/follow-ups-view.tsx index d17f0896056e..ed059f173276 100644 --- a/apps/web/modules/survey/follow-ups/components/follow-ups-view.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-ups-view.tsx @@ -1,6 +1,7 @@ "use client"; import { MailIcon } from "lucide-react"; +import Link from "next/link"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { TSurveyFollowUp } from "@formbricks/types/surveys/follow-up"; @@ -10,6 +11,7 @@ import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/works import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up"; import { FollowUpItem } from "@/modules/survey/follow-ups/components/follow-up-item"; import { FollowUpModal } from "@/modules/survey/follow-ups/components/follow-up-modal"; +import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; @@ -102,23 +104,37 @@ export const FollowUpsView = ({ )}
-
- {surveyFollowUps.map((followUp) => { - return ( - - ); - })} -
+ {surveyFollowUps.length > 0 && ( +
+ {surveyFollowUps.map((followUp) => { + return ( + + ); + })} +
+ )} + + + {t("workspace.surveys.edit.follow_ups_workflows_alert_title")} + + + {t("common.learn_more")} + + + ({ + mockGetElementsFromBlocks: vi.fn(), + mockRecallToHeadline: vi.fn(), + mockGetTextContent: vi.fn(), +})); + +vi.mock("@/modules/survey/lib/client-utils", () => ({ + getElementsFromBlocks: mockGetElementsFromBlocks, +})); + +vi.mock("@/lib/utils/recall", () => ({ + recallToHeadline: mockRecallToHeadline, +})); + +vi.mock("@formbricks/types/surveys/validation", () => ({ + getTextContent: mockGetTextContent, +})); + +const t = ((key: string) => key) as unknown as Parameters[0]["t"]; + +const makeSurvey = (overrides: Partial = {}): TSurvey => + ({ + id: "survey1", + blocks: [], + hiddenFields: { enabled: true, fieldIds: [] }, + isVerifyEmailEnabled: false, + ...overrides, + }) as unknown as TSurvey; + +describe("buildEmailSendToOptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetElementsFromBlocks.mockReturnValue([]); + // recallToHeadline returns a language-keyed map; the helper reads [selectedLanguageCode]. + mockRecallToHeadline.mockImplementation((headline) => headline); + mockGetTextContent.mockImplementation((value: string) => value); + }); + + test("includes OpenText(email) and ContactInfo(email.show) elements, skipping others", () => { + mockGetElementsFromBlocks.mockReturnValue([ + { id: "q1", type: TSurveyElementTypeEnum.OpenText, inputType: "email", headline: { default: "Email" } }, + { id: "q2", type: TSurveyElementTypeEnum.OpenText, inputType: "text", headline: { default: "Name" } }, + { + id: "q3", + type: TSurveyElementTypeEnum.ContactInfo, + email: { show: true }, + headline: { default: "Contact" }, + }, + { + id: "q4", + type: TSurveyElementTypeEnum.ContactInfo, + email: { show: false }, + headline: { default: "Contact hidden" }, + }, + ]); + + const options = buildEmailSendToOptions({ + survey: makeSurvey(), + teamMemberDetails: [], + userEmail: "me@example.com", + selectedLanguageCode: "default", + t, + }); + + const elementOptions = options.filter( + (o) => o.type === "openTextElement" || o.type === "contactInfoElement" + ); + expect(elementOptions).toEqual([ + { id: "q1", type: "openTextElement", label: "Email" }, + { id: "q3", type: "contactInfoElement", label: "Contact" }, + ]); + }); + + test("maps hidden fields to options labelled by id", () => { + const options = buildEmailSendToOptions({ + survey: makeSurvey({ hiddenFields: { enabled: true, fieldIds: ["utm", "ref"] } }), + teamMemberDetails: [], + userEmail: "me@example.com", + selectedLanguageCode: "default", + t, + }); + + expect(options.filter((o) => o.type === "hiddenField")).toEqual([ + { id: "utm", type: "hiddenField", label: "utm" }, + { id: "ref", type: "hiddenField", label: "ref" }, + ]); + }); + + test("adds a verified-email option only when the survey enables it", () => { + const withVerify = buildEmailSendToOptions({ + survey: makeSurvey({ isVerifyEmailEnabled: true }), + teamMemberDetails: [], + userEmail: "me@example.com", + selectedLanguageCode: "default", + t, + }); + expect(withVerify.some((o) => o.type === "verifiedEmail" && o.id === "verifiedEmail")).toBe(true); + + const withoutVerify = buildEmailSendToOptions({ + survey: makeSurvey({ isVerifyEmailEnabled: false }), + teamMemberDetails: [], + userEmail: "me@example.com", + selectedLanguageCode: "default", + t, + }); + expect(withoutVerify.some((o) => o.type === "verifiedEmail")).toBe(false); + }); + + test("surfaces team members and renames the current user to 'Yourself'", () => { + const options = buildEmailSendToOptions({ + survey: makeSurvey(), + teamMemberDetails: [ + { name: "Alice", email: "alice@example.com" }, + { name: "Me", email: "me@example.com" }, + ], + userEmail: "me@example.com", + selectedLanguageCode: "default", + t, + }); + + const userOptions = options.filter((o) => o.type === "user"); + expect(userOptions).toEqual([ + { id: "alice@example.com", type: "user", label: "Alice (alice@example.com)" }, + { id: "me@example.com", type: "user", label: "Yourself (me@example.com)" }, + ]); + }); + + test("appends the current user as 'Yourself' when absent from the team roster", () => { + const options = buildEmailSendToOptions({ + survey: makeSurvey(), + teamMemberDetails: [{ name: "Alice", email: "alice@example.com" }], + userEmail: "me@example.com", + selectedLanguageCode: "default", + t, + }); + + expect(options.filter((o) => o.type === "user")).toEqual([ + { id: "alice@example.com", type: "user", label: "Alice (alice@example.com)" }, + { id: "me@example.com", type: "user", label: "Yourself (me@example.com)" }, + ]); + }); +}); diff --git a/apps/web/modules/survey/follow-ups/lib/email-send-to-options.ts b/apps/web/modules/survey/follow-ups/lib/email-send-to-options.ts new file mode 100644 index 000000000000..e5db75b7ef61 --- /dev/null +++ b/apps/web/modules/survey/follow-ups/lib/email-send-to-options.ts @@ -0,0 +1,89 @@ +import type { TFunction } from "i18next"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import { getTextContent } from "@formbricks/types/surveys/validation"; +import { recallToHeadline } from "@/lib/utils/recall"; +import type { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up"; +import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; + +/** A selectable recipient for a survey-response email (Follow-Ups + workflow send_email share this). */ +export interface EmailSendToOption { + type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user" | "verifiedEmail"; + label: string; + id: string; +} + +/** + * Builds the "who to send to" options for a survey-response email, identical to the survey + * Follow-Ups recipient dropdown: OpenText(email) + ContactInfo(email.show) elements, hidden fields, + * an optional verified-email entry, and the team roster (with the current user surfaced as + * "Yourself"). Shared by `FollowUpModal` and the workflow `send_email` inspector form. + */ +export const buildEmailSendToOptions = ({ + survey, + teamMemberDetails, + userEmail, + selectedLanguageCode, + t, +}: { + survey: TSurvey; + teamMemberDetails: TFollowUpEmailToUser[]; + userEmail: string; + selectedLanguageCode: string; + t: TFunction; +}): EmailSendToOption[] => { + const elements = getElementsFromBlocks(survey.blocks); + + const openTextAndContactElements = elements.filter((element) => { + if (element.type === TSurveyElementTypeEnum.ContactInfo) { + return element.email.show; + } + + if (element.type === TSurveyElementTypeEnum.OpenText) { + return element.inputType === "email"; + } + + return false; + }); + + const hiddenFieldIds = survey.hiddenFields.fieldIds ?? []; + + const updatedTeamMemberDetails = teamMemberDetails.map((teamMemberDetail) => + teamMemberDetail.email === userEmail ? { name: "Yourself", email: userEmail } : teamMemberDetail + ); + + const isUserEmailInTeamMemberDetails = updatedTeamMemberDetails.some( + (teamMemberDetail) => teamMemberDetail.email === userEmail + ); + + const updatedTeamMembers = isUserEmailInTeamMemberDetails + ? updatedTeamMemberDetails + : [...updatedTeamMemberDetails, { email: userEmail, name: "Yourself" }]; + + const verifiedEmailOption: EmailSendToOption[] = survey.isVerifyEmailEnabled + ? [{ label: t("common.verified_email"), id: "verifiedEmail", type: "verifiedEmail" }] + : []; + + return [ + ...verifiedEmailOption, + ...openTextAndContactElements.map((element) => ({ + label: getTextContent( + recallToHeadline(element.headline, survey, false, selectedLanguageCode)[selectedLanguageCode] + ), + id: element.id, + type: (element.type === TSurveyElementTypeEnum.OpenText + ? "openTextElement" + : "contactInfoElement") as EmailSendToOption["type"], + })), + ...hiddenFieldIds.map((fieldId) => ({ + label: fieldId, + id: fieldId, + type: "hiddenField" as EmailSendToOption["type"], + })), + ...updatedTeamMembers.map((member) => ({ + label: `${member.name} (${member.email})`, + id: member.email, + type: "user" as EmailSendToOption["type"], + })), + ]; +}; diff --git a/apps/web/modules/survey/follow-ups/lib/email.ts b/apps/web/modules/survey/follow-ups/lib/email.ts index ad27571dd9b0..c407050083ec 100644 --- a/apps/web/modules/survey/follow-ups/lib/email.ts +++ b/apps/web/modules/survey/follow-ups/lib/email.ts @@ -1,21 +1,9 @@ -import sanitizeHtml from "sanitize-html"; -import { - ProcessedHiddenField, - ProcessedResponseElement, - ProcessedVariable, - renderFollowUpEmail, -} from "@formbricks/email"; import { TResponse } from "@formbricks/types/responses"; -import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyFollowUp } from "@formbricks/types/surveys/follow-up"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { DEFAULT_LOCALE, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/constants"; -import { getElementResponseMapping } from "@/lib/responses"; -import { parseRecallInfo } from "@/lib/utils/recall"; -import { getTranslate } from "@/lingodotdev/server"; import { sendEmail } from "@/modules/email"; -import { resolveStorageUrl } from "@/modules/storage/utils"; +import { buildSurveyResponseEmailHtml } from "@/modules/email/lib/survey-response-email"; export const sendFollowUpEmail = async ({ followUp, @@ -46,110 +34,15 @@ export const sendFollowUpEmail = async ({ }, } = followUp; - // Worker context (no request scope) — pass explicit locale to skip headers()/cookies(). - // Falls back to DEFAULT_LOCALE when the respondent locale wasn't captured at submission. - const t = await getTranslate(locale ?? DEFAULT_LOCALE); - - // Process body: parse recall tags and sanitize HTML. - // Recall values are escaped as they are substituted (the last argument). The body is author-written - // HTML and the sanitizer below legitimately allows `
`, so it cannot distinguish that markup - // from markup a respondent smuggled in through an open-text answer — an anonymous respondent could - // otherwise place an arbitrary clickable link, with text of their choosing, into a Formbricks-branded - // email delivered to the survey owner. - const processedBody = sanitizeHtml( - // Same resolved locale the translations above use, not a hardcoded "en-US": recall values include - // date answers, which parseRecallInfo formats per locale, so pinning it here would render US dates - // in an otherwise correctly localized email. - parseRecallInfo( - body, - response.data, - response.variables, - false, - locale ?? DEFAULT_LOCALE, - undefined, - true - ), - { - allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"], - allowedAttributes: { - a: ["href", "rel", "target"], - "*": ["dir", "class"], - }, - allowedSchemes: ["http", "https"], - allowedSchemesByTag: { - a: ["http", "https"], - }, - } - ); - - // Process response data - // Resolve relative storage URLs to absolute URLs for email rendering - const responseData: ProcessedResponseElement[] = attachResponseData - ? getElementResponseMapping(survey, response).map((e) => { - // Resolve URLs for picture selection and file upload responses - if ( - (e.type === TSurveyElementTypeEnum.PictureSelection || - e.type === TSurveyElementTypeEnum.FileUpload) && - Array.isArray(e.response) - ) { - return { - element: e.element, - response: e.response.map((url) => resolveStorageUrl(url)), - type: e.type, - }; - } - return { - element: e.element, - response: e.response, - type: e.type, - }; - }) - : []; - - // Process variables - const variables: ProcessedVariable[] = - attachResponseData && includeVariables - ? survey.variables - .filter((variable) => { - const variableResponse = response.variables[variable.id]; - return ( - (typeof variableResponse === "string" || typeof variableResponse === "number") && - variableResponse !== undefined - ); - }) - .map((variable) => ({ - id: variable.id, - name: variable.name, - type: variable.type, - value: response.variables[variable.id], - })) - : []; - - // Process hidden fields - const hiddenFields: ProcessedHiddenField[] = - attachResponseData && includeHiddenFields - ? (survey.hiddenFields.fieldIds - ?.filter((hiddenFieldId) => { - const hiddenFieldResponse = response.data[hiddenFieldId]; - return hiddenFieldResponse && typeof hiddenFieldResponse === "string"; - }) - .map((hiddenFieldId) => ({ - id: hiddenFieldId, - value: response.data[hiddenFieldId] as string, - })) ?? []) - : []; - - const emailHtmlBody = await renderFollowUpEmail({ - body: processedBody, - responseData, - variables, - hiddenFields, + const emailHtmlBody = await buildSurveyResponseEmailHtml({ + body, + survey, + response, + attachResponseData, + includeVariables, + includeHiddenFields, logoUrl, - t, - privacyUrl: PRIVACY_URL || undefined, - termsUrl: TERMS_URL || undefined, - imprintUrl: IMPRINT_URL || undefined, - imprintAddress: IMPRINT_ADDRESS || undefined, + locale, }); await sendEmail({ diff --git a/apps/web/modules/ui/components/button/index.tsx b/apps/web/modules/ui/components/button/index.tsx index 3f3aa5ae0698..34a74b48cd1e 100644 --- a/apps/web/modules/ui/components/button/index.tsx +++ b/apps/web/modules/ui/components/button/index.tsx @@ -26,7 +26,7 @@ const buttonVariants = cva( tall: "h-10 rounded-md px-3 text-xs", }, loading: { - true: "cursor-not-allowed opacity-50", + true: "relative cursor-not-allowed opacity-50", }, }, defaultVariants: { @@ -53,9 +53,11 @@ const Button = React.forwardRef( {...props} disabled={loading || disabled}> {loading ? ( + // Keep the label in the layout but invisible and center the spinner over it, so the + // button's width never changes between idle and loading (no layout shift). <> - - {children} + + {children} ) : ( children diff --git a/apps/web/modules/ui/components/card-table/index.tsx b/apps/web/modules/ui/components/card-table/index.tsx new file mode 100644 index 000000000000..8bb9a947474f --- /dev/null +++ b/apps/web/modules/ui/components/card-table/index.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +interface CardTableProps { + children: ReactNode; + className?: string; +} + +export const CardTable = ({ children, className }: Readonly) => ( +
{children}
+); + +interface CardTableHeaderProps { + /** Column-template classes, e.g. `grid-cols-5`. */ + className?: string; + children: ReactNode; +} + +export const CardTableHeader = ({ className, children }: Readonly) => ( +
+ {children} +
+); + +interface CardTableRowProps { + href: string; + /** Column-template classes, e.g. `grid-cols-5`. Must match the header. */ + className?: string; + actions?: ReactNode; + children: ReactNode; +} + +export const CardTableRow = ({ href, className, actions, children }: Readonly) => ( +
+ + {children} + + {actions ?
{actions}
: null} +
+); diff --git a/apps/web/modules/ui/components/editor/components/editor.tsx b/apps/web/modules/ui/components/editor/components/editor.tsx index 604dd4c9d4d3..2ebb97de090c 100644 --- a/apps/web/modules/ui/components/editor/components/editor.tsx +++ b/apps/web/modules/ui/components/editor/components/editor.tsx @@ -4,6 +4,7 @@ import { ListItemNode, ListNode } from "@lexical/list"; import { LINK, TRANSFORMERS } from "@lexical/markdown"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; @@ -12,7 +13,7 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; -import { type Dispatch, type SetStateAction, useRef, useState } from "react"; +import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react"; import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types"; import { cn } from "@/lib/cn"; import { FallbackInput } from "@/modules/survey/components/element-form-input/components/fallback-input"; @@ -51,6 +52,12 @@ export type TextEditorProps = { editable?: boolean; onEmptyChange?: (isEmpty: boolean) => void; isInvalid?: boolean; + /** + * Id of the element describing an invalid state (an inline error message). Forwarded to the + * contenteditable alongside `aria-invalid`, so the editor announces its error like a native input + * does — `isInvalid` alone only paints the border red. + */ + ariaDescribedBy?: string; localSurvey?: TSurvey; elementId?: string; selectedLanguageCode?: string; @@ -83,6 +90,21 @@ const editorConfig = { ], }; +// LexicalComposer only reads `initialConfig.editable` at mount; without this sync a mounted +// editor keeps its initial mode when the `editable` prop flips (e.g. a workflow being enabled +// while its email body is open in the inspector). +const SyncEditablePlugin = ({ editable }: Readonly<{ editable: boolean }>) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (editor.isEditable() !== editable) { + editor.setEditable(editable); + } + }, [editor, editable]); + + return null; +}; + export const Editor = (props: TextEditorProps) => { const editable = props.editable ?? true; const editorContainerRef = useRef(null); @@ -97,6 +119,7 @@ export const Editor = (props: TextEditorProps) => { <>
+
@@ -128,11 +151,19 @@ export const Editor = (props: TextEditorProps) => { style={{ height: props.height }} className="editor-input" aria-labelledby={props.id} + aria-invalid={props.isInvalid ? true : undefined} + aria-describedby={props.ariaDescribedBy} dir="auto" /> } placeholder={ -
+ // Overlay the (empty-state-only) placeholder on the contentEditable instead of + // flowing after it — a static sibling would render below the input, which shows + // once the input's min-height exceeds one line. pointer-events-none keeps clicks + // on the placeholder text focusing the editor. Padding/leading mirror .editor-input. +
{props.placeholder ?? ""}
} diff --git a/apps/web/modules/ui/components/editor/styles-editor.css b/apps/web/modules/ui/components/editor/styles-editor.css index a0d430d0403a..54b48632f551 100644 --- a/apps/web/modules/ui/components/editor/styles-editor.css +++ b/apps/web/modules/ui/components/editor/styles-editor.css @@ -36,6 +36,10 @@ .editor-input { height: auto; + /* Fill the wrapper's min-height so a click anywhere in the visible box focuses the editor — + with height:auto alone, the area below the last line belongs to .editor-inner and clicks + there land outside the contentEditable. */ + min-height: var(--editor-min-height, 48px); font-size: 14px; position: relative; tab-size: 1; diff --git a/apps/web/modules/ui/components/page-header/index.tsx b/apps/web/modules/ui/components/page-header/index.tsx index 98dcfb2742d5..4b90e05dd0b1 100644 --- a/apps/web/modules/ui/components/page-header/index.tsx +++ b/apps/web/modules/ui/components/page-header/index.tsx @@ -1,7 +1,7 @@ import { cn } from "@/lib/cn"; export interface PageHeaderProps { - pageTitle: string; + pageTitle: React.ReactNode; cta?: React.ReactNode; children?: React.ReactNode; } diff --git a/apps/web/modules/ui/components/textarea/index.tsx b/apps/web/modules/ui/components/textarea/index.tsx new file mode 100644 index 000000000000..96703317a733 --- /dev/null +++ b/apps/web/modules/ui/components/textarea/index.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { cn } from "@/lib/cn"; + +export interface TextareaProps extends React.ComponentProps<"textarea"> { + isInvalid?: boolean; +} + +// Mirrors the Input component's border/focus treatment so single-line and multi-line fields look +// consistent. Defaults to a non-resizable min-height box; override via `className` when needed. +export const Textarea = ({ className, isInvalid, ref, ...props }: TextareaProps) => { + return ( +