mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-23 02:45:21 -05:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c04876bf | |||
| be5beaeed7 | |||
| bc56f99fd8 | |||
| 0f38627627 | |||
| a878bdff42 | |||
| d757e12c76 | |||
| 629febb2f7 | |||
| 40b93cc834 | |||
| f41d2c14f1 | |||
| af51414b03 | |||
| a9e39dd4ab | |||
| c8b0bb2225 | |||
| f6aa27ba8c | |||
| 82765f7dd7 | |||
| d5bbafcf90 | |||
| db87a588b5 | |||
| c834587c8d | |||
| ef18aacfa2 | |||
| 025a766c57 | |||
| f476db3128 | |||
| 37023275ca | |||
| 9266f64588 | |||
| 032066194b | |||
| 0bef023302 | |||
| aa83ee336c | |||
| 4357f497a1 | |||
| 526c17af23 | |||
| a0ddadebad | |||
| bc0d04f5e8 | |||
| f0967c2e23 | |||
| 13c9677edd | |||
| c0bf2ab7cc | |||
| 65d0f4ac0e | |||
| 655c0b5e47 | |||
| 4b1b377cf7 | |||
| 4463d5731d |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../skills
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../skills
|
||||
@@ -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 */}
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../skills
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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}</>;
|
||||
};
|
||||
|
||||
|
||||
+54
@@ -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) {
|
||||
|
||||
-5
@@ -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"),
|
||||
|
||||
+9
-4
@@ -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) {
|
||||
|
||||
+11
-1
@@ -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;
|
||||
|
||||
-6
@@ -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);
|
||||
|
||||
|
||||
+2
-9
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+4
-25
@@ -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
-2
@@ -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(
|
||||
|
||||
+4
@@ -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;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { WorkflowRunDetailLoading } from "@/modules/workflows/loading";
|
||||
|
||||
export default WorkflowRunDetailLoading;
|
||||
+13
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+34
@@ -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(),
|
||||
|
||||
+10
-1
@@ -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"),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type JobsRuntimeHandle,
|
||||
type TResponsePipelineJobData,
|
||||
type TSurveySchedulingJobData,
|
||||
type TWorkflowRunJobData,
|
||||
removeRecurringSurveySchedulingJobSchedule,
|
||||
startJobsRuntime,
|
||||
upsertRecurringSurveySchedulingJobSchedule,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
SURVEY_SCHEDULING_TIME_ZONE,
|
||||
} from "@/modules/survey/scheduling/lib/constants";
|
||||
import { processSurveySchedulingJob } from "@/modules/survey/scheduling/lib/process-survey-scheduling-job";
|
||||
import { processWorkflowRunJob } from "@/modules/workflows/lib/process-workflow-run-job";
|
||||
|
||||
const WORKER_STARTUP_RETRY_DELAY_MS = 30_000;
|
||||
|
||||
@@ -32,6 +34,7 @@ type TJobsRuntimeGlobal = typeof globalThis & {
|
||||
const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
|
||||
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
|
||||
const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile";
|
||||
const WORKFLOW_RUN_JOB_NAME = "workflow-run.process";
|
||||
|
||||
const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
|
||||
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
|
||||
@@ -39,6 +42,9 @@ const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = asy
|
||||
const surveySchedulingJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
|
||||
await processSurveySchedulingJob(data as TSurveySchedulingJobData, context);
|
||||
};
|
||||
const workflowRunJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
|
||||
await processWorkflowRunJob(data as TWorkflowRunJobData, context);
|
||||
};
|
||||
|
||||
const registerSurveySchedulingSchedule = async (): Promise<void> => {
|
||||
await removeRecurringSurveySchedulingJobSchedule({
|
||||
@@ -170,10 +176,12 @@ export const registerJobsWorker = async (): Promise<JobsRuntimeHandle | null> =>
|
||||
...runtimeOptions.jobHandlerOverrides,
|
||||
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
|
||||
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
|
||||
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
|
||||
}
|
||||
: {
|
||||
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
|
||||
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
|
||||
[WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
|
||||
};
|
||||
|
||||
globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => {
|
||||
|
||||
@@ -3,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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,6 @@ describe("auth", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user