Compare commits

...

36 Commits

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

    fix to stop running pnpm install when dev server stops

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

    docs(workbench): emphasize token-efficient context gathering

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

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

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

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

    chore: improve workflow dir structure and add skills

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

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

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

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

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

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

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

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

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

    feat(workflows): implement initial PoC of the MVP

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

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

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

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

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

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

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

    chore: add workbench blueprint, cowork, and guidelines scaffolding
2026-05-22 20:45:12 +02:00
Bhagya Amarasinghe be5beaeed7 fix: harden Helm release secret lookups (#8118) 2026-05-22 11:54:20 +00:00
Dhruwang Jariwala bc56f99fd8 feat: cascade delete Hub feedback records on org deletion (ENG-973) (#8055)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:51:00 +00:00
Harsh Bhat 0f38627627 docs: restructure into Platform, Surveys, and Unify Feedback tabs (#8114)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-22 09:09:03 +00:00
Bhagya Amarasinghe a878bdff42 fix: limit JSON request body size (#8051)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-22 08:09:45 +00:00
Bhagya Amarasinghe d757e12c76 fix: return 404 when response is deleted mid-update (#8049) 2026-05-22 07:58:35 +00:00
Bhagya Amarasinghe 629febb2f7 fix: order Helm Hub migrations after Prisma (#8104) 2026-05-22 06:31:11 +00:00
Bhagya Amarasinghe 40b93cc834 fix: use Valkey for bundled Helm Redis (#8092) 2026-05-22 05:56:57 +00:00
Anshuman Pandey f41d2c14f1 fix: pin DNS and block redirects on webhook delivery in the response pipeline (#8095)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 04:46:20 +00:00
Matti Nannt af51414b03 fix: remove isAIDataAnalysisEnabled (ENG-1039) (#8109)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:54:36 +00:00
Matti Nannt a9e39dd4ab fix: validate displayId ownership on response creation (ENG-825) (#8046)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:11:19 +00:00
Johannes c8b0bb2225 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994) (#8101)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 11:41:23 +00:00
Dhruwang Jariwala f6aa27ba8c fix: chart date range type switch + presets include today (ENG-1034, ENG-1035) (#8096)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-21 11:05:10 +00:00
Johannes 82765f7dd7 fix: allow enterprise oauth display names (#8099)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:59:35 +00:00
Dhruwang Jariwala d5bbafcf90 fix: remount AI translation editor on value change, not disabled transition (#8084) 2026-05-21 10:09:57 +00:00
Anshuman Pandey db87a588b5 fix: adds close button on response error screen (#8093) 2026-05-21 09:26:47 +00:00
Javi Aguilar c834587c8d chore: add typecheck command and fix format and type issues (#7999) 2026-05-21 08:13:46 +00:00
Anshuman Pandey ef18aacfa2 fix: fixes responseId client api issue with legacy environmentId (#8079) 2026-05-21 06:15:27 +00:00
Dhruwang Jariwala 025a766c57 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:33 +00:00
Bhagya Amarasinghe f476db3128 fix: update Helm chart default image tag (#8072) 2026-05-21 05:11:20 +00:00
Bhagya Amarasinghe 37023275ca fix: require Cube API secret in compose (#8071) 2026-05-21 05:07:57 +00:00
Bhagya Amarasinghe 9266f64588 fix: harden Helm env value rendering (#8070) 2026-05-21 05:01:10 +00:00
Dhruwang Jariwala 032066194b fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:58:39 +00:00
Dhruwang Jariwala 0bef023302 fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:53:42 +00:00
Dhruwang Jariwala aa83ee336c fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:51:47 +00:00
Anshuman Pandey 4357f497a1 fix: sanitize CSV/XLSX exports against formula injection (#8045) 2026-05-21 04:49:50 +00:00
Bhagya Amarasinghe 526c17af23 fix: wire Cube API secret into Helm defaults (#8068) 2026-05-21 04:47:15 +00:00
Matti Nannt a0ddadebad fix: scope display contact lookup to workspace (ENG-818) (#8048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 04:41:48 +00:00
Bhagya Amarasinghe bc0d04f5e8 fix: staging AI chart Cube schema (#8057) 2026-05-20 14:22:23 +00:00
Anshuman Pandey f0967c2e23 fix: preserve legacy SDK shape with placeholder segment data (#8067) 2026-05-20 16:21:13 +02:00
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
Javi Aguilar 4b1b377cf7 fix: improve blocked state explanations across UI (#8038)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-19 05:41:17 +00:00
dependabot[bot] 4463d5731d chore(deps): bump the npm_and_yarn group across 2 directories with 6 updates (#8027)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:54:36 +00:00
522 changed files with 26784 additions and 7321 deletions
+1
View File
@@ -0,0 +1 @@
../skills
+1
View File
@@ -0,0 +1 @@
../skills
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
+1
View File
@@ -0,0 +1 @@
../skills
+87 -5
View File
@@ -31,14 +31,14 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Get the latest release tag from GitHub API with error handling
echo "Fetching latest release from GitHub API..."
# Use curl with error handling - API returns 404 if no releases exist
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
if [[ "$http_code" == "404" ]]; then
echo "⚠️ No previous releases found (404). This appears to be the first release."
echo "latest_release=" >> $GITHUB_OUTPUT
@@ -55,7 +55,7 @@ jobs:
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
fi
echo "Current release tag: ${{ github.event.release.tag_name }}"
- name: Compare release tags
@@ -65,7 +65,7 @@ jobs:
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
run: |
set -euo pipefail
# Handle first release case (no previous releases)
if [[ -z "${LATEST_TAG}" ]]; then
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
@@ -156,6 +156,87 @@ jobs:
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
update-helm-app-version:
name: Create Helm app version update
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- helm-chart-release
if: ${{ !github.event.release.prerelease }}
permissions:
contents: write
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Prepare Helm app version update
id: update
env:
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
echo "Helm chart appVersion already matches ${VERSION}"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Helm app version PR
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
run: |
set -euo pipefail
branch="chore/update-helm-app-version-${VERSION}"
title="chore: update Helm app version to ${VERSION}"
body_file="$(mktemp)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
git commit -m "$title"
git push --force-with-lease origin "$branch"
cat > "$body_file" <<EOF
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
Release candidates and pre-releases do not create this source update.
EOF
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
else
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
fi
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
@@ -165,6 +246,7 @@ jobs:
- docker-build-cloud
- helm-chart-release
- move-stable-tag
- update-helm-app-version
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
+20 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
version: v3.15.4
- name: Log in to GitHub Container Registry
env:
@@ -70,6 +70,25 @@ jobs:
echo "✅ Successfully updated Chart.yaml"
- name: Validate default Formbricks image tag
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
rendered="$(helm template qa charts/formbricks \
--set formbricks.webappUrl=https://qa.example.com \
--show-only templates/deployment.yaml \
--show-only templates/migration-job.yaml)"
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
if [[ "$image_count" -ne 2 ]]; then
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
exit 1
fi
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
+52
View File
@@ -99,6 +99,58 @@ Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs coloc
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
## Workbench Workflow
The project workbench lives at `workbench/`.
Start substantial tasks with targeted workbench context, not a full-doc sweep. Use `rg`, indexes, headings, and linked records to find the smallest relevant set, then open only the sections needed for the task:
1. `workbench/blueprint/PRODUCT.md`
2. `workbench/blueprint/EPICS.md`
3. `workbench/blueprint/business-rules/`
4. `workbench/blueprint/decisions/`
5. `workbench/cowork/COORDINATOR.md`
6. `workbench/blueprint/MILESTONES.md`
7. The relevant plan, checkpoint, bug-fix, setup, env, security, check, or manual QA file.
`workbench/GUIDE.md` is the workflow source of truth. Keep `AGENTS.md` concise and route detailed workflow rules there.
### Workbench Token Budget
Prefer narrow input and compact output. Do not read entire workbench directories or paste long excerpts unless the task requires it. Summarize findings, link files, and report only changed records plus validation results. For routine implementation, one concise checkpoint is enough after an end-to-end pass.
### Blueprint vs Cowork
- `workbench/blueprint/` contains durable product and application truth: product definition, epics, milestones, business rules, decisions, design, security, checks, manual QA, and env var expectations.
- `workbench/cowork/` contains active execution records: plans, checkpoints, bug fixes, prompts, and coordination.
- `workbench/scratch/` and `workbench/local/` are ignored local spaces. Do not rely on their contents for durable project state.
### Blueprint Human Ownership
AI agents may help improve wording, structure, consistency, and traceability for `workbench/blueprint/` documents. The underlying concepts, original prompts or drafts, and final review must come from humans. Treat blueprint records as human-owned product truth: do not invent product direction, business rules, milestones, decisions, security posture, env expectations, checks, manual QA, or design guidance without explicit human input and review.
### Workbench Review Gates
Do not start planning or implementation from a milestone, plan, or bug-fix record unless it has a completed human review line such as `- [ ] Reviewed and refined by: Javier`. If the line is missing or still says `TBD`, stop and ask for human review. Keep checkpoints proportional: one checkpoint is enough when a plan is implemented end to end in one pass.
### Workbench Validation
After editing `workbench/`, `skills/`, or this workflow section in `AGENTS.md`, run `node workbench/scripts/validate-workbench.mjs workbench` and report any failures or relevant warnings. Before implementing from workbench records, run the validator when the workbench structure, links, or review gates may have changed since the plan was reviewed.
### Documentation Sync
When code changes alter product behavior, business rules, architecture decisions, env vars, setup, security posture, automated checks, or manual QA expectations, update the relevant workbench files in the same change. Avoid process churn: do not create or rewrite workbench docs for purely mechanical implementation details, formatting, or phase-by-phase narration when one concise checkpoint covers an end-to-end implementation.
Use checkpoints for completed plan phases. Use bug-fix records for scoped defects. Use decision records for durable tradeoffs. Use business-rule records for current domain behavior.
## Git Discipline
- Do not create commits unless explicitly asked.
- Preserve staged and unstaged work exactly.
- Do not stage, unstage, reset, or discard files unless explicitly asked.
- Before editing files in an existing repo, inspect `git status --short` and check relevant staged and unstaged diffs.
- Treat staged content as human-selected work in progress.
## Commit & Pull Request Guidelines
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { App } from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -13,11 +13,13 @@ import {
MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlayCircleIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -187,6 +189,41 @@ export const MainNavigation = ({
},
],
},
{
id: "workflows",
name: (
<span className="inline-flex items-center gap-2">
<span>{t("common.workflows")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="text-[10px] font-semibold normal-case tracking-normal"
/>
</span>
),
items: [
{
name: t("common.workflows"),
href: `/workspaces/${workspace.id}/workflows`,
icon: WorkflowIcon,
isActive:
pathname?.startsWith(`/workspaces/${workspace.id}/workflows`) && !pathname?.includes("/runs"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
name: t("workspace.workflows.workflow_runs"),
href: `/workspaces/${workspace.id}/workflows/runs`,
icon: PlayCircleIcon,
isActive:
pathname?.startsWith(`/workspaces/${workspace.id}/workflows/runs`) ||
(pathname?.startsWith(`/workspaces/${workspace.id}/workflows/`) && pathname?.includes("/runs")),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
@@ -194,7 +231,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
href: `/workspaces/${workspace.id}/settings/workspace/general`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -467,7 +504,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton />
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
{/* Settings sidebar content */}
@@ -335,6 +335,7 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -373,12 +374,14 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -1,4 +1,13 @@
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (
props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>
) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
};
@@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
@@ -1,3 +1,18 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export default PricingPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -12,8 +13,9 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -66,11 +66,6 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -1,9 +1,10 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -11,15 +12,19 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -1 +1,11 @@
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
@@ -57,7 +57,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
@@ -66,7 +65,6 @@ describe("organization AI settings actions", () => {
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
@@ -114,18 +112,15 @@ describe("organization AI settings actions", () => {
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
@@ -194,7 +189,6 @@ describe("organization AI settings actions", () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
@@ -71,12 +71,11 @@ export const updateOrganizationNameAction = authenticatedActionClient
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
"isAISmartToolsEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
@@ -90,16 +89,10 @@ const resolveOrganizationAISettings = ({
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
};
};
@@ -50,29 +50,18 @@ export const AISettingsToggle = ({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
const handleToggle = async (checked: boolean) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
setLoadingField("isAISmartToolsEnabled");
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
data: { isAISmartToolsEnabled: checked },
});
if (response?.data) {
@@ -122,7 +111,7 @@ export const AISettingsToggle = ({
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
onToggle={handleToggle}
htmlId="ai-smart-tools-toggle"
title={t("workspace.settings.general.ai_smart_tools_enabled")}
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
@@ -130,16 +119,6 @@ export const AISettingsToggle = ({
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("workspace.settings.general.ai_data_analysis_enabled")}
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
@@ -1,3 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -8,7 +9,6 @@ import {
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
@@ -26,8 +26,9 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -36,14 +37,11 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
]);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -4,7 +4,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
export default TeamsPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
throw new InvalidInputError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -11,6 +11,7 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -26,6 +27,7 @@ import {
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
SmilePlusIcon,
StarIcon,
User,
} from "lucide-react";
@@ -103,6 +105,8 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
@@ -0,0 +1,3 @@
import { WorkflowBuilderLoading } from "@/modules/workflows/loading";
export default WorkflowBuilderLoading;
@@ -0,0 +1,13 @@
import { WorkflowBuilderPage } from "@/modules/workflows/pages/workflow-builder-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowPage = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const { workspaceId, workflowId } = await props.params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
return <WorkflowBuilderPage workspaceId={workspaceId} workflowId={workflowId} isReadOnly={isReadOnly} />;
};
export default WorkflowPage;
@@ -0,0 +1,3 @@
import { WorkflowRunDetailLoading } from "@/modules/workflows/loading";
export default WorkflowRunDetailLoading;
@@ -0,0 +1,13 @@
import { WorkflowRunDetailPage } from "@/modules/workflows/pages/workflow-run-detail-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRunDetail = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string; runId: string }> }>
) => {
const { workspaceId, workflowId, runId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkflowRunDetailPage workspaceId={workspaceId} workflowId={workflowId} runId={runId} />;
};
export default WorkflowRunDetail;
@@ -0,0 +1,3 @@
import { WorkflowRunsLoading } from "@/modules/workflows/loading";
export default WorkflowRunsLoading;
@@ -0,0 +1,13 @@
import { WorkflowRunsPage } from "@/modules/workflows/pages/workflow-runs-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRuns = async (
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
) => {
const { workspaceId, workflowId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkflowRunsPage workspaceId={workspaceId} workflowId={workflowId} />;
};
export default WorkflowRuns;
@@ -0,0 +1,3 @@
import { WorkflowsListLoading } from "@/modules/workflows/loading";
export default WorkflowsListLoading;
@@ -0,0 +1,11 @@
import { WorkflowsListPage } from "@/modules/workflows/pages/workflows-list-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
return <WorkflowsListPage workspaceId={workspaceId} isReadOnly={isReadOnly} />;
};
export default WorkflowsPage;
@@ -0,0 +1,3 @@
import { WorkspaceWorkflowRunsLoading } from "@/modules/workflows/loading";
export default WorkspaceWorkflowRunsLoading;
@@ -0,0 +1,11 @@
import { WorkspaceWorkflowRunsPage } from "@/modules/workflows/pages/workspace-workflow-runs-page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const WorkflowRuns = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
await getWorkspaceAuth(workspaceId);
return <WorkspaceWorkflowRunsPage workspaceId={workspaceId} />;
};
export default WorkflowRuns;
@@ -1,10 +1,11 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
@@ -34,7 +34,7 @@ export const GET = async (req: Request) => {
return responses.unauthorizedResponse();
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
@@ -102,7 +102,7 @@ export const GET = async (req: Request) => {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(`${WEBAPP_URL}/${basePath}/integrations/google-sheets`);
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
+11 -2
View File
@@ -313,9 +313,18 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
test("returns 404 notFound for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
});
test("returns 500 internalServerError for unknown errors", async () => {
+4 -5
View File
@@ -29,11 +29,10 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -1,6 +1,7 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -32,7 +33,25 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
const jsonInput = await req.json();
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
workspaceId,
@@ -103,6 +103,7 @@ describe("getWorkspaceStateData", () => {
id: workspaceId,
appSetupCompleted: true,
workspaceSettings: {
id: workspaceId,
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -111,7 +112,14 @@ describe("getWorkspaceStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
surveys: mockWorkspaceData.surveys,
// `survey.name` is replaced with a back-compat placeholder; segment was
// null in the mock so the sanitized segment stays null.
surveys: [
{
...mockWorkspaceData.surveys[0],
name: "[deprecated] survey name omitted from public API - will be removed soon",
},
],
actionClasses: mockWorkspaceData.actionClasses,
});
@@ -211,6 +219,7 @@ describe("getWorkspaceStateData", () => {
const result = await getWorkspaceStateData(workspaceId);
expect(result.workspace.workspaceSettings).toEqual({
id: workspaceId,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
@@ -42,6 +42,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
where: { id: workspaceId },
select: {
id: true,
legacyEnvironmentId: true,
appSetupCompleted: true,
recontactDays: true,
clickOutsideClose: true,
@@ -72,7 +73,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
select: {
id: true,
welcomeCard: true,
// name intentionally omitted — internal label not needed by the SDK
// `name` deliberately not selected — internal label not needed by the
// SDK and replaced with a fixed placeholder below so older SDKs that
// decoded `Survey.name` as a required field keep working.
questions: true,
blocks: true,
variables: true,
@@ -99,9 +102,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
// Only need to know if any filters exist so we can compute
// `hasFilters`. Real filter values, segment title/description, and
// surveys-list relation are never exposed to clients.
segment: {
select: {
id: true,
@@ -135,17 +138,46 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
throw new ResourceNotFoundError("workspace", workspaceId);
}
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
// Backwards-compat response shape for SDKs from before PR #7931. Those
// clients decoded `survey.name` and the full `segment` object as required
// fields, so the response must still carry that shape — but every field
// that could leak sensitive targeting data is replaced with a placeholder.
// The actual segment-membership check happens server-side (segment IDs in
// POST /user); SDKs only inspect `filters.length` / `hasFilters` locally.
//
// `environmentId` mirrors `legacyEnvironmentId ?? workspace.id`, matching
// the `/me` endpoints' pattern so migrated workspaces keep returning the
// original env ID older clients persisted.
const legacyOrCurrentId = workspaceData.legacyEnvironmentId ?? workspaceData.id;
const placeholderDate = new Date(0);
const placeholderFilter = {
id: "placeholder",
connector: null,
resource: {
id: "placeholder",
root: { type: "device", deviceType: "phone" },
value: "deprecated",
qualifier: { operator: "equals" },
},
};
const transformedSurveys = workspaceData.surveys.map((survey) => {
const minimalSegment = survey.segment
const realHasFilters =
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
const sanitizedSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
title: "[deprecated] segment title omitted from public API - will be removed soon",
description: null,
isPrivate: true,
filters: realHasFilters ? [placeholderFilter] : [],
environmentId: legacyOrCurrentId,
workspaceId: legacyOrCurrentId,
createdAt: placeholderDate,
updatedAt: placeholderDate,
surveys: [],
hasFilters: realHasFilters,
}
: null;
@@ -155,7 +187,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
segment: null,
});
return { ...transformed, segment: minimalSegment };
return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
segment: sanitizedSegment,
};
});
return {
@@ -163,6 +199,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
id: workspaceData.id,
appSetupCompleted: workspaceData.appSetupCompleted,
workspaceSettings: {
id: workspaceData.id,
recontactDays: workspaceData.recontactDays,
clickOutsideClose: workspaceData.clickOutsideClose,
overlay: workspaceData.overlay,
@@ -171,7 +208,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: resolveStorageUrlsInObject(workspaceData.styling),
},
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
// The runtime shape carries extra back-compat fields (placeholder
// segment, `hasFilters`, mirrored `environmentId`) that aren't part of
// the modern `TJsWorkspaceStateSurvey`. Cast through unknown — this is
// intentional and only this endpoint's response widens the type.
surveys: resolveStorageUrlsInObject(transformedSurveys) as unknown as TJsWorkspaceStateSurvey[],
actionClasses: workspaceData.actionClasses,
};
} catch (error) {
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
@@ -34,6 +35,10 @@ vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
validateResponseData: mocks.validateResponseData,
@@ -123,6 +128,7 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
@@ -239,6 +245,34 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
@@ -8,6 +8,7 @@ import { THandlerParams } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -209,7 +210,7 @@ export const putResponseHandler = async ({
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { workspaceId, responseId } = params;
const { workspaceId: workspaceIdParam, responseId } = params;
if (!responseId) {
return {
@@ -217,6 +218,14 @@ export const putResponseHandler = async ({
};
}
const resolved = await resolveClientApiIds(workspaceIdParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", workspaceIdParam, true),
};
}
const { workspaceId } = resolved;
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
if ("response" in validatedUpdateInput) {
return validatedUpdateInput;
@@ -1,7 +1,12 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganization } from "@/lib/organization/service";
@@ -155,6 +160,16 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -11,6 +16,7 @@ import {
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -104,7 +110,21 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contact?.id ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaClient = tx ?? prisma;
@@ -127,6 +147,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -6,6 +6,7 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -56,11 +57,17 @@ export const POST = withV1ApiWrapper({
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await req.json();
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
"Malformed JSON input, please check your request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
@@ -211,7 +218,7 @@ export const POST = withV1ApiWrapper({
response: responseData,
});
if (responseInput.finished) {
if (responseInputData.finished) {
await sendToPipeline({
event: "responseFinished",
workspaceId,
@@ -51,7 +51,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
@@ -40,7 +40,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -37,7 +37,7 @@ export const GET = withV1ApiWrapper({
};
}
const basePath = `/workspaces/${workspaceId}`;
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
if (code && typeof code !== "string") {
return {
@@ -3,6 +3,7 @@ import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classe
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -84,8 +85,14 @@ export const PUT = withV1ApiWrapper({
let actionClassUpdate;
try {
actionClassUpdate = await req.json();
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -45,8 +46,14 @@ export const POST = withV1ApiWrapper({
try {
let actionClassInput;
try {
actionClassInput = await req.json();
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,6 +1,7 @@
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { TResponseData, ZResponseUpdateInput } from "@formbricks/types/responses";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -12,6 +13,11 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
type TUncheckedResponseUpdate = Record<string, unknown> & {
data: TResponseData;
language?: string;
};
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiV1Authentication | undefined,
@@ -120,10 +126,16 @@ export const PUT = withV1ApiWrapper({
auditLog.oldObject = result.response;
}
let responseUpdate;
let responseUpdate: TUncheckedResponseUpdate;
try {
responseUpdate = await req.json();
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -91,8 +92,14 @@ export const POST = withV1ApiWrapper({
try {
let jsonInput;
try {
jsonInput = await req.json();
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,6 +2,7 @@ import { logger } from "@formbricks/logger";
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -19,8 +20,14 @@ export const POST = withV1ApiWrapper({
let storageInput;
try {
storageInput = await req.json();
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -9,6 +9,7 @@ import {
addLegacyProjectOverwrites,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -22,6 +23,12 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
type TSurveyUpdateBody = Record<string, unknown> & {
blocks?: Parameters<typeof validateSurveyInput>[0]["blocks"];
endings?: Parameters<typeof transformQuestionsToBlocks>[1];
questions?: Parameters<typeof transformQuestionsToBlocks>[0];
};
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
@@ -164,10 +171,16 @@ export const PUT = withV1ApiWrapper({
};
}
let surveyUpdate;
let surveyUpdate: TSurveyUpdateBody;
try {
surveyUpdate = await req.json();
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -188,7 +201,7 @@ export const PUT = withV1ApiWrapper({
if (hasQuestions) {
surveyUpdate.blocks = transformQuestionsToBlocks(
surveyUpdate.questions,
surveyUpdate.questions ?? [],
surveyUpdate.endings || result.survey.endings
);
surveyUpdate.questions = [];
@@ -208,7 +221,11 @@ export const PUT = withV1ApiWrapper({
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
const featureCheckResult = await checkFeaturePermissions(
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
organization,
result.survey
);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -51,7 +51,6 @@ const mockOrganization: TOrganization = {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithWorkspaceId["followUps"][number] = {
@@ -8,6 +8,7 @@ import {
addLegacyProjectOverwritesToList,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -84,8 +85,14 @@ export const POST = withV1ApiWrapper({
try {
let surveyInput;
try {
surveyInput = await req.json();
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
+9 -2
View File
@@ -2,6 +2,7 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -40,8 +41,14 @@ export const POST = withV1ApiWrapper({
let webhookInput;
try {
webhookInput = await req.json();
} catch {
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
@@ -21,24 +21,25 @@ vi.mock("react", async () => {
});
const contactId = "test-contact-id";
const workspaceId = "test-workspace-id";
describe("doesContactExist", () => {
describe("doesContactExistInWorkspace", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return true if contact exists", async () => {
test("should return true if contact exists in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -46,11 +47,11 @@ describe("doesContactExist", () => {
test("should return false if contact does not exist in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId);
const result = await doesContactExistInWorkspace(contactId, workspaceId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
where: { id: contactId, workspaceId },
select: { id: true },
});
});
@@ -1,15 +1,18 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
},
select: {
id: true,
},
});
export const doesContactExistInWorkspace = reactCache(
async (id: string, workspaceId: string): Promise<boolean> => {
const contact = await prisma.contact.findFirst({
where: {
id,
workspaceId,
},
select: {
id: true,
},
});
return !!contact;
});
return !!contact;
}
);
@@ -9,7 +9,7 @@ import {
} from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
@@ -30,7 +30,7 @@ vi.mock("@formbricks/database", () => ({
}));
vi.mock("./contact", () => ({
doesContactExist: vi.fn(),
doesContactExistInWorkspace: vi.fn(),
}));
const workspaceId = "workspace-id-mock";
@@ -81,13 +81,13 @@ describe("createDisplay", () => {
});
test("should create a display with contactId successfully", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -104,7 +104,7 @@ describe("createDisplay", () => {
const result = await createDisplay(displayInputWithoutContact);
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -115,13 +115,13 @@ describe("createDisplay", () => {
});
test("should create a display without contact if contact does not exist in the workspace", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
@@ -139,16 +139,16 @@ describe("createDisplay", () => {
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(doesContactExist).not.toHaveBeenCalled();
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, workspaceId },
});
@@ -158,7 +158,7 @@ describe("createDisplay", () => {
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
@@ -171,7 +171,7 @@ describe("createDisplay", () => {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
@@ -179,15 +179,15 @@ describe("createDisplay", () => {
test("should throw original error on other errors during creation", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
});
test("should throw original error if doesContactExist fails", async () => {
test("should throw original error if doesContactExistInWorkspace fails", async () => {
const contactCheckError = new Error("Failed to check contact");
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
vi.mocked(doesContactExistInWorkspace).mockRejectedValue(contactCheckError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
expect(prisma.display.create).not.toHaveBeenCalled();
@@ -6,7 +6,7 @@ import {
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[workspaceId]/displays/types/display";
import { validateInputs } from "@/lib/utils/validate";
import { doesContactExist } from "./contact";
import { doesContactExistInWorkspace } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
const { contactId, surveyId, workspaceId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
const contactExists = contactId ? await doesContactExistInWorkspace(contactId, workspaceId) : false;
const survey = await prisma.survey.findUnique({
where: {
@@ -2,7 +2,12 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -190,7 +195,19 @@ describe("createResponse V2", () => {
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["someOtherField"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
@@ -199,7 +216,7 @@ describe("createResponse V2", () => {
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
).rejects.toThrow(InvalidInputError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -12,6 +17,7 @@ import {
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -49,18 +55,7 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
return {
survey: {
@@ -84,8 +79,6 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
@@ -112,6 +105,16 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contactId ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -135,6 +138,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
@@ -414,6 +415,44 @@ describe("withV3ApiWrapper", () => {
]);
});
test("returns 413 problem response for oversized JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{}",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
"x-request-id": "req-payload-too-large",
},
}),
{} as never
);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
code: "payload_too_large",
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
requestId: "req-payload-too-large",
status: 413,
title: "Payload Too Large",
})
);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
+11 -2
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -16,6 +17,7 @@ import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemPayloadTooLarge,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
@@ -170,8 +172,15 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
bodyData = await parseJsonBodyWithLimit(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
ok: false,
response: problemPayloadTooLarge(requestId, error.message, instance),
};
}
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
+11
View File
@@ -71,6 +71,17 @@ export function problemBadRequest(
});
}
export function problemPayloadTooLarge(
requestId: string,
detail: string = "Payload Too Large",
instance?: string
): Response {
return problemResponse(413, "Payload Too Large", detail, requestId, {
code: "payload_too_large",
instance,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
@@ -0,0 +1,59 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { disableWorkflow, getWorkflow } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await disableWorkflow(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow disable unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,59 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { enableWorkflow, getWorkflow } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await enableWorkflow(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (error instanceof z.ZodError || error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow enable unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,157 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowDefinition } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
deleteWorkflow,
getWorkflow,
getWorkflowByWorkspace,
updateWorkflow,
} from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
const ZUpdateWorkflowBody = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(500).nullable().optional(),
definition: ZWorkflowDefinition.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow detail unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const PATCH = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
body: ZUpdateWorkflowBody,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await updateWorkflow({
workflowId,
workspaceId: authResult.workspaceId,
name: parsedInput.body.name,
description: parsedInput.body.description,
definition: parsedInput.body.definition,
});
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflow(workflow), { requestId });
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return problemBadRequest(requestId, "A workflow with this name already exists", { instance });
}
if (error instanceof z.ZodError || error instanceof Error) {
return problemBadRequest(requestId, error.message, { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow update unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const existingWorkflow = await getWorkflow(workflowId);
if (!existingWorkflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
existingWorkflow.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await getWorkflowByWorkspace(workflowId, authResult.workspaceId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const deletedWorkflow = await deleteWorkflow(workflowId, authResult.workspaceId);
return successResponse(deletedWorkflow, { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,51 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getWorkflow, getWorkflowRun } from "@/modules/workflows/lib/service";
import { serializeWorkflowRun } from "../../../serializers";
const ZWorkflowRunParams = z.object({
workflowId: z.cuid2(),
runId: z.cuid2(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowRunParams,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const { workflowId, runId } = parsedInput.params;
const log = logger.withContext({ requestId, workflowId, runId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const run = await getWorkflowRun(workflowId, authResult.workspaceId, runId);
if (!run) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return successResponse(serializeWorkflowRun(run), { requestId });
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow run detail unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,64 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowRunStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successListResponse } from "@/app/api/v3/lib/response";
import { getWorkflow, listWorkflowRuns } from "@/modules/workflows/lib/service";
import { serializeWorkflowRun } from "../../serializers";
const ZWorkflowParams = z.object({
workflowId: z.cuid2(),
});
const ZWorkflowRunsQuery = z.object({
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowRunStatus.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: ZWorkflowParams,
query: ZWorkflowRunsQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const workflowId = parsedInput.params.workflowId;
const log = logger.withContext({ requestId, workflowId });
try {
const workflow = await getWorkflow(workflowId);
if (!workflow) {
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
workflow.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkflowRuns({
workflowId,
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.runs.map(serializeWorkflowRun), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflow runs list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+222
View File
@@ -0,0 +1,222 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { createDefaultWorkflowDefinition } from "@/modules/workflows/lib/default-workflow";
import { createWorkflow, listWorkflows, listWorkspaceWorkflowRuns } from "@/modules/workflows/lib/service";
import { GET, POST } from "./route";
import { GET as GET_WORKSPACE_RUNS } from "./runs/route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/workflows/lib/service", () => ({
createWorkflow: vi.fn(),
listWorkflows: vi.fn(),
listWorkspaceWorkflowRuns: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const workspaceId = "cm8cmpnjj000108jfdr9dfqe8";
const workflowId = "cm8cmpnjj000108jfdr9dfqe7";
function createRequest(url: string, init?: RequestInit): NextRequest {
return new NextRequest(url, init);
}
describe("/api/v3/workflows", () => {
beforeEach(() => {
vi.clearAllMocks();
getServerSession.mockResolvedValue({
expires: "2026-01-01",
user: { email: "u@example.com", id: "user_1", name: "User" },
} as never);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
organizationId: "org_1",
workspaceId,
});
vi.mocked(listWorkflows).mockResolvedValue({ nextCursor: null, workflows: [] } as never);
});
test("lists workflows through workspace-scoped v3 access", async () => {
const req = createRequest(`http://localhost/api/v3/workflows?workspaceId=${workspaceId}&limit=10`);
const res = await GET(req, {} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"read",
expect.any(String),
"/api/v3/workflows"
);
expect(listWorkflows).toHaveBeenCalledWith({
cursor: undefined,
limit: 10,
status: undefined,
workspaceId,
});
});
test("creates draft workflows through the v3 API", async () => {
const definition = createDefaultWorkflowDefinition();
vi.mocked(createWorkflow).mockResolvedValue({
createdAt: new Date("2026-04-07T10:00:00.000Z"),
createdBy: "user_1",
description: "Notify the team when a response matches the PoC branch.",
definition,
id: workflowId,
name: "Response completed workflow",
status: "draft",
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workspaceId,
} as never);
const req = createRequest("http://localhost/api/v3/workflows", {
body: JSON.stringify({
description: "Notify the team when a response matches the PoC branch.",
definition,
name: "Response completed workflow",
workspaceId,
}),
method: "POST",
});
const res = await POST(req, {} as never);
const body = await res.json();
expect(res.status).toBe(201);
expect(body.data.id).toBe(workflowId);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
expect.any(String),
"/api/v3/workflows"
);
expect(createWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
description: "Notify the team when a response matches the PoC branch.",
definition,
name: "Response completed workflow",
workspaceId,
})
);
});
test("lists workspace workflow runs through workspace-scoped v3 access", async () => {
vi.mocked(listWorkspaceWorkflowRuns).mockResolvedValue({
nextCursor: null,
runs: [
{
createdAt: new Date("2026-04-07T10:00:00.000Z"),
data: { steps: [], logs: [] },
error: null,
finishedAt: null,
id: "cm8cmpnjj000108jfdr9dfqe6",
responseId: "cm8cmpnjj000108jfdr9dfqe5",
startedAt: null,
status: "queued",
surveyId: "cm8cmpnjj000108jfdr9dfqe4",
triggerEvent: "response.completed",
triggerPayload: {},
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workflow: {
createdAt: new Date("2026-04-07T10:00:00.000Z"),
createdBy: "user_1",
definition: createDefaultWorkflowDefinition(),
description: null,
id: workflowId,
name: "Response completed workflow",
status: "enabled",
updatedAt: new Date("2026-04-07T10:00:00.000Z"),
workspaceId,
},
workflowId,
workspaceId,
},
],
} as never);
const req = createRequest(`http://localhost/api/v3/workflows/runs?workspaceId=${workspaceId}&limit=10`);
const res = await GET_WORKSPACE_RUNS(req, {} as never);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data[0].workflow.name).toBe("Response completed workflow");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"read",
expect.any(String),
"/api/v3/workflows/runs"
);
expect(listWorkspaceWorkflowRuns).toHaveBeenCalledWith({
cursor: undefined,
limit: 10,
status: undefined,
workspaceId,
});
});
test("rejects deferred compute actions before service calls", async () => {
const req = createRequest("http://localhost/api/v3/workflows", {
body: JSON.stringify({
definition: {
...createDefaultWorkflowDefinition(),
nodes: [
{
actionType: "compute",
config: {},
id: "compute-1",
type: "action",
},
],
},
name: "Unsupported workflow",
workspaceId,
}),
method: "POST",
});
const res = await POST(req, {} as never);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
expect(createWorkflow).not.toHaveBeenCalled();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowDefinition, ZWorkflowStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemInternalError,
successListResponse,
successResponse,
} from "@/app/api/v3/lib/response";
import { createWorkflow, listWorkflows } from "@/modules/workflows/lib/service";
import { serializeWorkflow } from "./serializers";
const ZWorkflowListQuery = z.object({
workspaceId: z.cuid2(),
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowStatus.optional(),
});
const ZCreateWorkflowBody = z.object({
workspaceId: z.cuid2(),
name: z.string().min(1).max(120),
description: z.string().max(500).nullable().optional(),
status: z.literal("draft").optional(),
definition: ZWorkflowDefinition,
});
const getAuthenticatedUserId = (authentication: unknown): string | undefined => {
if (authentication && typeof authentication === "object" && "user" in authentication) {
const user = (authentication as { user?: { id?: string } }).user;
return user?.id;
}
return undefined;
};
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
query: ZWorkflowListQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.query.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.query.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkflows({
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.workflows.map(serializeWorkflow), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workflows list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZCreateWorkflowBody,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.body.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.body.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const workflow = await createWorkflow({
workspaceId: authResult.workspaceId,
name: parsedInput.body.name,
description: parsedInput.body.description,
definition: parsedInput.body.definition,
createdBy: getAuthenticatedUserId(authentication),
});
return successResponse(serializeWorkflow(workflow), { requestId, status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return problemBadRequest(requestId, "Invalid workflow definition", { instance });
}
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return problemBadRequest(requestId, "A workflow with this name already exists", { instance });
}
log.error({ error, statusCode: 500 }, "V3 workflow create unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,53 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZWorkflowRunStatus } from "@formbricks/types/workflows";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemInternalError, successListResponse } from "@/app/api/v3/lib/response";
import { listWorkspaceWorkflowRuns } from "@/modules/workflows/lib/service";
import { serializeWorkflowRunWithWorkflow } from "../serializers";
const ZWorkspaceWorkflowRunsQuery = z.object({
workspaceId: z.cuid2(),
limit: z.coerce.number().int().positive().max(100).optional(),
cursor: z.cuid2().optional(),
status: ZWorkflowRunStatus.optional(),
});
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
query: ZWorkspaceWorkflowRunsQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId, workspaceId: parsedInput.query.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
parsedInput.query.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const page = await listWorkspaceWorkflowRuns({
workspaceId: authResult.workspaceId,
limit: parsedInput.query.limit,
cursor: parsedInput.query.cursor,
status: parsedInput.query.status,
});
return successListResponse(page.runs.map(serializeWorkflowRunWithWorkflow), {
limit: parsedInput.query.limit ?? 20,
nextCursor: page.nextCursor,
});
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 workspace workflow runs list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,44 @@
import type { Workflow, WorkflowRun } from "@prisma/client";
import { ZWorkflowDefinition, ZWorkflowRunData } from "@formbricks/types/workflows";
const serializeWorkflowRunSummary = (run: WorkflowRun) => ({
id: run.id,
workflowId: run.workflowId,
workspaceId: run.workspaceId,
status: run.status,
triggerEvent: run.triggerEvent,
surveyId: run.surveyId,
responseId: run.responseId,
error: run.error,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
});
export const serializeWorkflow = (workflow: Workflow & { runs?: WorkflowRun[] }) => ({
id: workflow.id,
name: workflow.name,
description: workflow.description,
status: workflow.status,
workspaceId: workflow.workspaceId,
createdBy: workflow.createdBy,
definition: ZWorkflowDefinition.parse(workflow.definition),
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
lastRun: workflow.runs?.[0] ? serializeWorkflowRunSummary(workflow.runs[0]) : null,
});
export const serializeWorkflowRun = (run: WorkflowRun) => ({
...serializeWorkflowRunSummary(run),
triggerPayload: run.triggerPayload,
data: ZWorkflowRunData.parse(run.data),
});
export const serializeWorkflowRunWithWorkflow = (run: WorkflowRun & { workflow: Workflow }) => ({
...serializeWorkflowRun(run),
workflow: {
id: run.workflow.id,
name: run.workflow.name,
},
});
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "./request-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
@@ -39,6 +40,40 @@ describe("parseAndValidateJsonBody", () => {
});
});
test("returns a payload too large response when the request body exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
body: "{}",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("payload_too_large");
expect(result.response.status).toBe(413);
await expect(result.response.json()).resolves.toEqual({
code: "payload_too_large",
message: "Payload Too Large",
details: {
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
@@ -1,8 +1,9 @@
import { z } from "zod";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body" | "payload_too_large";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
@@ -44,10 +45,18 @@ export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
let jsonInput: unknown;
try {
jsonInput = await request.json();
jsonInput = await parseJsonBodyWithLimit(request);
} catch (error) {
const details = { error: getErrorMessage(error) };
if (error instanceof RequestBodyTooLargeError) {
return {
details,
issue: "payload_too_large",
response: responses.payloadTooLargeResponse("Payload Too Large", details, true),
};
}
return {
details,
issue: "invalid_json",
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
RequestBodyTooLargeError,
parseJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./request-body";
const createStreamingRequest = (chunks: string[]): Request =>
new Request("http://localhost/api/test", {
method: "POST",
body: new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
describe("request body parsing", () => {
test("rejects a request when content-length exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
},
body: "{}",
});
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
name: "RequestBodyTooLargeError",
});
});
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
});
test("allows a body exactly at the body limit", async () => {
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
const request = new Request("http://localhost/api/test", {
method: "POST",
body: rawBody,
});
const body = await readRequestBodyWithLimit(request);
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
expect(body).toBe(rawBody);
});
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
body: "{invalid-json",
});
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
});
test("returns an empty string for requests without a body", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
});
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
});
});
+90
View File
@@ -0,0 +1,90 @@
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
export class RequestBodyTooLargeError extends Error {
readonly actualBytes: number | null;
readonly limitBytes: number;
constructor(limitBytes: number, actualBytes: number | null = null) {
super(`Request body must not exceed ${limitBytes} bytes`);
this.name = "RequestBodyTooLargeError";
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}
const textDecoder = new TextDecoder();
const getContentLength = (headers: Headers): number | null => {
const contentLength = headers.get("content-length");
if (!contentLength) {
return null;
}
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength < 0) {
return null;
}
return parsedContentLength;
};
const assertBodySize = (actualBytes: number, limitBytes: number): void => {
if (actualBytes > limitBytes) {
throw new RequestBodyTooLargeError(limitBytes, actualBytes);
}
};
export const readRequestBodyWithLimit = async (
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<string> => {
const contentLength = getContentLength(request.headers);
if (contentLength !== null) {
assertBodySize(contentLength, limitBytes);
}
if (!request.body) {
return "";
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > limitBytes) {
await reader.cancel().catch(() => undefined);
throw new RequestBodyTooLargeError(limitBytes, receivedBytes);
}
chunks.push(value);
}
if (chunks.length === 0) {
return "";
}
if (chunks.length === 1) {
return textDecoder.decode(chunks[0]);
}
const body = new Uint8Array(receivedBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return textDecoder.decode(body);
};
export const parseJsonBodyWithLimit = async <TJson = unknown>(
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<TJson> => JSON.parse(await readRequestBodyWithLimit(request, limitBytes)) as TJson;
+27 -1
View File
@@ -17,7 +17,8 @@ interface ApiErrorResponse {
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict";
| "conflict"
| "payload_too_large";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -80,6 +81,30 @@ const badRequestResponse = (
);
};
const payloadTooLargeResponse = (
message: string = "Payload Too Large",
details: ApiErrorResponse["details"] = {},
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "payload_too_large",
message,
details,
},
{
status: 413,
headers,
}
);
};
const methodNotAllowedResponse = (
res: CustomNextApiResponse,
allowedMethods: string[],
@@ -294,6 +319,7 @@ export const responses = {
unauthorizedResponse,
notFoundResponse,
successResponse,
payloadTooLargeResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
+13 -5
View File
@@ -1602,13 +1602,15 @@ checksums:
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
workspace/analysis/charts/ai_enable_in_settings: 426cb4525381e193e6c4dcce286e60c8
workspace/analysis/charts/ai_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
workspace/analysis/charts/ai_not_available: 173abfcd32dd45edcc258dfdaaed494b
workspace/analysis/charts/ai_not_enabled: 8651fdac58cd311d17a48001a880318d
workspace/analysis/charts/ai_not_in_plan: 60bb0792a1ed98c07d8694029cdfdb43
workspace/analysis/charts/ai_not_enabled: 2066fe71ecf8994ba738c79b63a1934b
workspace/analysis/charts/ai_not_in_plan: 4b75e143c97d657bd91f857ff2bbf33f
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
workspace/analysis/charts/ai_upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
@@ -1857,6 +1859,7 @@ checksums:
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
@@ -1891,6 +1894,7 @@ checksums:
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
@@ -2484,16 +2488,19 @@ checksums:
workspace/settings/feedback_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_directories/no_access: 707627df25fbaa28f18aa0f0d03dcb81
workspace/settings/feedback_directories/no_connectors: ccc725ff9a82a7b8ab68de735490a9b9
workspace/settings/feedback_directories/no_unassigned_workspaces_description: c96a260b582e6c930de72e6e69f9a9a6
workspace/settings/feedback_directories/no_unassigned_workspaces_title: 458d4289d73d799561bec26a0bb1a1a3
workspace/settings/feedback_directories/pause_connectors_confirmation_description: 0e30f827576b931651b9eae44e00279b
workspace/settings/feedback_directories/pause_connectors_confirmation_title: da1950dbb9ce62caa65c87ae8b88b1a1
workspace/settings/feedback_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
workspace/settings/feedback_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
workspace/settings/feedback_directories/title: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
workspace/settings/feedback_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
workspace/settings/feedback_directories/unarchive_workspace_conflict: ed44bc0bd570b40de5251d04abf7bd08
workspace/settings/feedback_directories/upgrade_prompt_description: eb8a4bf60bcae458899e1ea94094789d
workspace/settings/feedback_directories/upgrade_prompt_title: 0a7b67ccf15a0aa8c64e5da7feb6e532
workspace/settings/feedback_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
workspace/settings/feedback_directories/workspace_assigned_to_directory: 6b907668667a9c74a99c437fa3cc2046
workspace/settings/feedback_directories/workspaces_already_linked: ef6248289707611a44950c3406aec0ec
workspace/settings/feedback_directories/workspaces_being_added: e01628710aff05c5172f2f43aab1f6fb
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
@@ -2597,8 +2604,8 @@ checksums:
workspace/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
workspace/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
workspace/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
workspace/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
workspace/settings/profile/sso_identity_confirmation_failed: 2d699f31f3e92bca9508a2772b071a1f
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: 9a5d190ed96e0149ed431c130c40284d
workspace/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
workspace/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
workspace/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -3515,6 +3522,7 @@ checksums:
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
workspace/unify/api_ingestion_setup_description: d18a267d0e50198682950f5341307fa3
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
workspace/unify/clear_mapping: 9bd7c716667838b9f203f5af0ac2d651
+35
View File
@@ -10,6 +10,7 @@ const mockGetJobsQueueingConfig = vi.fn();
const mockGetJobsWorkerBootstrapConfig = vi.fn();
const mockProcessResponsePipelineJob = vi.fn();
const mockProcessSurveySchedulingJob = vi.fn();
const mockProcessWorkflowRunJob = vi.fn();
const TEST_TIMEOUT_MS = 15_000;
const slowTest = (name: string, fn: () => Promise<void>): void => {
@@ -44,6 +45,10 @@ vi.mock("@/modules/survey/scheduling/lib/process-survey-scheduling-job", () => (
processSurveySchedulingJob: mockProcessSurveySchedulingJob,
}));
vi.mock("@/modules/workflows/lib/process-workflow-run-job", () => ({
processWorkflowRunJob: mockProcessWorkflowRunJob,
}));
describe("instrumentation-jobs", () => {
beforeEach(() => {
vi.resetModules();
@@ -109,6 +114,7 @@ describe("instrumentation-jobs", () => {
"response-pipeline.process": expect.any(Function),
"survey-scheduling.reconcile": expect.any(Function),
"test-log.process": mockExistingOverride,
"workflow-run.process": expect.any(Function),
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
@@ -116,6 +122,7 @@ describe("instrumentation-jobs", () => {
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
const responsePipelineOverride = overrides?.["response-pipeline.process"];
const surveySchedulingOverride = overrides?.["survey-scheduling.reconcile"];
const workflowRunOverride = overrides?.["workflow-run.process"];
await responsePipelineOverride?.(
{
@@ -144,6 +151,20 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
await workflowRunOverride?.(
{
workflowId: "workflow_123",
workflowRunId: "workflow_run_123",
workspaceId: "ws_123",
},
{
attempt: 1,
jobId: "job_789",
jobName: "workflow-run.process",
maxAttempts: 1,
queueName: "background-jobs",
}
);
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
{
@@ -172,6 +193,20 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
expect(mockProcessWorkflowRunJob).toHaveBeenCalledWith(
{
workflowId: "workflow_123",
workflowRunId: "workflow_run_123",
workspaceId: "ws_123",
},
{
attempt: 1,
jobId: "job_789",
jobName: "workflow-run.process",
maxAttempts: 1,
queueName: "background-jobs",
}
);
});
slowTest("reuses the in-flight startup promise", async () => {
+8
View File
@@ -3,6 +3,7 @@ import {
type JobsRuntimeHandle,
type TResponsePipelineJobData,
type TSurveySchedulingJobData,
type TWorkflowRunJobData,
removeRecurringSurveySchedulingJobSchedule,
startJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule,
@@ -17,6 +18,7 @@ import {
SURVEY_SCHEDULING_TIME_ZONE,
} from "@/modules/survey/scheduling/lib/constants";
import { processSurveySchedulingJob } from "@/modules/survey/scheduling/lib/process-survey-scheduling-job";
import { processWorkflowRunJob } from "@/modules/workflows/lib/process-workflow-run-job";
const WORKER_STARTUP_RETRY_DELAY_MS = 30_000;
@@ -32,6 +34,7 @@ type TJobsRuntimeGlobal = typeof globalThis & {
const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile";
const WORKFLOW_RUN_JOB_NAME = "workflow-run.process";
const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
@@ -39,6 +42,9 @@ const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = asy
const surveySchedulingJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processSurveySchedulingJob(data as TSurveySchedulingJobData, context);
};
const workflowRunJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processWorkflowRunJob(data as TWorkflowRunJobData, context);
};
const registerSurveySchedulingSchedule = async (): Promise<void> => {
await removeRecurringSurveySchedulingJobSchedule({
@@ -170,10 +176,12 @@ export const registerJobsWorker = async (): Promise<JobsRuntimeHandle | null> =>
...runtimeOptions.jobHandlerOverrides,
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
}
: {
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
};
globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => {
+11 -29
View File
@@ -3,7 +3,7 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
import {
assertOrganizationAIConfigured,
generateOrganizationAIText,
getAIDataAnalysisUnavailableReason,
getAISmartToolsUnavailableReason,
getOrganizationAIConfig,
isInstanceAIConfigured,
} from "./service";
@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({
generateText: vi.fn(),
isAiConfigured: vi.fn(),
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
loggerError: vi.fn(),
}));
@@ -62,7 +61,6 @@ vi.mock("@/lib/organization/service", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
@@ -74,10 +72,8 @@ describe("AI organization service", () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
});
test("returns the instance AI status and organization settings", async () => {
@@ -88,9 +84,7 @@ describe("AI organization service", () => {
expect(result).toMatchObject({
organizationId: "org_1",
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
isAISmartToolsEntitled: true,
isAIDataAnalysisEntitled: true,
isInstanceConfigured: true,
});
});
@@ -104,29 +98,22 @@ describe("AI organization service", () => {
test("fails closed when the organization is not entitled to AI", async () => {
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("fails closed when the requested AI capability is disabled", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: "org_1",
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: true,
});
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("fails closed when the instance AI configuration is incomplete", async () => {
mocks.isAiConfigured.mockReturnValueOnce(false);
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
OperationNotAllowedError
);
await expect(assertOrganizationAIConfigured("org_1")).rejects.toThrow(OperationNotAllowedError);
});
test("generates organization AI text with the configured package abstraction", async () => {
@@ -135,7 +122,6 @@ describe("AI organization service", () => {
const result = await generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
});
@@ -159,14 +145,12 @@ describe("AI organization service", () => {
await expect(
generateOrganizationAIText({
organizationId: "org_1",
capability: "smartTools",
prompt: "Translate this survey",
})
).rejects.toThrow(modelError);
expect(mocks.loggerError).toHaveBeenCalledWith(
{
organizationId: "org_1",
capability: "smartTools",
isInstanceConfigured: true,
errorCode: undefined,
err: modelError,
@@ -175,34 +159,32 @@ describe("AI organization service", () => {
);
});
describe("getAIDataAnalysisUnavailableReason", () => {
describe("getAISmartToolsUnavailableReason", () => {
const baseConfig = {
organizationId: "org_1",
isAISmartToolsEntitled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEntitled: true,
isAIDataAnalysisEnabled: true,
isInstanceConfigured: true,
};
test("returns undefined when all checks pass", () => {
expect(getAIDataAnalysisUnavailableReason(baseConfig)).toBeUndefined();
expect(getAISmartToolsUnavailableReason(baseConfig)).toBeUndefined();
});
test("returns not_in_plan when not entitled", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEntitled: false })).toBe(
test("returns not_in_plan when smart tools entitlement is missing", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEntitled: false })).toBe(
"not_in_plan"
);
});
test("returns not_enabled when disabled at org level", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isAIDataAnalysisEnabled: false })).toBe(
test("returns not_enabled when smart tools is disabled at org level", () => {
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEnabled: false })).toBe(
"not_enabled"
);
});
test("returns instance_not_configured when instance AI is missing", () => {
expect(getAIDataAnalysisUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
"instance_not_configured"
);
});
+9 -27
View File
@@ -4,12 +4,11 @@ import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export const AI_ERROR_CODES = {
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
} as const;
@@ -18,9 +17,7 @@ export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
isAISmartToolsEntitled: boolean;
isAIDataAnalysisEntitled: boolean;
isInstanceConfigured: boolean;
}
@@ -33,52 +30,40 @@ export const getOrganizationAIConfig = async (organizationId: string): Promise<T
throw new ResourceNotFoundError("Organization", organizationId);
}
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
getIsAISmartToolsEnabled(organizationId),
getIsAIDataAnalysisEnabled(organizationId),
]);
const isAISmartToolsEntitled = await getIsAISmartToolsEnabled(organizationId);
return {
organizationId,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
isAISmartToolsEntitled,
isAIDataAnalysisEntitled,
isInstanceConfigured: isInstanceAIConfigured(),
};
};
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export const getAIDataAnalysisUnavailableReason = (
export const getAISmartToolsUnavailableReason = (
aiConfig: TOrganizationAIConfig
): TAIUnavailableReason | undefined => {
if (!aiConfig.isAIDataAnalysisEntitled) return "not_in_plan";
if (!aiConfig.isAIDataAnalysisEnabled) return "not_enabled";
if (!aiConfig.isAISmartToolsEntitled) return "not_in_plan";
if (!aiConfig.isAISmartToolsEnabled) return "not_enabled";
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
return undefined;
};
export const assertOrganizationAIConfigured = async (
organizationId: string,
capability: "smartTools" | "dataAnalysis"
organizationId: string
): Promise<TOrganizationAIConfig> => {
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!isCapabilityEntitled) {
if (!aiConfig.isAISmartToolsEntitled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
}
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
if (!aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
}
@@ -88,15 +73,13 @@ export const assertOrganizationAIConfigured = async (
type TGenerateOrganizationAITextInput = {
organizationId: string;
capability: "smartTools" | "dataAnalysis";
} & Parameters<typeof generateText>[0];
export const generateOrganizationAIText = async ({
organizationId,
capability,
...options
}: TGenerateOrganizationAITextInput): Promise<Awaited<ReturnType<typeof generateText>>> => {
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
const aiConfig = await assertOrganizationAIConfigured(organizationId);
try {
return await generateText(options, env);
@@ -104,7 +87,6 @@ export const generateOrganizationAIText = async ({
logger.error(
{
organizationId,
capability,
isInstanceConfigured: aiConfig.isInstanceConfigured,
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
err: error,
+2 -1
View File
@@ -1,5 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
+53 -1
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
export const selectDisplay = {
@@ -146,6 +146,58 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
}
);
export const getDisplayForResponseValidation = async (
displayId: string,
tx?: Prisma.TransactionClient
): Promise<{
surveyId: string;
workspaceId: string;
responseId: string | null;
contactId: string | null;
} | null> => {
validateInputs([displayId, ZId]);
const client = tx ?? prisma;
try {
const display = await client.display.findUnique({
where: { id: displayId },
select: {
surveyId: true,
contactId: true,
response: { select: { id: true } },
survey: { select: { workspaceId: true } },
},
});
if (!display) return null;
return {
surveyId: display.surveyId,
workspaceId: display.survey.workspaceId,
responseId: display.response?.id ?? null,
contactId: display.contactId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
throw error;
}
};
export const assertDisplayOwnership = async (
displayId: string,
workspaceId: string,
surveyId: string,
contactId: string | null,
tx?: Prisma.TransactionClient
): Promise<void> => {
const display = await getDisplayForResponseValidation(displayId, tx);
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
if (display.workspaceId !== workspaceId)
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
if (display.surveyId !== surveyId)
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
if (display.contactId !== null && display.contactId !== contactId)
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
};
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
@@ -3,14 +3,18 @@ import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import {
assertDisplayOwnership,
getDisplayCountBySurveyId,
getDisplayForResponseValidation,
getDisplaysByContactId,
getDisplaysBySurveyIdWithContact,
} from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
const mockResponseId = "clqnfg59i000208i426pb4wcv";
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
const mockDisplaysForContact = [
@@ -290,3 +294,96 @@ describe("getDisplaysBySurveyIdWithContact", () => {
});
});
});
const mockDisplayRecord = {
surveyId: mockSurveyId,
contactId: null as string | null,
response: null as { id: string } | null,
survey: { workspaceId: mockWorkspaceId },
};
describe("getDisplayForResponseValidation", () => {
test("returns null when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toBeNull();
});
test("returns mapped shape when display is found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
response: { id: mockResponseId },
} as any);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toEqual({
surveyId: mockSurveyId,
workspaceId: mockWorkspaceId,
responseId: mockResponseId,
contactId: mockContactId,
});
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
vi.mocked(prisma.display.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
});
});
describe("assertDisplayOwnership", () => {
test("throws InvalidInputError when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when workspaceId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when surveyId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when display is already linked to a response", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
response: { id: mockResponseId },
} as any);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when contactId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: "contact-a",
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
).rejects.toThrow(InvalidInputError);
});
test("resolves without error when all ownership checks pass", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
).resolves.toBeUndefined();
});
});
-1
View File
@@ -38,7 +38,6 @@ describe("auth", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+26 -5
View File
@@ -46,6 +46,13 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/hub/service", () => ({
deleteHubTenantData: vi.fn().mockResolvedValue({
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
error: null,
}),
}));
describe("Organization Service", () => {
beforeEach(() => {
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
@@ -73,7 +80,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -126,7 +132,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -179,7 +184,6 @@ describe("Organization Service", () => {
updatedAt: new Date(),
billing: expectedBilling,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -239,7 +243,6 @@ describe("Organization Service", () => {
usageCycleAnchor: new Date(),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
workspaces: [
@@ -281,7 +284,6 @@ describe("Organization Service", () => {
usageCycleAnchor: expect.any(Date),
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
@@ -355,6 +357,7 @@ describe("Organization Service", () => {
billing: { stripeCustomerId: "cus_123" },
memberships: [],
workspaces: [],
feedbackDirectories: [],
} as any);
await deleteOrganization("org1");
@@ -363,5 +366,23 @@ describe("Organization Service", () => {
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
}
});
test("should purge Hub-owned data for each feedback directory", async () => {
const { deleteHubTenantData } = await import("@/modules/hub/service");
vi.mocked(prisma.organization.delete).mockResolvedValue({
id: "org1",
name: "Test Org",
billing: null,
memberships: [],
workspaces: [],
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
} as any);
await deleteOrganization("org1");
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
});
+13 -2
View File
@@ -19,6 +19,7 @@ import { updateUser } from "@/lib/user/service";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getWorkspaces } from "@/lib/workspace/service";
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
import { deleteHubTenantData } from "@/modules/hub/service";
import { validateInputs } from "../utils/validate";
export const select = {
@@ -35,7 +36,6 @@ export const select = {
},
},
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -74,7 +74,6 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
@@ -294,6 +293,11 @@ export const deleteOrganization = async (organizationId: string) => {
id: true,
},
},
feedbackDirectories: {
select: {
id: true,
},
},
},
});
@@ -301,6 +305,13 @@ export const deleteOrganization = async (organizationId: string) => {
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
await cleanupStripeCustomer(stripeCustomerId);
}
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) for each
// directory tenant. Failures are logged inside the gateway and do not roll back the
// local delete.
for (const directory of deletedOrganization.feedbackDirectories) {
await deleteHubTenantData(directory.id);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+31
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -324,5 +325,35 @@ describe("updateResponse", () => {
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
});
});
+8
View File
@@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -569,6 +570,13 @@ export const updateResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Response", responseId);
}
throw new DatabaseError(error.message);
}
@@ -228,7 +228,6 @@ export const mockOrganizationOutput: TOrganization = {
createdAt: currentDate,
updatedAt: currentDate,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
limits: {

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