mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-23 11:40:17 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a103cc9f3 | |||
| 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 |
@@ -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 */}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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(
|
||||
|
||||
-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"),
|
||||
|
||||
-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>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import {
|
||||
getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
getWhiteLabelPermission,
|
||||
@@ -38,14 +37,11 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
|
||||
await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
getIsAIDataAnalysisEnabled(organization.id),
|
||||
]);
|
||||
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
]);
|
||||
|
||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||
const currentUserRole = currentUserMembership?.role;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
|
||||
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
});
|
||||
|
||||
export const ZUpdateOrganizationAISettingsAction = z.object({
|
||||
|
||||
@@ -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,6 +110,16 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contact?.id ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(
|
||||
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
|
||||
contact,
|
||||
@@ -131,6 +147,13 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -56,11 +57,17 @@ export const POST = withV1ApiWrapper({
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await req.json();
|
||||
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return {
|
||||
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid JSON in request body",
|
||||
"Malformed JSON input, please check your request body",
|
||||
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
|
||||
true
|
||||
),
|
||||
@@ -211,7 +218,7 @@ export const POST = withV1ApiWrapper({
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
if (responseInputData.finished) {
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId,
|
||||
|
||||
@@ -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";
|
||||
@@ -99,6 +105,16 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
if (responseInput.displayId) {
|
||||
await assertDisplayOwnership(
|
||||
responseInput.displayId,
|
||||
workspaceId,
|
||||
responseInput.surveyId,
|
||||
contactId ?? null,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
@@ -122,6 +138,13 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (
|
||||
error.code === "P2002" &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
error.meta.target.includes("displayId")
|
||||
) {
|
||||
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
|
||||
}
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
|
||||
import { withV3ApiWrapper } from "./api-wrapper";
|
||||
|
||||
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
|
||||
@@ -414,6 +415,44 @@ describe("withV3ApiWrapper", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns 413 problem response for oversized JSON input", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-payload-too-large",
|
||||
},
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: "payload_too_large",
|
||||
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
requestId: "req-payload-too-large",
|
||||
status: 413,
|
||||
title: "Payload Too Large",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid route params", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1859,6 +1859,7 @@ checksums:
|
||||
workspace/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
|
||||
workspace/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
|
||||
workspace/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
|
||||
workspace/contacts/attribute_key_reserved_future_default: 2dbd2159bb6883bf56195448789ef72e
|
||||
workspace/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
|
||||
workspace/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
|
||||
workspace/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
|
||||
@@ -1893,6 +1894,7 @@ checksums:
|
||||
workspace/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
workspace/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
workspace/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||
workspace/contacts/invalid_csv_reserved_column_names: 6fef9d55e3dd298fea069404c9aaa474
|
||||
workspace/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||
workspace/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||
workspace/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -67,7 +67,6 @@ describe("User Service", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "org2",
|
||||
@@ -85,7 +84,6 @@ describe("User Service", () => {
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ValidationError,
|
||||
isExpectedError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { RequestBodyTooLargeError } from "@/app/lib/api/request-body";
|
||||
|
||||
// Mock Sentry
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
@@ -78,6 +79,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
"UniqueConstraintError",
|
||||
"RequestBodyTooLargeError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -97,6 +99,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
|
||||
{ ErrorClass: RequestBodyTooLargeError, args: [2 * 1024 * 1024] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
|
||||
@@ -38,6 +38,50 @@ describe("convertToCsv", () => {
|
||||
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in cell values", async () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p, age: 0 }));
|
||||
const csv = await convertToCsv(["name", "age"], rows);
|
||||
const lines = csv.trim().split("\n").slice(1); // drop header
|
||||
payloads.forEach((p, i) => {
|
||||
// each value should be prefixed with a single quote so the spreadsheet
|
||||
// app treats it as text rather than a formula
|
||||
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in field/header names", async () => {
|
||||
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=evil","age"');
|
||||
expect(lines[1]).toBe('"x",1');
|
||||
});
|
||||
|
||||
test("should not alter benign strings", async () => {
|
||||
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[1]).toBe('"Alice = Bob"');
|
||||
});
|
||||
|
||||
test("should preserve distinct columns whose labels collide after sanitization", async () => {
|
||||
// "=field" and "'=field" both render as "'=field" once defanged, but the
|
||||
// underlying row keys must stay distinct so neither cell is dropped.
|
||||
const csv = await convertToCsv(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=field","\'=field"');
|
||||
expect(lines[1]).toBe('"a","b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToXlsxBuffer", () => {
|
||||
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
|
||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||
expect(cleaned).toEqual(data);
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in xlsx cells", () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p }));
|
||||
const buffer = convertToXlsxBuffer(["name"], rows);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
payloads.forEach((p, i) => {
|
||||
const cell = sheet[`A${i + 2}`]; // row 1 is header
|
||||
// value stored as plain text, not as a formula (no `f` property)
|
||||
expect(cell.f).toBeUndefined();
|
||||
expect(cell.v).toBe(`'${p}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in xlsx header names", () => {
|
||||
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
const headerCell = sheet["A1"];
|
||||
expect(headerCell.f).toBeUndefined();
|
||||
expect(headerCell.v).toBe("'=evil");
|
||||
// benign header untouched
|
||||
expect(sheet["B1"].v).toBe("name");
|
||||
// data row mapped via original key
|
||||
expect(sheet["A2"].v).toBe("x");
|
||||
expect(sheet["B2"].v).toBe("Alice");
|
||||
});
|
||||
|
||||
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
|
||||
// Original keys "=field" and "'=field" both render as "'=field"; ensure
|
||||
// both cells survive instead of one overwriting the other.
|
||||
const buffer = convertToXlsxBuffer(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
expect(sheet["A1"].v).toBe("'=field");
|
||||
expect(sheet["B1"].v).toBe("'=field");
|
||||
expect(sheet["A2"].v).toBe("a");
|
||||
expect(sheet["B2"].v).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,30 @@ import { AsyncParser } from "@json2csv/node";
|
||||
import * as xlsx from "xlsx";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
// Defang spreadsheet formula injection. Cell values starting with
|
||||
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
|
||||
// Sanitize at the render boundary only — never rewrite row keys, since
|
||||
// distinct user-controlled labels could collide after prefixing (e.g.
|
||||
// "=field" and "'=field" both map to "'=field"), dropping cell data.
|
||||
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
|
||||
|
||||
const sanitizeFormulaInjection = <T>(value: T): T => {
|
||||
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
|
||||
return `'${value}` as T;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
|
||||
let csv: string = "";
|
||||
|
||||
// Field descriptors preserve the original lookup key while overriding the
|
||||
// rendered label and cell value with sanitized versions.
|
||||
const parser = new AsyncParser({
|
||||
fields,
|
||||
fields: fields.map((name) => ({
|
||||
label: sanitizeFormulaInjection(name),
|
||||
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
|
||||
})),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -23,8 +42,13 @@ export const convertToXlsxBuffer = (
|
||||
fields: string[],
|
||||
jsonData: Record<string, string | number>[]
|
||||
): Buffer => {
|
||||
// Build as array-of-arrays so original row keys are looked up before
|
||||
// sanitization is applied to the rendered header/cell only.
|
||||
const headerRow = fields.map(sanitizeFormulaInjection);
|
||||
const dataRows = jsonData.map((row) => fields.map((name) => sanitizeFormulaInjection(row[name])));
|
||||
|
||||
const wb = xlsx.utils.book_new();
|
||||
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
|
||||
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
};
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
|
||||
"attribute_key_placeholder": "z. B. geburtsdatum",
|
||||
"attribute_key_required": "Schlüssel ist erforderlich",
|
||||
"attribute_key_reserved_future_default": "Der Schlüssel ist für zukünftige Standardattribute reserviert ({reservedKeys}). Bitte wähle einen anderen Schlüssel.",
|
||||
"attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen",
|
||||
"attribute_label": "Bezeichnung",
|
||||
"attribute_label_placeholder": "z. B. Geburtsdatum",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Persönlichen Link erstellen",
|
||||
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu erstellen.",
|
||||
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
|
||||
"invalid_csv_reserved_column_names": "Reservierte CSV-Spaltennamen: {columns}. Diese Namen sind für zukünftige Standardattribute ({reservedKeys}) reserviert und können nicht als neue Attribute erstellt werden.",
|
||||
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
|
||||
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
|
||||
"no_activity_yet": "Noch keine Aktivität",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
|
||||
"ai_data_analysis_enabled_description": "KI nutzen, um mehr aus deinen Daten herauszuholen – richte Dashboards, Diagramme, Berichte und mehr ein. Greift auf deine Erfahrungsdaten zu.",
|
||||
"ai_enabled": "Formbricks KI",
|
||||
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
|
||||
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte deine:n Administrator:in, AI_PROVIDER, AI_MODEL und die passenden Provider-Zugangsdaten zu setzen, bevor du KI-Funktionen aktivierst.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "„Umfrage geschlossen“-Nachricht anpassen",
|
||||
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Passe das Theme in den <lookFeelLink>Look & Feel</lookFeelLink> Einstellungen an.",
|
||||
"ai_data_analysis_disabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
|
||||
"ai_features_not_enabled": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
|
||||
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
|
||||
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
|
||||
"attribute_key_placeholder": "e.g. date_of_birth",
|
||||
"attribute_key_required": "Key is required",
|
||||
"attribute_key_reserved_future_default": "Key is reserved for future default attributes ({reservedKeys}). Please choose a different key.",
|
||||
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
"attribute_label": "Label",
|
||||
"attribute_label_placeholder": "e.g. Date of Birth",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Generate Personal Link",
|
||||
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
|
||||
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
|
||||
"invalid_csv_reserved_column_names": "Reserved CSV column name(s): {columns}. These names are reserved for future default attributes ({reservedKeys}) and cannot be created as new attributes.",
|
||||
"invalid_date_format": "Invalid date format. Please use a valid date.",
|
||||
"invalid_number_format": "Invalid number format. Please enter a valid number.",
|
||||
"no_activity_yet": "No activity yet",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Workspaces being granted access"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Manage AI-powered features for this organization.",
|
||||
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
|
||||
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Adjust the theme in the <lookFeelLink>Look & Feel</lookFeelLink> Settings.",
|
||||
"ai_data_analysis_disabled": "AI data analysis is disabled for this organization.",
|
||||
"ai_features_not_enabled": "AI features are not enabled for this organization.",
|
||||
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
|
||||
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
|
||||
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",
|
||||
"attribute_key_required": "La clave es obligatoria",
|
||||
"attribute_key_reserved_future_default": "La clave está reservada para atributos predeterminados futuros ({reservedKeys}). Por favor, elige una clave diferente.",
|
||||
"attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra",
|
||||
"attribute_label": "Etiqueta",
|
||||
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Generar enlace personal",
|
||||
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
|
||||
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
|
||||
"invalid_csv_reserved_column_names": "Nombre(s) de columna CSV reservado(s): {columns}. Estos nombres están reservados para atributos predeterminados futuros ({reservedKeys}) y no se pueden crear como nuevos atributos.",
|
||||
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
|
||||
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
|
||||
"no_activity_yet": "Aún no hay actividad",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
|
||||
"ai_enabled": "IA de Formbricks",
|
||||
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
|
||||
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
|
||||
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
|
||||
"ai_data_analysis_disabled": "El análisis de datos con IA está deshabilitado para esta organización.",
|
||||
"ai_features_not_enabled": "Las funciones de IA no están habilitadas para esta organización.",
|
||||
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
|
||||
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
|
||||
"attribute_key_placeholder": "ex. date_de_naissance",
|
||||
"attribute_key_required": "La clé est requise",
|
||||
"attribute_key_reserved_future_default": "La clé est réservée pour les attributs par défaut futurs ({reservedKeys}). Veuillez choisir une clé différente.",
|
||||
"attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr : uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre",
|
||||
"attribute_label": "Étiquette",
|
||||
"attribute_label_placeholder": "ex. Date de naissance",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Générer un lien personnel",
|
||||
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
|
||||
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s) : {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
|
||||
"invalid_csv_reserved_column_names": "Nom(s) de colonne CSV réservé(s) : {columns}. Ces noms sont réservés pour les attributs par défaut futurs ({reservedKeys}) et ne peuvent pas être créés en tant que nouveaux attributs.",
|
||||
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
|
||||
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
|
||||
"no_activity_yet": "Aucune activité pour le moment",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Espaces de travail en cours d'ajout"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
|
||||
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
|
||||
"ai_enabled": "IA Formbricks",
|
||||
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
|
||||
"ai_instance_not_configured": "L'IA est configurée au niveau de l'instance via des variables d'environnement. Demandez à votre administrateur de définir AI_PROVIDER, les identifiants du fournisseur et la liste de modèles correspondante avant d'activer les fonctionnalités d'IA.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
|
||||
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajuste le thème dans les paramètres <lookFeelLink>Apparence et ressenti</lookFeelLink>.",
|
||||
"ai_data_analysis_disabled": "L'analyse de données par IA est désactivée pour cette organisation.",
|
||||
"ai_features_not_enabled": "Les fonctionnalités IA ne sont pas activées pour cette organisation.",
|
||||
"ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.",
|
||||
"ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók. Betűvel kell kezdődnie.",
|
||||
"attribute_key_placeholder": "például: szuletesi_ido",
|
||||
"attribute_key_required": "A kulcs kötelező",
|
||||
"attribute_key_reserved_future_default": "A kulcs le van foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}). Kérem, válasszon egy másik kulcsot.",
|
||||
"attribute_key_safe_identifier_required": "A kulcs csak biztonságos azonosító lehet: csak ékezet nélküli kisbetűk, számok és aláhúzásjelek használhatók, és betűvel kell kezdődnie",
|
||||
"attribute_label": "Címke",
|
||||
"attribute_label_placeholder": "például: Születési idő",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Személyes hivatkozás előállítása",
|
||||
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
|
||||
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
|
||||
"invalid_csv_reserved_column_names": "Fenntartott CSV oszlopnév/nevek: {columns}. Ezek a nevek le vannak foglalva jövőbeli alapértelmezett attribútumok számára ({reservedKeys}), és nem hozhatók létre új attribútumokként.",
|
||||
"invalid_date_format": "Érvénytelen dátumformátum. Használjon érvényes dátumot.",
|
||||
"invalid_number_format": "Érvénytelen számformátum. Adjon meg érvényes számot.",
|
||||
"no_activity_yet": "Még nincs tevékenység",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
|
||||
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
|
||||
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
|
||||
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
|
||||
"ai_data_analysis_disabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
|
||||
"ai_features_not_enabled": "Az AI funkciók nincsenek engedélyezve ezen szervezet számára.",
|
||||
"ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.",
|
||||
"ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
|
||||
"attribute_key_placeholder": "例: date_of_birth",
|
||||
"attribute_key_required": "キーは必須です",
|
||||
"attribute_key_reserved_future_default": "このキーは将来のデフォルト属性用に予約されています({reservedKeys})。別のキーを選択してください。",
|
||||
"attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります",
|
||||
"attribute_label": "ラベル",
|
||||
"attribute_label_placeholder": "例: 生年月日",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "個人リンクを生成",
|
||||
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
|
||||
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
|
||||
"invalid_csv_reserved_column_names": "予約されたCSV列名: {columns}。これらの名前は将来のデフォルト属性({reservedKeys})用に予約されており、新しい属性として作成できません。",
|
||||
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
|
||||
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
|
||||
"no_activity_yet": "まだアクティビティがありません",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "アクセス権が付与されるワークスペース"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "この組織のAI機能を管理します。",
|
||||
"ai_instance_not_configured": "AI は環境変数を使ってインスタンスレベルで設定されます。AI 機能を有効にする前に、管理者に AI_PROVIDER、このプロバイダーの認証情報、および対応するモデル一覧を設定してもらってください。",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "「フォームはクローズしました」メッセージを調整",
|
||||
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
|
||||
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
|
||||
"ai_data_analysis_disabled": "この組織ではAIデータ分析が無効になっています。",
|
||||
"ai_features_not_enabled": "この組織ではAI機能が有効になっていません。",
|
||||
"ai_instance_not_configured": "AIが設定されていません。管理者にお問い合わせください。",
|
||||
"ai_smart_tools_disabled": "この組織ではAIスマートツールが無効になっています。",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
|
||||
"attribute_key_placeholder": "bijv. geboortedatum",
|
||||
"attribute_key_required": "Sleutel is verplicht",
|
||||
"attribute_key_reserved_future_default": "Sleutel is gereserveerd voor toekomstige standaardattributen ({reservedKeys}). Kies een andere sleutel.",
|
||||
"attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter",
|
||||
"attribute_label": "Label",
|
||||
"attribute_label_placeholder": "bijv. Geboortedatum",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Persoonlijke link genereren",
|
||||
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
|
||||
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
|
||||
"invalid_csv_reserved_column_names": "Gereserveerde CSV-kolomnaam/namen: {columns}. Deze namen zijn gereserveerd voor toekomstige standaardattributen ({reservedKeys}) en kunnen niet als nieuwe attributen worden aangemaakt.",
|
||||
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
|
||||
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
|
||||
"no_activity_yet": "Nog geen activiteit",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Werkruimtes die toegang krijgen"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
|
||||
"ai_instance_not_configured": "AI wordt op instantieniveau geconfigureerd via omgevingsvariabelen. Vraag je beheerder om AI_PROVIDER, de inloggegevens voor die provider en de bijbehorende modellenlijst in te stellen voordat AI-functies worden ingeschakeld.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Pas het bericht 'Enquête gesloten' aan",
|
||||
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
|
||||
"ai_data_analysis_disabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
|
||||
"ai_features_not_enabled": "AI-functies zijn niet ingeschakeld voor deze organisatie.",
|
||||
"ai_instance_not_configured": "AI is niet geconfigureerd. Neem contact op met je beheerder.",
|
||||
"ai_smart_tools_disabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
|
||||
"attribute_key_placeholder": "ex: data_de_nascimento",
|
||||
"attribute_key_required": "A chave é obrigatória",
|
||||
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolha uma chave diferente.",
|
||||
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra",
|
||||
"attribute_label": "Etiqueta",
|
||||
"attribute_label_placeholder": "ex: Data de nascimento",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Gerar link pessoal",
|
||||
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
|
||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
|
||||
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Esses nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
|
||||
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
|
||||
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
|
||||
"no_activity_yet": "Nenhuma atividade ainda",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Workspaces recebendo acesso"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
|
||||
"ai_instance_not_configured": "A IA é configurada no nível da instância por meio de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse provedor e a lista de modelos correspondente antes de habilitar os recursos de IA.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Ajustar mensagem 'Pesquisa Encerrada''",
|
||||
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajuste o tema nas configurações de <lookFeelLink>Aparência</lookFeelLink>.",
|
||||
"ai_data_analysis_disabled": "A análise de dados por IA está desabilitada para esta organização.",
|
||||
"ai_features_not_enabled": "Os recursos de IA não estão habilitados para esta organização.",
|
||||
"ai_instance_not_configured": "A IA não está configurada. Entre em contato com seu administrador.",
|
||||
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
|
||||
"attribute_key_placeholder": "ex. data_de_nascimento",
|
||||
"attribute_key_required": "A chave é obrigatória",
|
||||
"attribute_key_reserved_future_default": "A chave está reservada para atributos padrão futuros ({reservedKeys}). Por favor, escolhe uma chave diferente.",
|
||||
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra",
|
||||
"attribute_label": "Etiqueta",
|
||||
"attribute_label_placeholder": "ex. Data de nascimento",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Gerar Link Pessoal",
|
||||
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
|
||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
|
||||
"invalid_csv_reserved_column_names": "Nome(s) de coluna CSV reservado(s): {columns}. Estes nomes estão reservados para atributos padrão futuros ({reservedKeys}) e não podem ser criados como novos atributos.",
|
||||
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
|
||||
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
|
||||
"no_activity_yet": "Ainda sem atividade",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Workspaces a receber acesso"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
|
||||
"ai_enabled": "IA da Formbricks",
|
||||
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
|
||||
"ai_instance_not_configured": "A IA é configurada ao nível da instância através de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse fornecedor e a lista de modelos correspondente antes de ativar as funcionalidades de IA.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Ajustar mensagem de 'Inquérito Fechado'",
|
||||
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajusta o tema nas definições de <lookFeelLink>Aparência</lookFeelLink>.",
|
||||
"ai_data_analysis_disabled": "A análise de dados por IA está desativada para esta organização.",
|
||||
"ai_features_not_enabled": "As funcionalidades de IA não estão ativadas para esta organização.",
|
||||
"ai_instance_not_configured": "A IA não está configurada. Contacta o teu administrador.",
|
||||
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
|
||||
"attribute_key_placeholder": "ex: date_of_birth",
|
||||
"attribute_key_required": "Cheia este obligatorie",
|
||||
"attribute_key_reserved_future_default": "Cheia este rezervată pentru atribute implicite viitoare ({reservedKeys}). Te rugăm să alegi o cheie diferită.",
|
||||
"attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă",
|
||||
"attribute_label": "Etichetă",
|
||||
"attribute_label_placeholder": "ex: Data nașterii",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Generează link personal",
|
||||
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
|
||||
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
|
||||
"invalid_csv_reserved_column_names": "Nume de coloană CSV rezervate: {columns}. Aceste nume sunt rezervate pentru atribute implicite viitoare ({reservedKeys}) și nu pot fi create ca atribute noi.",
|
||||
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
|
||||
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
|
||||
"no_activity_yet": "Nicio activitate încă",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
|
||||
"ai_instance_not_configured": "AI este configurată la nivel de instanță prin variabile de mediu. Cere administratorului să configureze AI_PROVIDER, credențialele acelui furnizor și lista de modele corespunzătoare înainte de a activa funcționalitățile AI.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'",
|
||||
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajustează tema în setările <lookFeelLink>Aspect și Experiență</lookFeelLink>.",
|
||||
"ai_data_analysis_disabled": "Analiza de date AI este dezactivată pentru această organizație.",
|
||||
"ai_features_not_enabled": "Funcțiile AI nu sunt activate pentru această organizație.",
|
||||
"ai_instance_not_configured": "AI nu este configurat. Contactează administratorul.",
|
||||
"ai_smart_tools_disabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
|
||||
"attribute_key_placeholder": "например, date_of_birth",
|
||||
"attribute_key_required": "Ключ обязателен",
|
||||
"attribute_key_reserved_future_default": "Ключ зарезервирован для будущих атрибутов по умолчанию ({reservedKeys}). Пожалуйста, выбери другой ключ.",
|
||||
"attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы",
|
||||
"attribute_label": "Метка",
|
||||
"attribute_label_placeholder": "например, дата рождения",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Сгенерировать персональную ссылку",
|
||||
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
|
||||
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
|
||||
"invalid_csv_reserved_column_names": "Зарезервированные названия столбцов CSV: {columns}. Эти названия зарезервированы для будущих атрибутов по умолчанию ({reservedKeys}) и не могут быть созданы как новые атрибуты.",
|
||||
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
|
||||
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
|
||||
"no_activity_yet": "Пока нет активности",
|
||||
@@ -2429,7 +2431,7 @@
|
||||
"most_popular": "Самый популярный",
|
||||
"pending_change_removed": "Запланированное изменение тарифа отменено.",
|
||||
"pending_plan_badge": "Запланирован",
|
||||
"pending_plan_change_description": "Твой тариф сменится на {plan} {date}.",
|
||||
"pending_plan_change_description": "Твой тариф сменится на {plan} на {date}.",
|
||||
"pending_plan_change_title": "Запланированное изменение тарифа",
|
||||
"pending_plan_cta": "Запланирован",
|
||||
"per_month": "в месяц",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
|
||||
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
|
||||
"ai_instance_not_configured": "ИИ настраивается на уровне инстанса через переменные окружения. Попросите администратора настроить AI_PROVIDER, учетные данные этого провайдера и соответствующий список моделей перед включением функций ИИ.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Изменить сообщение «Опрос закрыт»",
|
||||
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
|
||||
"ai_data_analysis_disabled": "Анализ данных с помощью ИИ отключён для этой организации.",
|
||||
"ai_features_not_enabled": "Функции ИИ не включены для этой организации.",
|
||||
"ai_instance_not_configured": "ИИ не настроен. Свяжись с администратором.",
|
||||
"ai_smart_tools_disabled": "Умные инструменты ИИ отключены для этой организации.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
|
||||
"attribute_key_placeholder": "t.ex. date_of_birth",
|
||||
"attribute_key_required": "Nyckel krävs",
|
||||
"attribute_key_reserved_future_default": "Nyckeln är reserverad för framtida standardattribut ({reservedKeys}). Välj en annan nyckel.",
|
||||
"attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav",
|
||||
"attribute_label": "Etikett",
|
||||
"attribute_label_placeholder": "t.ex. Födelsedatum",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Generera personlig länk",
|
||||
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
|
||||
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
|
||||
"invalid_csv_reserved_column_names": "Reserverade CSV-kolumnnamn: {columns}. Dessa namn är reserverade för framtida standardattribut ({reservedKeys}) och kan inte skapas som nya attribut.",
|
||||
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
|
||||
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
|
||||
"no_activity_yet": "Ingen aktivitet än",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
|
||||
"ai_instance_not_configured": "AI konfigureras på instansnivå via miljövariabler. Be din administratör att ange AI_PROVIDER, autentiseringsuppgifterna för den leverantören och den tillhörande modellistan innan AI-funktioner aktiveras.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "Justera meddelande för 'Enkät stängd'",
|
||||
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
|
||||
"ai_data_analysis_disabled": "AI-dataanalys är inaktiverad för den här organisationen.",
|
||||
"ai_features_not_enabled": "AI-funktioner är inte aktiverade för den här organisationen.",
|
||||
"ai_instance_not_configured": "AI är inte konfigurerad. Kontakta din administratör.",
|
||||
"ai_smart_tools_disabled": "AI smarta verktyg är inaktiverade för den här organisationen.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "Yalnızca küçük harfler, rakamlar ve alt çizgiler. Bir harfle başlamalıdır.",
|
||||
"attribute_key_placeholder": "örn. dogum_tarihi",
|
||||
"attribute_key_required": "Anahtar gereklidir",
|
||||
"attribute_key_reserved_future_default": "Anahtar, gelecekteki varsayılan özellikler için ayrılmıştır ({reservedKeys}). Lütfen farklı bir anahtar seçin.",
|
||||
"attribute_key_safe_identifier_required": "Anahtar güvenli bir tanımlayıcı olmalıdır: yalnızca küçük harfler, rakamlar ve alt çizgiler içermeli ve bir harfle başlamalıdır",
|
||||
"attribute_label": "Etiket",
|
||||
"attribute_label_placeholder": "örn. Doğum Tarihi",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "Kişisel Bağlantı Oluştur",
|
||||
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir anket seç.",
|
||||
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harf, rakam ve alt çizgi içerebilir ve bir harfle başlamalıdır.",
|
||||
"invalid_csv_reserved_column_names": "Ayrılmış CSV sütun adı/adları: {columns}. Bu adlar gelecekteki varsayılan özellikler ({reservedKeys}) için ayrılmıştır ve yeni özellik olarak oluşturulamaz.",
|
||||
"invalid_date_format": "Geçersiz tarih formatı. Lütfen geçerli bir tarih kullanın.",
|
||||
"invalid_number_format": "Geçersiz sayı formatı. Lütfen geçerli bir sayı girin.",
|
||||
"no_activity_yet": "Henüz aktivite yok",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "Erişim verilen çalışma alanları"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
|
||||
"ai_data_analysis_enabled_description": "Verilerinden daha fazlasını elde etmek, kontrol panelleri, grafikler, raporlar ve daha fazlasını kurmak için yapay zeka. Deneyim verilerine dokunur.",
|
||||
"ai_enabled": "Formbricks Yapay Zeka",
|
||||
"ai_enabled_description": "Bu organizasyon için yapay zeka destekli özellikleri yönet.",
|
||||
"ai_instance_not_configured": "Yapay zeka, ortam değişkenleri aracılığıyla instance seviyesinde yapılandırılır. Yapay zeka özelliklerini etkinleştirmeden önce yöneticinden AI_PROVIDER, AI_MODEL ve eşleşen sağlayıcı kimlik bilgilerini ayarlamasını iste.",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "\"Anket Kapatıldı\" mesajını düzenle",
|
||||
"adjust_survey_closed_message_description": "Anket kapalıyken ziyaretçilerin gördüğü mesajı değiştir.",
|
||||
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
|
||||
"ai_data_analysis_disabled": "Bu organizasyon için yapay zeka veri analizi devre dışı.",
|
||||
"ai_features_not_enabled": "Bu organizasyon için yapay zeka özellikleri etkinleştirilmemiş.",
|
||||
"ai_instance_not_configured": "Yapay zeka yapılandırılmamış. Yöneticinle iletişime geç.",
|
||||
"ai_smart_tools_disabled": "Bu organizasyon için yapay zeka akıllı araçları devre dışı.",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
|
||||
"attribute_key_placeholder": "例如:date_of_birth",
|
||||
"attribute_key_required": "键为必填项",
|
||||
"attribute_key_reserved_future_default": "该键已保留用于未来的默认属性({reservedKeys})。请选择其他键。",
|
||||
"attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头",
|
||||
"attribute_label": "标签",
|
||||
"attribute_label_placeholder": "例如:出生日期",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "生成个人链接",
|
||||
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
|
||||
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
|
||||
"invalid_csv_reserved_column_names": "CSV 列名已被保留:{columns}。这些名称已保留用于未来的默认属性({reservedKeys}),无法创建为新属性。",
|
||||
"invalid_date_format": "日期格式无效。请使用有效日期。",
|
||||
"invalid_number_format": "数字格式无效。请输入有效的数字。",
|
||||
"no_activity_yet": "暂无活动",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "将被授权访问的工作区"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "数据增强与分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
|
||||
"ai_instance_not_configured": "AI 通过环境变量在实例级别进行配置。启用 AI 功能前,请让管理员设置 AI_PROVIDER、该提供商的凭据以及对应的模型列表。",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "调整 \"调查 关闭\" 消息",
|
||||
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
|
||||
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
|
||||
"ai_data_analysis_disabled": "此组织已禁用 AI 数据分析。",
|
||||
"ai_features_not_enabled": "此组织未启用 AI 功能。",
|
||||
"ai_instance_not_configured": "AI 未配置。请联系您的管理员。",
|
||||
"ai_smart_tools_disabled": "此组织已禁用 AI 智能工具。",
|
||||
|
||||
@@ -1935,6 +1935,7 @@
|
||||
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
|
||||
"attribute_key_placeholder": "例如:date_of_birth",
|
||||
"attribute_key_required": "金鑰為必填項目",
|
||||
"attribute_key_reserved_future_default": "此鍵已保留供未來預設屬性使用({reservedKeys})。請選擇其他鍵。",
|
||||
"attribute_key_safe_identifier_required": "金鑰必須為安全識別字:僅限小寫字母、數字和底線,且必須以字母開頭",
|
||||
"attribute_label": "標籤",
|
||||
"attribute_label_placeholder": "例如:出生日期",
|
||||
@@ -1969,6 +1970,7 @@
|
||||
"generate_personal_link": "產生個人連結",
|
||||
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
|
||||
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
|
||||
"invalid_csv_reserved_column_names": "保留的 CSV 欄位名稱:{columns}。這些名稱已保留供未來預設屬性使用({reservedKeys}),無法建立為新屬性。",
|
||||
"invalid_date_format": "日期格式無效。請使用有效的日期。",
|
||||
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
|
||||
"no_activity_yet": "尚無活動",
|
||||
@@ -2610,8 +2612,6 @@
|
||||
"workspaces_being_added": "正在授予存取權限的工作區"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "資料增強與分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理此組織的 AI 功能。",
|
||||
"ai_instance_not_configured": "AI 會透過環境變數在實例層級進行設定。啟用 AI 功能前,請管理員設定 AI_PROVIDER、該供應商的憑證,以及對應的模型清單。",
|
||||
@@ -2818,7 +2818,6 @@
|
||||
"adjust_survey_closed_message": "調整「問卷已關閉」訊息",
|
||||
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
|
||||
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外觀與感覺</lookFeelLink>設定中調整主題。",
|
||||
"ai_data_analysis_disabled": "此組織已停用 AI 資料分析。",
|
||||
"ai_features_not_enabled": "此組織未啟用 AI 功能。",
|
||||
"ai_instance_not_configured": "AI 未設定。請聯絡您的管理員。",
|
||||
"ai_smart_tools_disabled": "此組織已停用 AI 智慧工具。",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
@@ -73,10 +74,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||
|
||||
if (schemas?.body) {
|
||||
let bodyData;
|
||||
let bodyData: Record<string, unknown>;
|
||||
try {
|
||||
bodyData = await request.json();
|
||||
bodyData = await parseJsonBodyWithLimit<Record<string, unknown>>(request);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return handleApiError(request, {
|
||||
type: "payload_too_large",
|
||||
details: [
|
||||
{
|
||||
field: "body",
|
||||
issue: error.message,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
@@ -164,6 +165,42 @@ describe("apiWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle oversized JSON input in request body", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 413 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||
type: "payload_too_large",
|
||||
details: [
|
||||
{
|
||||
field: "body",
|
||||
issue: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty body when body schema is provided", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
|
||||
@@ -148,6 +148,35 @@ const conflictResponse = ({
|
||||
);
|
||||
};
|
||||
|
||||
const payloadTooLargeResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 413,
|
||||
message: "Payload Too Large",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 413,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const unprocessableEntityResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
@@ -351,6 +380,7 @@ export const responses = {
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
conflictResponse,
|
||||
payloadTooLargeResponse,
|
||||
unprocessableEntityResponse,
|
||||
tooManyRequestsResponse,
|
||||
internalServerErrorResponse,
|
||||
|
||||
@@ -85,6 +85,18 @@ describe("utils", () => {
|
||||
expect(body.error.message).toBe("Conflict");
|
||||
});
|
||||
|
||||
test('return payload too large response for "payload_too_large" error', async () => {
|
||||
const details = [{ field: "body", issue: "Request body must not exceed 2097152 bytes" }];
|
||||
const error: ApiErrorResponseV2 = { type: "payload_too_large", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(413);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(413);
|
||||
expect(body.error.message).toBe("Payload Too Large");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
|
||||
const details = [{ field: "data", issue: "malformed" }];
|
||||
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
|
||||
|
||||
@@ -28,6 +28,8 @@ export const handleApiError = (
|
||||
return responses.notFoundResponse({ details: err.details });
|
||||
case "conflict":
|
||||
return responses.conflictResponse({ details: err.details });
|
||||
case "payload_too_large":
|
||||
return responses.payloadTooLargeResponse({ details: err.details });
|
||||
case "unprocessable_entity":
|
||||
return responses.unprocessableEntityResponse({ details: err.details });
|
||||
case "too_many_requests":
|
||||
|
||||
+11
@@ -10,6 +10,10 @@ import {
|
||||
TGetContactAttributeKeysFilter,
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import {
|
||||
getReservedFutureDefaultAttributeKeyIssue,
|
||||
isReservedFutureDefaultAttributeKey,
|
||||
} from "@/modules/ee/contacts/lib/attribute-key-policy";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
async (workspaceIds: string[], params: TGetContactAttributeKeysFilter) => {
|
||||
@@ -45,6 +49,13 @@ export const createContactAttributeKey = async (
|
||||
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
const { workspaceId, name, description, key, dataType } = contactAttributeKey;
|
||||
|
||||
if (isReservedFutureDefaultAttributeKey(key)) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: [{ field: "key", issue: getReservedFutureDefaultAttributeKeyIssue([key]) }],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
|
||||
workspace: {
|
||||
|
||||
+22
@@ -105,6 +105,28 @@ describe("createContactAttributeKey", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("returns bad request when key is reserved for future defaults", async () => {
|
||||
const result = await createContactAttributeKey({
|
||||
...inputContactAttributeKey,
|
||||
key: "user_id",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{
|
||||
field: "key",
|
||||
issue:
|
||||
"Reserved attribute key(s): user_id. These keys are reserved for the v5.1 safe-identifier default attribute migration and cannot be created as custom attributes.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("returns conflict error when key already exists", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
|
||||
+20
@@ -2,6 +2,10 @@ import { z } from "zod";
|
||||
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import {
|
||||
getReservedFutureDefaultAttributeKeyIssue,
|
||||
isReservedFutureDefaultAttributeKey,
|
||||
} from "@/modules/ee/contacts/lib/attribute-key-policy";
|
||||
|
||||
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({})
|
||||
.refine(
|
||||
@@ -38,6 +42,14 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
|
||||
if (isReservedFutureDefaultAttributeKey(data.key)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
})
|
||||
.meta({
|
||||
id: "contactAttributeKeyInput",
|
||||
@@ -65,6 +77,14 @@ export const ZContactAttributeKeyCreateInput = ZContactAttributeKey.pick({
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
|
||||
if (isReservedFutureDefaultAttributeKey(data.key)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: getReservedFutureDefaultAttributeKeyIssue([data.key]),
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
})
|
||||
.meta({
|
||||
id: "contactAttributeKeyCreateInput",
|
||||
|
||||
@@ -10,7 +10,13 @@ export type ApiErrorDetails = {
|
||||
|
||||
export type ApiErrorResponseV2 =
|
||||
| {
|
||||
type: "unauthorized" | "forbidden" | "conflict" | "too_many_requests" | "internal_server_error";
|
||||
type:
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "conflict"
|
||||
| "payload_too_large"
|
||||
| "too_many_requests"
|
||||
| "internal_server_error";
|
||||
details?: ApiErrorDetails;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -2,7 +2,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 { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { createUser, getUser, getUserByEmail, updateUser, updateUserLastLoginAt } from "./user";
|
||||
|
||||
@@ -53,6 +53,41 @@ describe("User Management", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
});
|
||||
|
||||
test("creates a user with an Azure AD enterprise display name", async () => {
|
||||
const enterpriseDisplayName = "Lastname,Firstname (DEPT) COMPANY-CITY";
|
||||
vi.mocked(prisma.user.create).mockResolvedValueOnce({
|
||||
...mockPrismaUser,
|
||||
name: enterpriseDisplayName,
|
||||
});
|
||||
|
||||
const result = await createUser({
|
||||
email: mockUser.email,
|
||||
name: enterpriseDisplayName,
|
||||
locale: mockUser.locale,
|
||||
});
|
||||
|
||||
expect(result.name).toBe(enterpriseDisplayName);
|
||||
expect(prisma.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: enterpriseDisplayName,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects display names with newline characters", async () => {
|
||||
await expect(
|
||||
createUser({
|
||||
email: mockUser.email,
|
||||
name: "Lastname,Firstname\n(DEPT) COMPANY-CITY",
|
||||
locale: mockUser.locale,
|
||||
})
|
||||
).rejects.toThrow(ValidationError);
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when email already exists", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
|
||||
@@ -10,7 +10,6 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
|
||||
SPAM_PROTECTION: "spam-protection",
|
||||
CONTACTS: "contacts",
|
||||
AI_SMART_TOOLS: "ai-smart-tools",
|
||||
AI_DATA_ANALYSIS: "ai-data-analysis",
|
||||
FEEDBACK_DIRECTORIES: "feedback-directories",
|
||||
DASHBOARDS: "dashboards",
|
||||
} as const;
|
||||
|
||||
@@ -81,7 +81,7 @@ export const translateSurveyFieldsAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
await assertOrganizationAIConfigured(organizationId, "smartTools");
|
||||
await assertOrganizationAIConfigured(organizationId);
|
||||
|
||||
const translations = await translateFields({
|
||||
organizationId,
|
||||
|
||||
@@ -40,7 +40,6 @@ Rules:
|
||||
|
||||
const result = await generateOrganizationAIText({
|
||||
organizationId,
|
||||
capability: "smartTools",
|
||||
system: systemPrompt,
|
||||
prompt: JSON.stringify(items),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import cubejs, { type Query } from "@cubejs-client/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { expandPresetDateRanges } from "@/modules/ee/analysis/lib/date-presets";
|
||||
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { type TCubeQuerySource, getCubeApiConfig } from "./cube-config";
|
||||
@@ -89,7 +90,7 @@ export async function executeTenantScopedQuery(input: TScopedCubeQueryInput) {
|
||||
|
||||
try {
|
||||
const client = cubejs(token, { apiUrl });
|
||||
const resultSet = await client.load(input.query as Query);
|
||||
const resultSet = await client.load(expandPresetDateRanges(input.query) as Query);
|
||||
const result = resultSet.tablePivot();
|
||||
queueCubeQueryAuditEvent({ input, requestId, status: "success" });
|
||||
return result;
|
||||
|
||||
@@ -363,8 +363,7 @@ export const generateAIChartAction = authenticatedActionClient
|
||||
|
||||
await checkDashboardsEnabled(organizationId);
|
||||
|
||||
// Verify AI is entitled, enabled at org level, and configured at instance level
|
||||
await assertOrganizationAIConfigured(organizationId, "dataAnalysis");
|
||||
await assertOrganizationAIConfigured(organizationId);
|
||||
|
||||
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
|
||||
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { use } from "react";
|
||||
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -87,7 +87,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
|
||||
getConnectorsWithMappings(workspaceId),
|
||||
getOrganizationAIConfig(organization.id),
|
||||
]);
|
||||
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
|
||||
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
|
||||
const isAIAvailable = !aiUnavailableReason;
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
|
||||
@@ -83,6 +83,24 @@ export function TimeDimensionPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateRangeTypeChange = (value: "preset" | "custom") => {
|
||||
setDateRangeType(value);
|
||||
if (!timeDimension) return;
|
||||
|
||||
if (value === "preset") {
|
||||
const nextPreset = presetValue || "last 30 days";
|
||||
if (!presetValue) setPresetValue(nextPreset);
|
||||
onTimeDimensionChange({ ...timeDimension, dateRange: nextPreset });
|
||||
return;
|
||||
}
|
||||
|
||||
const start = customStartDate ?? new Date();
|
||||
const end = customEndDate ?? start;
|
||||
if (!customStartDate) setCustomStartDate(start);
|
||||
if (!customEndDate) setCustomEndDate(end);
|
||||
onTimeDimensionChange({ ...timeDimension, dateRange: [start, end] });
|
||||
};
|
||||
|
||||
if (!timeDimension) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -150,7 +168,7 @@ export function TimeDimensionPanel({
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={dateRangeType}
|
||||
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
|
||||
onValueChange={(value) => handleDateRangeTypeChange(value as "preset" | "custom")}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { executeTenantScopedQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
@@ -99,7 +99,7 @@ export async function DashboardDetailPage({
|
||||
getFeedbackDirectoriesByWorkspaceId(workspaceId),
|
||||
getOrganizationAIConfig(organization.id),
|
||||
]);
|
||||
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
|
||||
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
|
||||
const isAIAvailable = !aiUnavailableReason;
|
||||
|
||||
let dashboard;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { expandPresetDateRanges } from "./date-presets";
|
||||
|
||||
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
|
||||
});
|
||||
|
||||
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
|
||||
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
|
||||
|
||||
describe("expandPresetDateRanges", () => {
|
||||
test("includes today for 'last 7 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("includes today for 'last 30 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'today' to today..today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'yesterday' to yesterday..yesterday", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
|
||||
});
|
||||
|
||||
test("'this month' runs from the 1st through today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'last month' is the full previous calendar month", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
|
||||
});
|
||||
|
||||
test("'last month' handles year rollover", () => {
|
||||
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
|
||||
});
|
||||
|
||||
test("'this quarter' starts at the first day of the calendar quarter", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'this year' starts on Jan 1", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("leaves explicit [start, end] tuple unchanged", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
|
||||
});
|
||||
|
||||
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
|
||||
});
|
||||
|
||||
test("returns input unchanged when there are no time dimensions", () => {
|
||||
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
|
||||
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
|
||||
});
|
||||
|
||||
test("preserves other timeDimension fields (granularity, dimension)", () => {
|
||||
const q: TChartQuery = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [
|
||||
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
|
||||
],
|
||||
};
|
||||
const result = expandPresetDateRanges(q, NOW);
|
||||
expect(result.timeDimensions?.[0]).toMatchObject({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: ["2026-05-15", "2026-05-21"],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mutate the input query", () => {
|
||||
const q = queryWithDateRange("last 7 days");
|
||||
const before = JSON.stringify(q);
|
||||
expandPresetDateRanges(q, NOW);
|
||||
expect(JSON.stringify(q)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { addDays, formatDate, startOfDay, startOfMonth, startOfQuarter, startOfYear } from "date-fns";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
|
||||
// Cube's native "last N days" / "this month" / etc. strings exclude today; we expand them
|
||||
// to explicit inclusive ranges so charts behave like every other analytics tool (GA, Mixpanel,
|
||||
// PostHog, ...) and include the current partial day.
|
||||
const PRESET_RESOLVERS: Record<string, (now: Date) => [Date, Date]> = {
|
||||
today: (now) => [startOfDay(now), startOfDay(now)],
|
||||
yesterday: (now) => [addDays(startOfDay(now), -1), addDays(startOfDay(now), -1)],
|
||||
"last 7 days": (now) => [addDays(startOfDay(now), -6), startOfDay(now)],
|
||||
"last 30 days": (now) => [addDays(startOfDay(now), -29), startOfDay(now)],
|
||||
"this month": (now) => [startOfMonth(now), startOfDay(now)],
|
||||
"last month": (now) => {
|
||||
const firstOfThisMonth = startOfMonth(now);
|
||||
const lastOfLastMonth = addDays(firstOfThisMonth, -1);
|
||||
return [startOfMonth(lastOfLastMonth), lastOfLastMonth];
|
||||
},
|
||||
"this quarter": (now) => [startOfQuarter(now), startOfDay(now)],
|
||||
"this year": (now) => [startOfYear(now), startOfDay(now)],
|
||||
};
|
||||
|
||||
export const expandPresetDateRanges = (query: TChartQuery, now: Date = new Date()): TChartQuery => {
|
||||
if (!query.timeDimensions?.length) return query;
|
||||
|
||||
const expanded = query.timeDimensions.map((td) => {
|
||||
if (typeof td.dateRange !== "string") return td;
|
||||
const resolver = PRESET_RESOLVERS[td.dateRange.toLowerCase().trim()];
|
||||
if (!resolver) return td;
|
||||
const [start, end] = resolver(now);
|
||||
return {
|
||||
...td,
|
||||
dateRange: [formatDate(start, "yyyy-MM-dd"), formatDate(end, "yyyy-MM-dd")] as [string, string],
|
||||
};
|
||||
});
|
||||
|
||||
return { ...query, timeDimensions: expanded };
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user