Compare commits

..

69 Commits

Author SHA1 Message Date
Tiago Farto
3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto
83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto
2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto
54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto
1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto
197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
Tiago Farto
4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto
92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Tiago Farto
ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Tiago Farto
8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto
a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto
d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Tiago Farto
03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto
cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto
a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Tiago Farto
e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto
581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto
5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Tiago Farto
ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto
597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto
3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto
e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto
84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto
784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto
5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto
65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto
bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto
22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto
a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto
c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto
dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto
9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto
29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto
f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
Dhruwang Jariwala
a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey
029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Matti Nannt
81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
bharath kumar
d197271771 fix(web): add <noscript> message for when JS is disabled (#7455) (#7459)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 12:35:29 +00:00
Dhruwang Jariwala
37f652c70e fix: prevent session expiry during active use (#7558) 2026-03-23 10:44:55 +00:00
Matti Nannt
645f0ab0d1 fix: resolve remaining dependabot alerts (#7561) 2026-03-23 09:59:01 +00:00
Johannes
389a7d9e7b feat: enhance segment activity summary and settings in segment modal (#7553)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-23 08:39:10 +00:00
Tiago
c4cf468c7e fix: localize survey and app date rendering (#7473)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 07:23:07 +00:00
Johannes
cbc3e923e4 fix: segment targeting "isNotIn" didnt work (#7550) 2026-03-23 05:22:19 +00:00
Tiago
a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes
e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt
998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr
13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala
0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey
0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago
d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala
e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala
c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago
78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala
95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala
136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey
eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey
0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr
540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala
2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Dhruwang Jariwala
633bf18204 fix: auto-expand multi-language card when toggle is enabled (#7504)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:18:35 +00:00
Balázs Úr
9a6cbd05b6 fix: mark various strings as translatable (#7338)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 11:30:38 +00:00
Johannes
94b0248075 fix: only allow URL in exact match URL (#7505)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 07:20:14 +00:00
Johannes
082de1042d feat: add validation for custom survey closed message heading (#7502) 2026-03-18 06:40:57 +00:00
Johannes
8c19587baa fix: ensure at least one filter is required for segments (#7503)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:39:58 +00:00
Anshuman Pandey
433750d3fe fix: removes pino pretty from edge runtime (#7510) 2026-03-18 06:32:55 +00:00
Johannes
61befd5ffd feat: add enterprise license features table (#7492)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:14:40 +00:00
Dhruwang Jariwala
1e7817fb69 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-17 15:33:44 +00:00
Anshuman Pandey
f250bc7e88 fix: fixes race between setUserId and trigger (#7498)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 08:57:07 +00:00
Santosh
c7faa29437 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7409)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 05:36:58 +00:00
720 changed files with 24193 additions and 10190 deletions

View File

@@ -32,12 +32,31 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
##############
# DATABASE #
##############
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -231,4 +250,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGO_API_KEY=your_api_key_here

View File

@@ -1,14 +1,10 @@
name: Build & Cache Web App
on:
workflow_dispatch:
inputs:
e2e_testing_mode:
description: "Set E2E Testing Mode"
required: false
default: "0"
inputs:
e2e_testing_mode:
description: "Set E2E Testing Mode"
required: false
default: "0"
turbo_token:
description: "Turborepo token"
required: false

View File

@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
@@ -14,7 +14,7 @@ interface ConnectWithFormbricksProps {
environment: TEnvironment;
publicDomain: string;
appSetupCompleted: boolean;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
}
export const ConnectWithFormbricks = ({

View File

@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -19,7 +19,7 @@ const tabs = [
interface OnboardingSetupInstructionsProps {
environmentId: string;
publicDomain: string;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean;
}

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -23,12 +23,12 @@ const Page = async (props: ConnectPageProps) => {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;
const channel = workspace.config.channel || null;
const publicDomain = getPublicDomain();

View File

@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
@@ -16,12 +16,12 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps {
project: TProject;
workspace: TWorkspace;
user: TUser;
environmentId: string;
}
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -48,7 +48,7 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project);
const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate);
};

View File

@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
const mockWorkspace: TWorkspace = {
id: "workspace1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
name: "Test Workspace",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
@@ -32,7 +32,7 @@ const mockProject: TProject = {
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
name: "$[workspaceName] Survey",
blocks: [
{
id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
headline: { default: "$[workspaceName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Workspace Survey");
});
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});

View File

@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
// replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
};

View File

@@ -3,9 +3,9 @@ import { getServerSession } from "next-auth";
import Link from "next/link";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getUserWorkspaces, getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
@@ -36,18 +36,18 @@ const Page = async (props: XMTemplatePageProps) => {
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);
const workspaces = await getUserWorkspaces(session.user.id, organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<XMTemplateList workspace={workspace} user={user} environmentId={environment.id} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -2,7 +2,7 @@ import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: {
@@ -24,11 +24,11 @@ const LandingLayout = async (props: {
return notFound();
}
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) {
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
if (workspaces.length !== 0) {
const firstWorkspace = workspaces[0];
const environments = await getEnvironments(firstWorkspace.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {

View File

@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -34,12 +34,12 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<WorkspaceAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}

View File

@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: {
const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
);
};
export default ProjectOnboardingLayout;
export default WorkspaceOnboardingLayout;

View File

@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -3,10 +3,10 @@ import { notFound, redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
@@ -31,12 +31,12 @@ const OnboardingLayout = async (props: {
throw new Error(t("common.organization_not_found"));
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id),
getOrganizationWorkspacesCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`);
}

View File

@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
TProjectMode,
TProjectUpdateInput,
ZProjectUpdateInput,
} from "@formbricks/types/project";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
TWorkspaceUpdateInput,
ZWorkspaceUpdateInput,
} from "@formbricks/types/workspace";
import { createWorkspaceAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps {
interface WorkspaceSettingsProps {
organizationId: string;
projectMode: TProjectMode;
channel: TProjectConfigChannel;
industry: TProjectConfigIndustry;
workspaceMode: TWorkspaceMode;
channel: TWorkspaceConfigChannel;
industry: TWorkspaceConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
userWorkspacesCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
export const WorkspaceSettings = ({
organizationId,
projectMode,
workspaceMode,
channel,
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
userWorkspacesCount,
publicDomain,
}: ProjectSettingsProps) => {
}: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
@@ -71,7 +71,7 @@ export const ProjectSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
const createWorkspaceResponse = await createWorkspaceAction({
organizationId,
data: {
...data,
@@ -81,14 +81,14 @@ export const ProjectSettings = ({
},
});
if (createProjectResponse?.data) {
if (createWorkspaceResponse?.data) {
// get production environment
const productionEnvironment = createProjectResponse.data.environments.find(
(environment) => environment.type === "production"
const productionEnvironment = createWorkspaceResponse.data.environments.find(
(environment: { type: string }) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
// Remove filters when creating a new workspace
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
}
@@ -96,11 +96,11 @@ export const ProjectSettings = ({
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (projectMode === "cx") {
} else if (workspaceMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProjectResponse);
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage);
}
} catch (error) {
@@ -109,15 +109,15 @@ export const ProjectSettings = ({
}
};
const form = useForm<TProjectUpdateInput>({
const form = useForm<TWorkspaceUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
resolver: zodResolver(ZWorkspaceUpdateInput),
});
const projectName = form.watch("name");
const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -132,7 +132,7 @@ export const ProjectSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
@@ -184,7 +184,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField
control={form.control}
name="teamIds"
@@ -242,7 +242,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"

View File

@@ -1,30 +1,34 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ProjectSettingsPageProps {
interface WorkspaceSettingsPageProps {
params: Promise<{
organizationId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
mode?: TProjectMode;
channel?: TWorkspaceConfigChannel;
industry?: TWorkspaceConfigIndustry;
mode?: TWorkspaceMode;
}>;
}
const Page = async (props: ProjectSettingsPageProps) => {
const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -38,7 +42,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
@@ -56,18 +60,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
<WorkspaceSettings
organizationId={params.organizationId}
projectMode={mode}
workspaceMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
userWorkspacesCount={workspaces.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -3,29 +3,29 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getOrganizationWorkspacesLimit,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateProjectAction = z.object({
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZProjectUpdateInput,
data: ZWorkspaceUpdateInput,
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
@@ -36,7 +36,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
schema: ZWorkspaceUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -49,10 +49,10 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
@@ -64,7 +64,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
@@ -77,9 +77,9 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
ctx.auditLoggingCtx.workspaceId = workspace.id;
ctx.auditLoggingCtx.newObject = workspace;
return workspace;
})
);
@@ -108,16 +108,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
return await getOrganizationsByUserId(ctx.user.id);
});
const ZGetProjectsForSwitcherAction = z.object({
const ZGetWorkspacesForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
});
/**
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the project switcher.
* Called on-demand when user opens the workspace switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction)
export const getWorkspacesForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetWorkspacesForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -130,11 +130,11 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
],
});
// Need membership for getProjectsByUserId (1 DB query)
// Need membership for getWorkspacesByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
return await getWorkspacesByUserId(ctx.user.id, membership);
});

View File

@@ -4,7 +4,7 @@ import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
@@ -24,10 +24,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
workspace, // Current workspace details
environments, // All workspace environments (for environment switcher)
isAccessControlAllowed,
projectPermission,
workspacePermission,
license,
responseCount,
} = layoutData;
@@ -37,11 +37,11 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
if (isMember && !projectPermission) {
// Validate that workspace permission exists for members
if (isMember && !workspacePermission) {
throw new Error(t("common.workspace_permission_not_found"));
}
@@ -69,7 +69,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment={environment}
organization={organization}
user={user}
project={{ id: project.id, name: project.name }}
workspace={{ id: workspace.id, name: workspace.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
@@ -79,9 +79,9 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
currentProjectId={project.id}
currentWorkspaceId={workspace.id}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}

View File

@@ -29,7 +29,6 @@ import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
@@ -38,13 +37,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
import packageJson from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
project: { id: string; name: string };
workspace: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
@@ -55,7 +55,7 @@ export const MainNavigation = ({
environment,
organization,
user,
project,
workspace,
membershipRole,
isFormbricksCloud,
isDevelopment,
@@ -92,7 +92,7 @@ export const MainNavigation = ({
}, [isCollapsed]);
useEffect(() => {
// Auto collapse project navbar on org and account settings
// Auto collapse workspace navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
@@ -186,7 +186,7 @@ export const MainNavigation = ({
return (
<>
{project && (
{workspace && (
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",

View File

@@ -2,16 +2,16 @@
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
currentProjectId: string;
currentWorkspaceId: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
@@ -22,9 +22,9 @@ interface TopControlBarProps {
export const TopControlBar = ({
environments,
currentOrganizationId,
currentProjectId,
currentWorkspaceId,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
@@ -38,13 +38,13 @@ export const TopControlBar = ({
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<ProjectAndOrgSwitch
<WorkspaceAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
currentProjectId={currentProjectId}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}

View File

@@ -1,13 +1,13 @@
import Link from "next/link";
import { ReactNode } from "react";
interface ProjectNavItemProps {
interface WorkspaceNavItemProps {
href: string;
children: ReactNode;
isActive: boolean;
}
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
const activeClass = "bg-slate-50 font-semibold";
const inactiveClass = "hover:bg-slate-50";

View File

@@ -138,6 +138,12 @@ export const OrganizationBreadcrumb = ({
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),

View File

@@ -2,18 +2,18 @@
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { WorkspaceBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/workspace-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
interface WorkspaceAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentWorkspaceId?: string;
currentWorkspaceName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
@@ -21,21 +21,21 @@ interface ProjectAndOrgSwitchProps {
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
export const WorkspaceAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentWorkspaceId,
currentWorkspaceName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
}: ProjectAndOrgSwitchProps) => {
}: WorkspaceAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -51,14 +51,14 @@ export const ProjectAndOrgSwitch = ({
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
{currentWorkspaceId && currentEnvironmentId && (
<WorkspaceBreadcrumb
currentWorkspaceId={currentWorkspaceId}
currentWorkspaceName={currentWorkspaceName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}

View File

@@ -6,10 +6,8 @@ import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getWorkspacesForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
@@ -20,13 +18,15 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { useWorkspace } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
interface WorkspaceBreadcrumbProps {
currentWorkspaceId: string;
currentWorkspaceName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
@@ -35,7 +35,7 @@ interface ProjectBreadcrumbProps {
isEnvironmentBreadcrumbVisible: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
@@ -45,59 +45,59 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
return pattern.test(pathname);
};
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
export const WorkspaceBreadcrumb = ({
currentWorkspaceId,
currentWorkspaceName,
isOwnerOrManager,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
}: ProjectBreadcrumbProps) => {
}: WorkspaceBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Get current workspace name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
const { workspace: currentWorkspace } = useWorkspace();
const workspaceName = currentWorkspace?.name || currentWorkspaceName || "";
// Lazy-load projects when dropdown opens
// Lazy-load workspaces when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
if (isWorkspaceDropdownOpen && workspaces.length === 0 && !isLoadingWorkspaces && !loadError) {
setIsLoadingWorkspaces(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
getWorkspacesForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
// Sort workspaces by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
setWorkspaces(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
logger.error(error, "Failed to load workspaces");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
setIsLoadingWorkspaces(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
const projectSettings = [
const workspaceSettings = [
{
id: "general",
label: t("common.general"),
@@ -135,29 +135,29 @@ export const ProjectBreadcrumb = ({
},
];
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
if (!currentWorkspace) {
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === currentWorkspaceId) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
router.push(`/workspaces/${workspaceId}/`);
});
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
const handleAddWorkspace = () => {
if (workspaces.length >= organizationWorkspacesLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
setOpenCreateWorkspaceModal(true);
};
const handleProjectSettingsNavigation = (settingId: string) => {
const handleWorkspaceSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
@@ -191,17 +191,17 @@ export const ProjectBreadcrumb = ({
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}>
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
id="workspaceDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
<span>{workspaceName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -214,32 +214,32 @@ export const ProjectBreadcrumb = ({
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
{isLoadingWorkspaces && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
{!isLoadingWorkspaces && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
setWorkspaces([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
{!isLoadingWorkspaces && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
checked={proj.id === currentWorkspaceId}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
@@ -249,7 +249,7 @@ export const ProjectBreadcrumb = ({
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
onClick={handleAddWorkspace}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
@@ -263,11 +263,11 @@ export const ProjectBreadcrumb = ({
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
{workspaceSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
checked={isActiveWorkspaceSetting(pathname, setting.id)}
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
@@ -277,17 +277,17 @@ export const ProjectBreadcrumb = ({
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
<WorkspaceLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
workspaceLimit={organizationWorkspacesLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
{openCreateWorkspaceModal && (
<CreateWorkspaceModal
open={openCreateWorkspaceModal}
setOpen={setOpenCreateWorkspaceModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>

View File

@@ -3,11 +3,11 @@
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TWorkspace } from "@formbricks/types/workspace";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
workspace: TWorkspace;
organization: TOrganization;
organizationId: string;
}
@@ -22,12 +22,12 @@ export const useEnvironment = () => {
return context;
};
export const useProject = () => {
export const useWorkspace = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
return { workspace: null };
}
return { project: context.project };
return { workspace: context.workspace };
};
export const useOrganization = () => {
@@ -41,25 +41,25 @@ export const useOrganization = () => {
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
workspace: TWorkspace;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
workspace,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
workspace,
organization,
organizationId: project.organizationId,
organizationId: workspace.organizationId,
}),
[environment, project, organization]
[environment, workspace, organization]
);
return (

View File

@@ -27,7 +27,7 @@ const EnvLayout = async (props: {
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
workspace={layoutData.workspace}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>

View File

@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { getProjectsByUserId } from "./project";
import { getWorkspacesByUserId } from "./workspace";
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
workspace: {
findMany: vi.fn(),
},
},
}));
describe("Project", () => {
describe("getUserProjects", () => {
describe("Workspace", () => {
describe("getUserWorkspaces", () => {
const mockAdminMembership: TMembership = {
role: "manager",
organizationId: "org1",
@@ -29,17 +29,17 @@ describe("Project", () => {
accepted: true,
};
test("should return projects for admin role", async () => {
const mockProjects = [
{ id: "project1", name: "Project 1" },
{ id: "project2", name: "Project 2" },
test("should return workspaces for admin role", async () => {
const mockWorkspaces = [
{ id: "workspace1", name: "Workspace 1" },
{ id: "workspace2", name: "Workspace 2" },
];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockAdminMembership);
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
@@ -48,20 +48,20 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
test("should return projects for member role with team restrictions", async () => {
const mockProjects = [{ id: "project1", name: "Project 1" }];
test("should return workspaces for member role with team restrictions", async () => {
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockMemberMembership);
const result = await getWorkspacesByUserId("user1", mockMemberMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
projectTeams: {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -78,13 +78,13 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
test("should return empty array when no projects found", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
test("should return empty array when no workspaces found", async () => {
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]);
const result = await getProjectsByUserId("user1", mockAdminMembership);
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
expect(result).toEqual([]);
});
@@ -95,27 +95,27 @@ describe("Project", () => {
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(
new DatabaseError("Database error")
);
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
});
test("should validate inputs correctly", async () => {
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
});
test("should validate membership input correctly", async () => {
const invalidMembership = {} as TMembership;
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow();
});
test("should handle owner role like manager", async () => {
@@ -126,12 +126,12 @@ describe("Project", () => {
accepted: true,
};
const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockOwnerMembership);
const result = await getWorkspacesByUserId("user1", mockOwnerMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
@@ -140,7 +140,7 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
});
});

View File

@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { validateInputs } from "@/lib/utils/validate";
export const getProjectsByUserId = reactCache(
export const getWorkspacesByUserId = reactCache(
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
validateInputs([userId, ZString], [orgMembership, ZMembership]);
let projectWhereClause: Prisma.ProjectWhereInput = {};
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {};
if (orgMembership.role === "member") {
projectWhereClause = {
projectTeams: {
workspaceWhereClause = {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -29,17 +29,17 @@ export const getProjectsByUserId = reactCache(
}
try {
const projects = await prisma.project.findMany({
const workspaces = await prisma.workspace.findMany({
where: {
organizationId: orgMembership.organizationId,
...projectWhereClause,
...workspaceWhereClause,
},
select: {
id: true,
name: true,
},
});
return projects;
return workspaces;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -13,9 +13,9 @@ const AccountSettingsLayout = async (props: {
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
const [organization, workspace, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
@@ -23,7 +23,7 @@ const AccountSettingsLayout = async (props: {
throw new Error(t("common.organization_not_found"));
}
if (!project) {
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}

View File

@@ -40,7 +40,7 @@ export const EditAlerts = ({
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
</p>
<NotificationSwitch
surveyOrProjectOrOrganizationId={membership.organization.id}
surveyOrWorkspaceOrOrganizationId={membership.organization.id}
notificationSettings={user.notificationSettings!}
notificationType={"unsubscribedOrganizationIds"}
autoDisableNotificationType={autoDisableNotificationType}
@@ -66,13 +66,13 @@ export const EditAlerts = ({
</TooltipProvider>
</div>
{membership.organization.projects.some((project) =>
project.environments.some((environment) => environment.surveys.length > 0)
{membership.organization.workspaces.some((workspace) =>
workspace.environments.some((environment) => environment.surveys.length > 0)
) ? (
<div className="grid-cols-8 space-y-1 p-2">
{membership.organization.projects.map((project) => (
<div key={project.id}>
{project.environments.map((environment) => (
{membership.organization.workspaces.map((workspace) => (
<div key={workspace.id}>
{workspace.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
@@ -80,11 +80,11 @@ export const EditAlerts = ({
key={survey.name}>
<div className="col-span-2 text-left">
<div className="font-medium text-slate-900">{survey.name}</div>
<div className="text-xs text-slate-400">{project.name}</div>
<div className="text-xs text-slate-400">{workspace.name}</div>
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrProjectOrOrganizationId={survey.id}
surveyOrWorkspaceOrOrganizationId={survey.id}
notificationSettings={user.notificationSettings!}
notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType}

View File

@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string;
surveyOrWorkspaceOrOrganizationId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string;
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
}
export const NotificationSwitch = ({
surveyOrProjectOrOrganizationId,
surveyOrWorkspaceOrOrganizationId,
notificationSettings,
notificationType,
autoDisableNotificationType,
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
const router = useRouter();
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -38,21 +38,21 @@ export const NotificationSwitch = ({
let updatedNotificationSettings = { ...notificationSettings };
if (notificationType === "unsubscribedOrganizationIds") {
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) {
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
(id) => id !== surveyOrProjectOrOrganizationId
(id) => id !== surveyOrWorkspaceOrOrganizationId
);
} else {
updatedNotificationSettings.unsubscribedOrganizationIds = [
...unsubscribedOrganizationIds,
surveyOrProjectOrOrganizationId,
surveyOrWorkspaceOrOrganizationId,
];
}
} else {
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
[surveyOrWorkspaceOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId],
};
}
@@ -76,12 +76,12 @@ export const NotificationSwitch = ({
useEffect(() => {
if (
autoDisableNotificationType &&
autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId &&
isChecked
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(
@@ -95,7 +95,9 @@ export const NotificationSwitch = ({
break;
case "unsubscribedOrganizationIds":
if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
if (
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
) {
handleSwitchChange();
toast.success(
t(

View File

@@ -21,9 +21,9 @@ const setCompleteNotificationSettings = (
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
for (const project of membership.organization.projects) {
for (const workspace of membership.organization.workspaces) {
// set default values for alerts
for (const environment of project.environments) {
for (const environment of workspace.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
@@ -46,17 +46,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
},
OR: [
{
// Fetch all projects if user role is owner or manager
// Fetch all workspaces if user role is owner or manager
role: {
in: ["owner", "manager"],
},
},
{
// Filter projects based on team membership if user is not owner or manager
// Filter workspaces based on team membership if user is not owner or manager
organization: {
projects: {
workspaces: {
some: {
projectTeams: {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -78,12 +78,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
select: {
id: true,
name: true,
projects: {
workspaces: {
// Apply conditional filtering based on user's role
where: {
OR: [
{
// Fetch all projects if user is owner or manager
// Fetch all workspaces if user is owner or manager
organization: {
memberships: {
some: {
@@ -96,8 +96,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
},
},
{
// Only include projects accessible through teams if user is not owner or manager
projectTeams: {
// Only include workspaces accessible through teams if user is not owner or manager
workspaceTeams: {
some: {
team: {
teamUsers: {

View File

@@ -4,7 +4,7 @@ export interface Membership {
organization: {
id: string;
name: string;
projects: {
workspaces: {
id: string;
name: string;
environments: {

View File

@@ -40,6 +40,13 @@ export const OrganizationSettingsNavbar = ({
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${environmentId}/settings/feedback-record-directories`,
current: pathname?.includes("/feedback-record-directories"),
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),

View File

@@ -14,7 +14,7 @@ interface SurveyWithSlug {
environment: {
id: string;
type: "production" | "development";
project: {
workspace: {
id: string;
name: string;
};
@@ -40,7 +40,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
},
{
label: t("environments.settings.domain.workspace"),
key: "project",
key: "workspace",
},
{
label: t("environments.settings.domain.pretty_url"),
@@ -81,7 +81,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
{survey.name}
</Link>
</TableCell>
<TableCell>{survey.environment.project.name}</TableCell>
<TableCell>{survey.environment.workspace.name}</TableCell>
<TableCell>
<IdBadge id={survey.slug ?? ""} />
</TableCell>

View File

@@ -0,0 +1,146 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "workspaces",
labelKey: t("environments.settings.enterprise.license_feature_workspaces"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -15,6 +16,7 @@ import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps {
status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date;
environmentId: string;
}
@@ -44,10 +46,12 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({
status,
lastChecked,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -92,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div>
<Button
type="button"
@@ -118,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
year: "numeric",
month: "short",
day: "numeric",

View File

@@ -10,6 +10,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
@@ -93,15 +94,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
<>
<EnterpriseLicenseStatus
status={licenseState.status}
lastChecked={licenseState.lastChecked}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : (
<div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">

View File

@@ -0,0 +1 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";

View File

@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -10,9 +10,9 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
const [organization, workspace, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
@@ -20,7 +20,7 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
throw new Error(t("common.organization_not_found"));
}
if (!project) {
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}

View File

@@ -8,7 +8,7 @@ import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { getSurveySummary } from "./summary/lib/surveySummary";
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
@@ -36,9 +36,9 @@ export const getResponsesAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -70,9 +70,9 @@ export const getSurveySummaryAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -98,9 +98,9 @@ export const getResponseCountAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -126,9 +126,9 @@ export const getDisplaysWithContactAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});

View File

@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
);
// Save settings to localStorage when they change

View File

@@ -8,10 +8,11 @@ import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -34,6 +35,7 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -167,6 +169,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -218,6 +221,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt",
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
},
};

View File

@@ -7,7 +7,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -23,13 +22,12 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
@@ -86,7 +84,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
locale={user.locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}

View File

@@ -8,7 +8,7 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -35,9 +35,9 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -64,29 +64,31 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
workspaceId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
workspaceId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
@@ -123,9 +125,9 @@ export const getEmailHtmlAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -158,8 +160,8 @@ export const generatePersonalLinksAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -232,8 +234,8 @@ export const updateSingleUseLinksAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],

View File

@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
return (
formattedDate ??
t("common.invalid_date_with_value", {
value,
})
);
};
return (
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
<div className="px-4 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>

View File

@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { workspace } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,8 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
workspaceId: workspace.id,
});
if (result?.data) {
toast.success(
@@ -213,7 +212,7 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
workspaceCustomScripts={workspace.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />

View File

@@ -53,7 +53,7 @@ interface ShareSurveyModalProps {
isFormbricksCloud: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
workspaceCustomScripts?: string | null;
}
export const ShareSurveyModal = ({
@@ -68,7 +68,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
isReadOnly,
isStorageConfigured,
projectCustomScripts,
workspaceCustomScripts,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -193,7 +193,7 @@ export const ShareSurveyModal = ({
title: t("environments.surveys.share.custom_html.nav_title"),
description: t("environments.surveys.share.custom_html.description"),
componentType: CustomHtmlTab,
componentProps: { projectCustomScripts, isReadOnly },
componentProps: { workspaceCustomScripts, isReadOnly },
},
];
@@ -216,7 +216,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
email,
isStorageConfigured,
projectCustomScripts,
workspaceCustomScripts,
]);
const getDefaultActiveId = useCallback(() => {

View File

@@ -88,7 +88,7 @@ const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayC
export const AppTab = () => {
const { t } = useTranslation();
const { environment, project } = useEnvironment();
const { environment, workspace } = useEnvironment();
const { survey } = useSurvey();
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
@@ -98,8 +98,8 @@ export const AppTab = () => {
if (survey.recontactDays !== null) {
return formatRecontactDaysString(survey.recontactDays, t);
}
if (project.recontactDays !== null) {
return formatRecontactDaysString(project.recontactDays, t);
if (workspace.recontactDays !== null) {
return formatRecontactDaysString(workspace.recontactDays, t);
}
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
};

View File

@@ -22,7 +22,7 @@ import {
import { TabToggle } from "@/modules/ui/components/tab-toggle";
interface CustomHtmlTabProps {
projectCustomScripts: string | null | undefined;
workspaceCustomScripts: string | null | undefined;
isReadOnly: boolean;
}
@@ -31,7 +31,7 @@ interface CustomHtmlFormData {
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
}
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
const { t } = useTranslation();
const { survey } = useSurvey();
const [isSaving, setIsSaving] = useState(false);
@@ -101,18 +101,18 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
</div>
{/* Workspace Scripts Preview */}
{projectCustomScripts && (
{workspaceCustomScripts && (
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{projectCustomScripts}
{workspaceCustomScripts}
</pre>
</div>
</div>
)}
{!projectCustomScripts && (
{!workspaceCustomScripts && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
<p className="text-sm text-slate-500">
{t("environments.surveys.share.custom_html.no_workspace_scripts")}

View File

@@ -1,7 +1,7 @@
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
@@ -11,12 +11,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
if (!survey) {
throw new Error("Survey not found");
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
const workspace = await getWorkspaceByEnvironmentId(survey.environmentId);
if (!workspace) {
throw new Error("Workspace not found");
}
const styling = getStyling(project, survey);
const styling = getStyling(workspace, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =

View File

@@ -2,10 +2,10 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TLanguage } from "@formbricks/types/workspace";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -100,7 +100,7 @@ const mockBaseSurvey: TSurvey = {
createdBy: "user_123",
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
workspaceOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],

View File

@@ -2,21 +2,16 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
@@ -37,9 +32,9 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -75,9 +70,9 @@ export const getSurveyFilterDataAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -97,68 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
Select,
SelectContent,
@@ -14,7 +15,6 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps {
environment: TEnvironment;

View File

@@ -1,3 +1,3 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;

View File

@@ -1,3 +1,3 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page";
export default AppConnectionPage;

View File

@@ -1,3 +1,3 @@
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
import { GeneralSettingsLoading } from "@/modules/workspaces/settings/general/loading";
export default GeneralSettingsLoading;

View File

@@ -1,3 +1,3 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page";
export default GeneralSettingsPage;

View File

@@ -9,8 +9,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
getWorkspaceIdFromEnvironmentId,
getWorkspaceIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -34,9 +34,9 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -66,8 +66,8 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],

View File

@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,11 +18,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -33,9 +34,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -52,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>

View File

@@ -10,7 +10,7 @@ import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/go
import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
@@ -28,8 +28,8 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
@@ -62,8 +62,8 @@ export const getSpreadsheetNameByIdAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],

View File

@@ -3,13 +3,14 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -21,19 +22,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -49,7 +48,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>

View File

@@ -3,6 +3,7 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET,
@@ -11,7 +12,7 @@ import {
} from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -28,18 +29,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration] = await Promise.all([
const [surveys, notionIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getUserLocale(session.user.id),
]);
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
@@ -56,7 +57,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</PageContentWrapper>
);

View File

@@ -16,10 +16,10 @@ import ZapierLogo from "@/images/zapier-small.png";
import { getIntegrations } from "@/lib/integration/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
const getStatusText = (count: number, t: TFunction, type: string) => {
if (count === 1) return `1 ${type}`;
@@ -210,7 +210,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
<WorkspaceConfigNavigation environmentId={params.environmentId} activeId="integrations" />
</PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
{integrationCards.map((card) => (

View File

@@ -5,7 +5,7 @@ import { ZId } from "@formbricks/types/common";
import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
const ZGetSlackChannelsAction = z.object({
environmentId: ZId,
@@ -23,8 +23,8 @@ export const getSlackChannelsAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],

View File

@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,15 +17,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration] = await Promise.all([
const [surveys, slackIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -41,7 +40,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>

View File

@@ -1,3 +1,3 @@
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
import { LanguagesLoading } from "@/modules/workspaces/settings/languages/loading";
export default LanguagesLoading;

View File

@@ -1,3 +1,3 @@
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
import { LanguagesPage } from "@/modules/workspaces/settings/languages/page";
export default LanguagesPage;

View File

@@ -1,4 +1,4 @@
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
import { WorkspaceSettingsLayout, metadata } from "@/modules/workspaces/settings/layout";
export { metadata };
export default ProjectSettingsLayout;
export default WorkspaceSettingsLayout;

View File

@@ -1,3 +1,3 @@
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
import { WorkspaceLookSettingsLoading } from "@/modules/workspaces/settings/look/loading";
export default ProjectLookSettingsLoading;
export default WorkspaceLookSettingsLoading;

View File

@@ -1,3 +1,3 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
import { WorkspaceLookSettingsPage } from "@/modules/workspaces/settings/look/page";
export default ProjectLookSettingsPage;
export default WorkspaceLookSettingsPage;

View File

@@ -1,3 +1,3 @@
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
import { WorkspaceSettingsPage } from "@/modules/workspaces/settings/page";
export default ProjectSettingsPage;
export default WorkspaceSettingsPage;

View File

@@ -1,3 +1,3 @@
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
import { TagsLoading } from "@/modules/workspaces/settings/tags/loading";
export default TagsLoading;

View File

@@ -1,3 +1,3 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
import { TagsPage } from "@/modules/workspaces/settings/tags/page";
export default TagsPage;

View File

@@ -1,3 +1,3 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
import { WorkspaceTeams } from "@/modules/ee/teams/workspace-teams/page";
export default ProjectTeams;
export default WorkspaceTeams;

View File

@@ -6,8 +6,10 @@ import {
CHATWOOT_WEBSITE_TOKEN,
IS_CHATWOOT_CONFIGURED,
POSTHOG_KEY,
SESSION_MAX_AGE,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
@@ -23,7 +25,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
}
return (
<>
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
<NoMobileOverlay />
{POSTHOG_KEY && user && (
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
@@ -39,7 +41,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
)}
<ToasterClient />
{children}
</>
</NextAuthProvider>
);
};

View File

@@ -6,7 +6,7 @@ import { hasOrganizationAccess } from "@/lib/auth";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (_: Request, context: { params: Promise<{ organizationId: string }> }) => {
@@ -22,14 +22,14 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organizationId);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
// redirect to first project's production environment
const projects = await getUserProjects(session.user.id, organizationId);
if (projects.length === 0) {
// redirect to first workspace's production environment
const workspaces = await getUserWorkspaces(session.user.id, organizationId);
if (workspaces.length === 0) {
return redirect(`/organizations/${organizationId}/landing`);
}
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
const firstWorkspace = workspaces[0];
const environments = await getEnvironments(firstWorkspace.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (!prodEnvironment) return notFound();

View File

@@ -3,22 +3,22 @@ import { notFound, redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { hasOrganizationAccess } from "@/lib/auth";
import { getEnvironments } from "@/lib/environment/service";
import { getProject } from "@/lib/project/service";
import { getWorkspace } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (_: Request, context: { params: Promise<{ projectId: string }> }) => {
export const GET = async (_: Request, context: { params: Promise<{ workspaceId: string }> }) => {
const params = await context?.params;
const projectId = params.projectId;
if (!projectId) return notFound();
const workspaceId = params.workspaceId;
if (!workspaceId) return notFound();
// check auth
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const project = await getProject(projectId);
if (!project) return notFound();
const hasAccess = await hasOrganizationAccess(session.user.id, project.organizationId);
const workspace = await getWorkspace(workspaceId);
if (!workspace) return notFound();
const hasAccess = await hasOrganizationAccess(session.user.id, workspace.organizationId);
if (!hasAccess) throw new AuthorizationError("Unauthorized");
// redirect to project's production environment
const environments = await getEnvironments(project.id);
// redirect to workspace's production environment
const environments = await getEnvironments(workspace.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (!prodEnvironment) return notFound();
return redirect(`/environments/${prodEnvironment.id}/`);

View File

@@ -1,268 +0,0 @@
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyContactInfoQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
export const mockEndingId1 = "mpkt4n5krsv2ulqetle7b9e7";
export const mockEndingId2 = "ge0h63htnmgq6kwx1suh9cyi";
export const mockResponseEmailFollowUp: TSurvey["followUps"][number] = {
id: "cm9gpuazd0002192z67olbfdt",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up",
trigger: {
type: "response",
properties: null,
},
action: {
type: "send-email",
properties: {
to: "vjniuob08ggl8dewl0hwed41",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockEndingFollowUp: TSurvey["followUps"][number] = {
id: "j0g23cue6eih6xs5m0m4cj50",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up",
trigger: {
type: "endings",
properties: {
endingIds: [mockEndingId1],
},
},
action: {
type: "send-email",
properties: {
to: "vjniuob08ggl8dewl0hwed41",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockDirectEmailFollowUp: TSurvey["followUps"][number] = {
id: "yyc5sq1fqofrsyw4viuypeku",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up 1",
trigger: {
type: "response",
properties: null,
},
action: {
type: "send-email",
properties: {
to: "direct@email.com",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockFollowUps: TSurvey["followUps"] = [mockDirectEmailFollowUp, mockResponseEmailFollowUp];
export const mockSurvey: TSurvey = {
id: "cm9gptbhg0000192zceq9ayuc",
createdAt: new Date(),
updatedAt: new Date(),
name: "Start from scratch",
type: "link",
environmentId: "cm98djl8e000919hpzi6a80zp",
createdBy: "cm98dg3xm000019hpubj39vfi",
status: "inProgress",
welcomeCard: {
subheader: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,
headline: {
default: "Welcome!",
},
buttonLabel: {
default: "Next",
},
timeToFinish: false,
showResponseCount: false,
},
questions: [
{
id: "vjniuob08ggl8dewl0hwed41",
type: "openText" as TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "What would you like to know?",
},
required: true,
charLimit: {},
inputType: "email",
longAnswer: false,
buttonLabel: {
default: "Next",
},
placeholder: {
default: "example@email.com",
},
},
],
endings: [
{
id: "gt1yoaeb5a3istszxqbl08mk",
type: "endScreen",
headline: {
default: "Thank you!",
},
subheader: {
default: "We appreciate your feedback.",
},
buttonLink: "https://formbricks.com",
buttonLabel: {
default: "Create your own Survey",
},
},
],
hiddenFields: {
enabled: true,
fieldIds: [],
},
variables: [],
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
recaptcha: null,
projectOverwrites: null,
styling: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,
isEncrypted: true,
},
pin: null,
showLanguageSwitch: null,
languages: [],
triggers: [],
segment: null,
followUps: mockFollowUps,
metadata: {},
blocks: [],
isCaptureIpEnabled: false,
slug: null,
};
export const mockContactQuestion: TSurveyContactInfoQuestion = {
id: "zyoobxyolyqj17bt1i4ofr37",
type: TSurveyQuestionTypeEnum.ContactInfo,
email: {
show: true,
required: true,
placeholder: {
default: "Email",
},
},
phone: {
show: true,
required: true,
placeholder: {
default: "Phone",
},
},
company: {
show: true,
required: true,
placeholder: {
default: "Company",
},
},
headline: {
default: "Contact Question",
},
lastName: {
show: true,
required: true,
placeholder: {
default: "Last Name",
},
},
required: true,
firstName: {
show: true,
required: true,
placeholder: {
default: "First Name",
},
},
buttonLabel: {
default: "Next",
},
backButtonLabel: {
default: "Back",
},
};
export const mockContactEmailFollowUp: TSurvey["followUps"][number] = {
...mockResponseEmailFollowUp,
action: {
...mockResponseEmailFollowUp.action,
properties: {
...mockResponseEmailFollowUp.action.properties,
to: mockContactQuestion.id,
},
},
};
export const mockSurveyWithContactQuestion: TSurvey = {
...mockSurvey,
questions: [mockContactQuestion],
followUps: [mockContactEmailFollowUp],
};
export const mockResponse: TResponse = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
variables: {},
language: "en",
data: {
["vjniuob08ggl8dewl0hwed41"]: "test@example.com",
},
contact: null,
contactAttributes: {},
meta: {},
finished: true,
singleUseId: null,
tags: [],
displayId: null,
};
export const mockResponseWithContactQuestion: TResponse = {
...mockResponse,
data: {
zyoobxyolyqj17bt1i4ofr37: ["test", "user1", "test@user1.com", "99999999999", "sampleCompany"],
},
};

View File

@@ -1,307 +0,0 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
// Check authentication
if (requestHeaders.get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const jsonInput = await request.json();
const convertedJsonInput = convertDatesInObject(
jsonInput,
new Set(["contactAttributes", "variables", "data", "meta"])
);
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
if (!inputValidation.success) {
logger.error(
{ error: inputValidation.error, url: request.url },
"Error in POST /api/(internal)/pipeline"
);
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, surveyId, event, response } = inputValidation.data;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", "Organization not found");
}
// Fetch survey for webhook payload
const survey = await getSurvey(surveyId);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.environmentId !== environmentId) {
logger.error(
{ url: request.url, surveyId, environmentId, surveyEnvironmentId: survey.environmentId },
`Survey ${surveyId} does not belong to environment ${environmentId}`
);
return responses.badRequestResponse("Survey not found in this environment");
}
// Fetch webhooks
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId,
triggers: { has: event },
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
},
});
return webhooks;
};
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
},
});
// Generate Standard Webhooks headers
const webhookMessageId = uuidv7();
const webhookTimestamp = Math.floor(Date.now() / 1000);
const requestHeaders: Record<string, string> = {
"content-type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
};
// Add signature if webhook has a secret configured
if (webhook.secret) {
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
webhookMessageId,
webhookTimestamp,
body,
webhook.secret
);
}
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
});
if (event === "responseFinished") {
// Fetch integrations and responseCount in parallel
const [integrations, responseCount] = await Promise.all([
getIntegrations(environmentId),
getResponseCountBySurveyId(surveyId),
]);
if (integrations.length > 0) {
await handleIntegrations(integrations, inputValidation.data, survey);
}
// Fetch users with notifications in a single query
// TODO: add cache for this query. Not possible at the moment since we can't get the membership cache by environmentId
const usersWithNotifications = await prisma.user.findMany({
where: {
memberships: {
some: {
organization: {
projects: {
some: {
environments: {
some: { id: environmentId },
},
},
},
},
},
},
OR: [
{
memberships: {
every: {
role: {
in: ["owner", "manager"],
},
},
},
},
{
teamUsers: {
some: {
team: {
projectTeams: {
some: {
project: {
environments: {
some: {
id: environmentId,
},
},
},
},
},
},
},
},
},
],
notificationSettings: {
path: ["alert", surveyId],
equals: true,
},
},
select: { email: true, locale: true },
});
if (survey.followUps?.length > 0) {
// send follow up emails
const followUpsResult = await sendFollowUpsForResponse(response.id);
if (!followUpsResult.ok) {
const { error: followUpsError } = followUpsResult;
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
}
}
}
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(
user.email,
user.locale,
environmentId,
survey,
response,
responseCount
).catch((error) => {
logger.error(
{ error, url: request.url, userEmail: user.email },
`Failed to send email to ${user.email}`
);
})
);
// Update survey status if necessary
if (survey.autoComplete && responseCount >= survey.autoComplete) {
let logStatus: TAuditStatus = "success";
try {
await updateSurvey({
...survey,
status: "completed",
});
} catch (error) {
logStatus = "failure";
logger.error(
{ error, url: request.url, surveyId },
`Failed to update survey ${surveyId} status to completed`
);
} finally {
await queueAuditEvent({
status: logStatus,
action: "updated",
targetType: "survey",
userId: UNKNOWN_DATA,
userType: "system",
targetId: survey.id,
organizationId: organization.id,
newObject: {
status: "completed",
},
});
}
}
// Await webhook and email promises with allSettled to prevent early rejection
const results = await Promise.allSettled([...webhookPromises, ...emailPromises]);
results.forEach((result) => {
if (result.status === "rejected") {
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
} else {
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
const results = await Promise.allSettled(webhookPromises);
results.forEach((result) => {
if (result.status === "rejected") {
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
}
if (event === "responseCreated") {
recordResponseCreatedMeterEvent({
stripeCustomerId: organization.billing.stripeCustomerId,
responseId: response.id,
createdAt: response.createdAt,
}).catch((error) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Send telemetry events
await sendTelemetryEvents();
}
return Response.json({ data: {} });
};

View File

@@ -1,12 +0,0 @@
import { z } from "zod";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { ZResponse } from "@formbricks/types/responses";
export const ZPipelineInput = z.object({
event: ZWebhook.shape.triggers.element,
response: ZResponse,
environmentId: z.string(),
surveyId: z.string(),
});
export type TPipelineInput = z.infer<typeof ZPipelineInput>;

View File

@@ -29,9 +29,9 @@ describe("getApiKeyWithPermissions", () => {
createdAt: new Date(),
updatedAt: new Date(),
type: "development" as const,
projectId: "project-1",
workspaceId: "workspace-1",
appSetupCompleted: true,
project: { id: "project-1", name: "Project 1" },
workspace: { id: "workspace-1", name: "Workspace 1" },
},
},
],
@@ -60,22 +60,22 @@ describe("hasPermission", () => {
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
workspaceId: "workspace-1",
workspaceName: "Workspace 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
projectId: "project-2",
projectName: "Project 2",
workspaceId: "workspace-2",
workspaceName: "Workspace 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "development",
projectId: "project-3",
projectName: "Project 3",
workspaceId: "workspace-3",
workspaceName: "Workspace 3",
},
];
@@ -125,9 +125,9 @@ describe("authenticateRequest", () => {
createdAt: new Date(),
updatedAt: new Date(),
type: "development" as const,
projectId: "project-1",
workspaceId: "workspace-1",
appSetupCompleted: true,
project: { id: "project-1", name: "Project 1" },
workspace: { id: "workspace-1", name: "Workspace 1" },
},
},
],
@@ -143,8 +143,8 @@ describe("authenticateRequest", () => {
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
workspaceId: "workspace-1",
workspaceName: "Workspace 1",
},
],
apiKeyId: "api-key-id",

View File

@@ -22,8 +22,8 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
workspaceId: env.environment.workspaceId,
workspaceName: env.environment.workspace.name,
})),
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,

View File

@@ -30,8 +30,8 @@ const mockEnvironmentData = {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
workspace: {
id: "workspace-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -73,7 +73,7 @@ const mockEnvironmentData = {
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
workspaceOverwrites: null,
},
],
};
@@ -97,8 +97,8 @@ describe("getEnvironmentStateData", () => {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
workspace: {
id: "workspace-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -117,7 +117,7 @@ describe("getEnvironmentStateData", () => {
id: true,
type: true,
appSetupCompleted: true,
project: expect.any(Object),
workspace: expect.any(Object),
actionClasses: expect.any(Object),
surveys: expect.any(Object),
}),
@@ -131,10 +131,10 @@ describe("getEnvironmentStateData", () => {
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
});
test("should throw ResourceNotFoundError when project is not found", async () => {
test("should throw ResourceNotFoundError when workspace is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: null,
workspace: null,
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
@@ -201,9 +201,9 @@ describe("getEnvironmentStateData", () => {
expect(result.surveys).toHaveLength(2);
});
test("should correctly map project properties to environment.project", async () => {
const customProject = {
...mockEnvironmentData.project,
test("should correctly map workspace properties to environment.workspace", async () => {
const customWorkspace = {
...mockEnvironmentData.workspace,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
@@ -214,13 +214,13 @@ describe("getEnvironmentStateData", () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: customProject,
workspace: customWorkspace,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.project).toEqual({
id: "project-123",
expect(result.environment.workspace).toEqual({
id: "workspace-123",
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",

View File

@@ -6,8 +6,8 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TJsEnvironmentStateActionClass,
TJsEnvironmentStateProject,
TJsEnvironmentStateSurvey,
TJsEnvironmentStateWorkspace,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
@@ -23,7 +23,7 @@ export interface EnvironmentStateData {
id: string;
type: string;
appSetupCompleted: boolean;
project: TJsEnvironmentStateProject;
workspace: TJsEnvironmentStateWorkspace;
};
surveys: TJsEnvironmentStateSurvey[];
actionClasses: TJsEnvironmentStateActionClass[];
@@ -45,8 +45,8 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: true,
type: true,
appSetupCompleted: true,
// Project data (optimized select)
project: {
// Workspace data (optimized select)
workspace: {
select: {
id: true,
recontactDays: true,
@@ -97,7 +97,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
alias: true,
createdAt: true,
updatedAt: true,
projectId: true,
workspaceId: true,
},
},
},
@@ -132,7 +132,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
},
displayPercentage: true,
delay: true,
projectOverwrites: true,
workspaceOverwrites: true,
},
},
},
@@ -142,8 +142,8 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("environment", environmentId);
}
if (!environmentData.project) {
throw new ResourceNotFoundError("project", null);
if (!environmentData.workspace) {
throw new ResourceNotFoundError("workspace", null);
}
// Transform surveys using existing utility
@@ -156,14 +156,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: environmentData.id,
type: environmentData.type,
appSetupCompleted: environmentData.appSetupCompleted,
project: {
id: environmentData.project.id,
recontactDays: environmentData.project.recontactDays,
clickOutsideClose: environmentData.project.clickOutsideClose,
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
workspace: {
id: environmentData.workspace.id,
recontactDays: environmentData.workspace.recontactDays,
clickOutsideClose: environmentData.workspace.clickOutsideClose,
overlay: environmentData.workspace.overlay,
placement: environmentData.workspace.placement,
inAppSurveyBranding: environmentData.workspace.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.workspace.styling),
},
},
surveys: resolveStorageUrlsInObject(transformedSurveys),

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
import { TJsEnvironmentState, TJsEnvironmentStateWorkspace } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
@@ -49,8 +49,8 @@ vi.mock("@formbricks/cache", () => ({
const environmentId = "test-environment-id";
const mockProject: TJsEnvironmentStateProject = {
id: "test-project-id",
const mockWorkspace: TJsEnvironmentStateWorkspace = {
id: "test-workspace-id",
recontactDays: 30,
inAppSurveyBranding: true,
placement: "bottomRight",
@@ -69,7 +69,7 @@ const mockOrganization: TOrganization = {
billing: {
stripeCustomerId: null,
limits: {
projects: 1,
workspaces: 1,
monthly: {
responses: 100,
},
@@ -94,7 +94,7 @@ const mockSurveys: TSurvey[] = [
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
workspaceOverwrites: null,
showLanguageSwitch: false,
questions: [],
displayOption: "displayOnce",
@@ -137,11 +137,7 @@ const mockEnvironmentStateData: EnvironmentStateData = {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: mockProject,
},
organization: {
id: mockOrganization.id,
billing: mockOrganization.billing,
workspace: mockWorkspace,
},
surveys: mockSurveys,
actionClasses: mockActionClasses,
@@ -165,11 +161,19 @@ describe("getEnvironmentState", () => {
test("should return the correct environment state", async () => {
const result = await getEnvironmentState(environmentId);
const expectedData: TJsEnvironmentState["data"] = {
// Backwards compat: response includes `project` alongside `workspace`,
// and each survey includes `projectOverwrites` alongside `workspaceOverwrites`
const surveysWithLegacy = mockSurveys.map((s) => ({
...s,
projectOverwrites: (s as Record<string, unknown>).workspaceOverwrites ?? null,
}));
const expectedData = {
recaptchaSiteKey: "mock_recaptcha_site_key",
surveys: mockSurveys,
surveys: surveysWithLegacy,
actionClasses: mockActionClasses,
project: mockProject,
workspace: mockWorkspace,
project: mockWorkspace,
};
expect(result.data).toEqual(expectedData);
@@ -189,8 +193,8 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError if project not found", async () => {
vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("project", null));
test("should throw ResourceNotFoundError if workspace not found", async () => {
vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("workspace", null));
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
@@ -276,7 +280,12 @@ describe("getEnvironmentState", () => {
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual(mixedSurveys);
// Backwards compat: each survey includes `projectOverwrites`
const expectedSurveys = mixedSurveys.map((s) => ({
...s,
projectOverwrites: (s as Record<string, unknown>).workspaceOverwrites ?? null,
}));
expect(result.data.surveys).toEqual(expectedSurveys);
});
test("should handle empty surveys array", async () => {

View File

@@ -2,6 +2,10 @@ import "server-only";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { TJsEnvironmentState } from "@formbricks/types/js";
import {
addLegacyProjectOverwritesToList,
addLegacyProjectToEnvironmentState,
} from "@/app/lib/api/api-backwards-compat";
import { cache } from "@/lib/cache";
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getEnvironmentStateData } from "./data";
@@ -13,7 +17,7 @@ import { getEnvironmentStateData } from "./data";
*
* @param environmentId - The environment ID to fetch state for
* @returns The environment state
* @throws ResourceNotFoundError if environment, organization, or project not found
* @throws ResourceNotFoundError if environment, organization, or workspace not found
*/
export const getEnvironmentState = async (
environmentId: string
@@ -33,12 +37,14 @@ export const getEnvironmentState = async (
}
// Build the response data
const data: TJsEnvironmentState["data"] = {
surveys,
// Backwards compat: include `project` alongside `workspace`, and
// `projectOverwrites` alongside `workspaceOverwrites` in each survey
const data = addLegacyProjectToEnvironmentState({
surveys: addLegacyProjectOverwritesToList(surveys),
actionClasses,
project: environment.project,
workspace: environment.workspace,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
};
} as TJsEnvironmentState["data"]);
return { data };
},

View File

@@ -0,0 +1,270 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import type { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { type TResponse, type TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import type { THandlerParams } from "@/app/lib/api/with-api-logging";
import { enqueueResponsePipelineEvents } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./response";
const UPDATE_RESPONSE_ENDPOINT = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return responses.internalServerErrorResponse(error.message, true);
}
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const getValidatedResponseUpdateInput = async (req: Request): Promise<Response | TResponseUpdateInput> => {
let responseUpdate;
try {
responseUpdate = await req.json();
} catch (error) {
return responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
);
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
return inputValidation.data;
};
const getResponseForUpdate = async (req: Request, responseId: string): Promise<Response | TResponse> => {
try {
const response = await getResponse(responseId);
if (!response) {
return responses.notFoundResponse("Response", responseId, true);
}
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
return response;
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
UPDATE_RESPONSE_ENDPOINT,
responseId
);
}
};
const getSurveyForResponseUpdate = async (
req: Request,
environmentId: string,
responseId: string,
surveyId: string
): Promise<Response | TSurvey> => {
try {
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
return survey;
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
UPDATE_RESPONSE_ENDPOINT,
responseId
);
}
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
const getResponseUpdateValidationError = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
): Response | undefined => {
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseUpdateInput.data,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: responseUpdateInput.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
"Response exceeds character limit",
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
return validateResponse(response, survey, responseUpdateInput)?.response;
};
const updateResponseSafely = async (
req: Request,
responseId: string,
responseUpdateInput: TResponseUpdateInput
): Promise<Response | TResponseWithQuotaFull> => {
try {
return await updateResponseWithQuotaEvaluation(responseId, responseUpdateInput);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url: req.url }, `Error in ${UPDATE_RESPONSE_ENDPOINT}`);
return responses.internalServerErrorResponse(error.message, true);
}
logger.error({ error, url: req.url }, `Error in ${UPDATE_RESPONSE_ENDPOINT}`);
return responses.internalServerErrorResponse("Something went wrong", true);
}
};
export const handleUpdateResponseRequest = async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; responseId: string }> }>): Promise<{
response: Response;
}> => {
const params = await props.params;
const { environmentId, responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const responseUpdateInput = await getValidatedResponseUpdateInput(req);
if (responseUpdateInput instanceof Response) {
return {
response: responseUpdateInput,
};
}
const response = await getResponseForUpdate(req, responseId);
if (response instanceof Response) {
return {
response,
};
}
const survey = await getSurveyForResponseUpdate(req, environmentId, responseId, response.surveyId);
if (survey instanceof Response) {
return {
response: survey,
};
}
const validationError = getResponseUpdateValidationError(response, survey, responseUpdateInput);
if (validationError) {
return {
response: validationError,
};
}
const updatedResponse = await updateResponseSafely(req, responseId, responseUpdateInput);
if (updatedResponse instanceof Response) {
return {
response: updatedResponse,
};
}
const { quotaFull, ...responseData } = updatedResponse;
await enqueueResponsePipelineEvents({
environmentId: survey.environmentId,
events: updatedResponse.finished ? ["responseUpdated", "responseFinished"] : ["responseUpdated"],
response: responseData,
responseId: responseData.id,
surveyId: survey.id,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
};

View File

@@ -0,0 +1,351 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const {
mockCreateQuotaFullObject,
mockEnqueueResponsePipelineEvents,
mockFormatValidationErrorsForV1Api,
mockGetResponse,
mockGetSurvey,
mockLoggerError,
mockTransformErrorToDetails,
mockUpdateResponseWithQuotaEvaluation,
mockValidateFileUploads,
mockValidateOtherOptionLengthForMultipleChoice,
mockValidateResponseData,
} = vi.hoisted(() => ({
mockCreateQuotaFullObject: vi.fn(),
mockEnqueueResponsePipelineEvents: vi.fn(),
mockFormatValidationErrorsForV1Api: vi.fn(),
mockGetResponse: vi.fn(),
mockGetSurvey: vi.fn(),
mockLoggerError: vi.fn(),
mockTransformErrorToDetails: vi.fn(),
mockUpdateResponseWithQuotaEvaluation: vi.fn(),
mockValidateFileUploads: vi.fn(),
mockValidateOtherOptionLengthForMultipleChoice: vi.fn(),
mockValidateResponseData: vi.fn(),
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message: string, details?: unknown) =>
Response.json({ details, message }, { status: 400 })
),
internalServerErrorResponse: vi.fn((message: string) => Response.json({ message }, { status: 500 })),
notFoundResponse: vi.fn((resource: string, id: string) =>
Response.json({ id, message: `${resource} not found` }, { status: 404 })
),
successResponse: vi.fn((body: unknown) => Response.json(body, { status: 200 })),
},
}));
vi.mock("@/app/lib/api/validator", () => ({
transformErrorToDetails: mockTransformErrorToDetails,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
withV1ApiWrapper: ({ handler }: { handler: unknown }) => handler,
}));
vi.mock("@/app/lib/pipelines", () => ({
enqueueResponsePipelineEvents: mockEnqueueResponsePipelineEvents,
}));
vi.mock("@/lib/response/service", () => ({
getResponse: mockGetResponse,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mockGetSurvey,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mockFormatValidationErrorsForV1Api,
validateResponseData: mockValidateResponseData,
}));
vi.mock("@/modules/api/v2/lib/element", () => ({
validateOtherOptionLengthForMultipleChoice: mockValidateOtherOptionLengthForMultipleChoice,
}));
vi.mock("@/modules/ee/quotas/lib/helpers", () => ({
createQuotaFullObject: mockCreateQuotaFullObject,
}));
vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mockValidateFileUploads,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
debug: vi.fn(),
error: mockLoggerError,
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("./lib/response", () => ({
updateResponseWithQuotaEvaluation: mockUpdateResponseWithQuotaEvaluation,
}));
type PutHandler = (args: {
props: { params: Promise<{ environmentId: string; responseId: string }> };
req: NextRequest;
}) => Promise<{ response: Response }>;
const environmentId = "cm9environment000108l4abcz12";
const responseId = "cm9response000108l4abcz12";
const surveyId = "cm9survey000108l4abcz12zz";
const createRequest = (body: BodyInit) =>
new NextRequest(`http://localhost/api/v1/client/${environmentId}/responses/${responseId}`, {
body,
headers: {
"content-type": "application/json",
},
method: "PUT",
});
const waitForEnqueueCall = async (mockFn: ReturnType<typeof vi.fn>) => {
for (let attempt = 0; attempt < 20; attempt++) {
if (mockFn.mock.calls.length > 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error("Timed out waiting for enqueueResponsePipelineEvents to be called");
};
describe("PUT /api/v1/client/[environmentId]/responses/[responseId]", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTransformErrorToDetails.mockReturnValue([{ field: "body", message: "invalid" }]);
mockGetResponse.mockResolvedValue({
data: { question_1: "old" },
finished: false,
id: responseId,
language: "en",
surveyId,
});
mockGetSurvey.mockResolvedValue({
blocks: [],
environmentId,
id: surveyId,
questions: [],
});
mockValidateFileUploads.mockReturnValue(true);
mockValidateOtherOptionLengthForMultipleChoice.mockReturnValue(undefined);
mockValidateResponseData.mockReturnValue(null);
mockFormatValidationErrorsForV1Api.mockReturnValue([{ field: "data", message: "invalid" }]);
mockUpdateResponseWithQuotaEvaluation.mockResolvedValue({
finished: true,
id: responseId,
quotaFull: { quotaId: "quota_1" },
});
mockCreateQuotaFullObject.mockReturnValue({ quotaReached: true });
});
test("returns a bad request response when the response is already finished", async () => {
mockGetResponse.mockResolvedValue({
data: {},
finished: true,
id: responseId,
language: "en",
surveyId,
});
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: {}, finished: false })),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
message: "Response is already finished",
});
expect(mockGetSurvey).not.toHaveBeenCalled();
});
test("returns a bad request response when the request body fails validation", async () => {
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: 123, finished: false })),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
message: "Fields are missing or incorrectly formatted",
});
expect(mockGetResponse).not.toHaveBeenCalled();
});
test("returns a bad request response for malformed JSON bodies", async () => {
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest("{"),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
message: "Invalid JSON in request body",
});
expect(mockGetResponse).not.toHaveBeenCalled();
});
test("returns a bad request response when file uploads are invalid", async () => {
mockValidateFileUploads.mockReturnValue(false);
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: { fileQuestion: ["invalid"] }, finished: false })),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
message: "Invalid file upload response",
});
expect(mockUpdateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("returns a bad request response when an other-option answer exceeds the limit", async () => {
mockValidateOtherOptionLengthForMultipleChoice.mockReturnValue("question_other");
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: { question_other: "value" }, finished: false })),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
details: { questionId: "question_other" },
message: "Response exceeds character limit",
});
});
test("returns an internal server error response when loading the survey fails", async () => {
const surveyError = new Error("survey lookup failed");
mockGetSurvey.mockRejectedValue(surveyError);
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: { question_1: "new" }, finished: false })),
});
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toMatchObject({
message: "Unknown error occurred",
});
});
test("returns an internal server error response when persisting the update fails", async () => {
const updateError = new Error("update failed");
mockUpdateResponseWithQuotaEvaluation.mockRejectedValue(updateError);
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: { question_1: "new" }, finished: false })),
});
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toMatchObject({
message: "Something went wrong",
});
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: updateError, url: `http://localhost/api/v1/client/${environmentId}/responses/${responseId}` },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("returns a bad request response when the response survey belongs to another environment", async () => {
mockGetSurvey.mockResolvedValue({
blocks: [],
environmentId: "cm9differentenvironment000108",
id: surveyId,
questions: [],
});
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: { question_1: "new" }, finished: false })),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
details: {
"survey.environmentId": "cm9differentenvironment000108",
environmentId,
},
message: "Survey is part of another environment",
});
expect(mockUpdateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mockEnqueueResponsePipelineEvents).not.toHaveBeenCalled();
});
test("updates the response and enqueues BullMQ events", async () => {
const { PUT } = await import("./route");
const result = await (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: { question_1: "new" }, finished: true })),
});
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
id: responseId,
quotaReached: true,
});
expect(mockUpdateResponseWithQuotaEvaluation).toHaveBeenCalledWith(responseId, {
data: { question_1: "new" },
finished: true,
});
expect(mockEnqueueResponsePipelineEvents).toHaveBeenCalledWith({
environmentId,
events: ["responseUpdated", "responseFinished"],
response: expect.objectContaining({
finished: true,
id: responseId,
}),
responseId,
surveyId,
});
});
test("waits for the enqueue attempt before returning the update response", async () => {
let resolveEnqueue: (() => void) | undefined;
const enqueuePromise = new Promise<void>((resolve) => {
resolveEnqueue = resolve;
});
mockEnqueueResponsePipelineEvents.mockReturnValue(enqueuePromise);
const { PUT } = await import("./route");
let settled = false;
const resultPromise = (PUT as unknown as PutHandler)({
props: { params: Promise.resolve({ environmentId, responseId }) },
req: createRequest(JSON.stringify({ data: { question_1: "new" }, finished: true })),
}).then((result) => {
settled = true;
return result;
});
await waitForEnqueueCall(mockEnqueueResponsePipelineEvents);
expect(settled).toBe(false);
resolveEnqueue?.();
const result = await resultPromise;
expect(result.response.status).toBe(200);
});
});

View File

@@ -1,235 +1,11 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } 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 { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { handleUpdateResponseRequest } from "./lib/put-response-handler";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return responses.internalServerErrorResponse(error.message, true);
}
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
const { responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const responseUpdate = await req.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!response) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", response.surveyId, true),
};
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
};
}
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
};
}
const { quotaFull, ...responseData } = updatedResponse;
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
},
handler: handleUpdateResponseRequest,
});

View File

@@ -0,0 +1,245 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import type { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { type TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import type { THandlerParams } from "@/app/lib/api/with-api-logging";
import { enqueueResponsePipelineEvents } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./response";
const createValidationResponse = (details: Record<string, string>): Response =>
responses.badRequestResponse("Fields are missing or incorrectly formatted", details, true);
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
const parseResponseInput = async (
req: Request,
environmentId: string
): Promise<Response | TResponseInput> => {
let responseInput;
try {
responseInput = await req.json();
} catch (error) {
return responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
);
}
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
if (!environmentIdValidation.success) {
return createValidationResponse(transformErrorToDetails(environmentIdValidation.error));
}
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!responseInputValidation.success) {
return createValidationResponse(transformErrorToDetails(responseInputValidation.error));
}
return responseInputValidation.data;
};
const ensureContactsAccess = async (
environmentId: string,
responseInputData: TResponseInput
): Promise<Response | undefined> => {
if (!responseInputData.userId) {
return;
}
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
};
const getSurveyForResponseCreation = async (
environmentId: string,
surveyId: string
): Promise<Response | TSurvey> => {
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
return survey;
};
const getResponseCreationValidationError = (
responseInputData: TResponseInput,
survey: TSurvey
): Response | undefined => {
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
return validateResponse(responseInputData, survey)?.response;
};
const buildResponseMeta = async (
req: Request,
requestHeaders: Headers,
responseInputData: TResponseInput,
survey: TSurvey
): Promise<TResponseInput["meta"]> => {
const userAgent = req.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const meta: TResponseInput["meta"] = {
source: responseInputData.meta?.source,
url: responseInputData.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country,
action: responseInputData.meta?.action,
};
if (survey.isCaptureIpEnabled) {
meta.ipAddress = await getClientIpFromHeaders();
}
return meta;
};
const createResponseSafely = async (
req: Request,
requestHeaders: Headers,
responseInputData: TResponseInput,
survey: TSurvey
): Promise<Response | TResponseWithQuotaFull> => {
try {
const meta = await buildResponseMeta(req, requestHeaders, responseInputData, survey);
return await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
logger.error({ error, url: req.url }, "Error creating response");
return responses.internalServerErrorResponse(
error instanceof Error ? error.message : "Unknown error occurred",
true
);
}
};
export const handleCreateResponseRequest = async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string }> }>): Promise<{ response: Response }> => {
const params = await props.params;
const requestHeaders = await headers();
const { environmentId } = params;
const responseInputData = await parseResponseInput(req, environmentId);
if (responseInputData instanceof Response) {
return {
response: responseInputData,
};
}
const contactsError = await ensureContactsAccess(environmentId, responseInputData);
if (contactsError) {
return {
response: contactsError,
};
}
const survey = await getSurveyForResponseCreation(environmentId, responseInputData.surveyId);
if (survey instanceof Response) {
return {
response: survey,
};
}
const validationError = getResponseCreationValidationError(responseInputData, survey);
if (validationError) {
return {
response: validationError,
};
}
const response = await createResponseSafely(req, requestHeaders, responseInputData, survey);
if (response instanceof Response) {
return {
response,
};
}
const { quotaFull, ...responseData } = response;
await enqueueResponsePipelineEvents({
environmentId: survey.environmentId,
events: response.finished ? ["responseCreated", "responseFinished"] : ["responseCreated"],
response: responseData,
responseId: responseData.id,
surveyId: responseData.surveyId,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
};

View File

@@ -0,0 +1,330 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const {
mockCreateQuotaFullObject,
mockCreateResponseWithQuotaEvaluation,
mockEnqueueResponsePipelineEvents,
mockFormatValidationErrorsForV1Api,
mockGetClientIpFromHeaders,
mockGetIsContactsEnabled,
mockGetOrganizationIdFromEnvironmentId,
mockGetSurvey,
mockHeaders,
mockLoggerError,
mockTransformErrorToDetails,
mockValidateFileUploads,
mockValidateResponseData,
} = vi.hoisted(() => ({
mockCreateQuotaFullObject: vi.fn(),
mockCreateResponseWithQuotaEvaluation: vi.fn(),
mockEnqueueResponsePipelineEvents: vi.fn(),
mockFormatValidationErrorsForV1Api: vi.fn(),
mockGetClientIpFromHeaders: vi.fn(),
mockGetIsContactsEnabled: vi.fn(),
mockGetOrganizationIdFromEnvironmentId: vi.fn(),
mockGetSurvey: vi.fn(),
mockHeaders: vi.fn(),
mockLoggerError: vi.fn(),
mockTransformErrorToDetails: vi.fn(),
mockValidateFileUploads: vi.fn(),
mockValidateResponseData: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: mockHeaders,
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message: string, details?: unknown) =>
Response.json({ details, message }, { status: 400 })
),
forbiddenResponse: vi.fn((message: string) => Response.json({ message }, { status: 403 })),
internalServerErrorResponse: vi.fn((message: string) => Response.json({ message }, { status: 500 })),
notFoundResponse: vi.fn((resource: string, id: string) =>
Response.json({ id, message: `${resource} not found` }, { status: 404 })
),
successResponse: vi.fn((body: unknown) => Response.json(body, { status: 200 })),
},
}));
vi.mock("@/app/lib/api/validator", () => ({
transformErrorToDetails: mockTransformErrorToDetails,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
withV1ApiWrapper: ({ handler }: { handler: unknown }) => handler,
}));
vi.mock("@/app/lib/pipelines", () => ({
enqueueResponsePipelineEvents: mockEnqueueResponsePipelineEvents,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mockGetSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mockGetClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mockGetOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mockFormatValidationErrorsForV1Api,
validateResponseData: mockValidateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mockGetIsContactsEnabled,
}));
vi.mock("@/modules/ee/quotas/lib/helpers", () => ({
createQuotaFullObject: mockCreateQuotaFullObject,
}));
vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mockValidateFileUploads,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
debug: vi.fn(),
error: mockLoggerError,
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mockCreateResponseWithQuotaEvaluation,
}));
type PostHandler = (args: {
props: { params: Promise<{ environmentId: string }> };
req: NextRequest;
}) => Promise<{ response: Response }>;
const environmentId = "cm9environment000108l4abcz12";
const surveyId = "cm9survey000108l4abcz12zz";
const createRequest = (body: BodyInit, headers?: HeadersInit) =>
new NextRequest(`http://localhost/api/v1/client/${environmentId}/responses`, {
body,
headers: {
"content-type": "application/json",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
...headers,
},
method: "POST",
});
const waitForEnqueueCall = async (mockFn: ReturnType<typeof vi.fn>) => {
for (let attempt = 0; attempt < 20; attempt++) {
if (mockFn.mock.calls.length > 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error("Timed out waiting for enqueueResponsePipelineEvents to be called");
};
describe("POST /api/v1/client/[environmentId]/responses", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHeaders.mockResolvedValue(new Headers({ "CF-IPCountry": "RO" }));
mockTransformErrorToDetails.mockReturnValue([{ field: "body", message: "invalid" }]);
mockGetOrganizationIdFromEnvironmentId.mockResolvedValue("org_1");
mockGetIsContactsEnabled.mockResolvedValue(true);
mockGetSurvey.mockResolvedValue({
blocks: [],
environmentId,
id: surveyId,
isCaptureIpEnabled: true,
questions: [],
});
mockValidateFileUploads.mockReturnValue(true);
mockValidateResponseData.mockReturnValue(null);
mockFormatValidationErrorsForV1Api.mockReturnValue([{ field: "data", message: "invalid" }]);
mockGetClientIpFromHeaders.mockResolvedValue("203.0.113.10");
mockCreateResponseWithQuotaEvaluation.mockResolvedValue({
finished: true,
id: "resp_1",
quotaFull: { quotaId: "quota_1" },
surveyId,
});
mockCreateQuotaFullObject.mockReturnValue({ quotaReached: false });
});
test("returns a bad request response for invalid JSON bodies", async () => {
const { POST } = await import("./route");
const result = await (POST as unknown as PostHandler)({
props: { params: Promise.resolve({ environmentId }) },
req: createRequest("{"),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
message: "Invalid JSON in request body",
});
expect(mockGetSurvey).not.toHaveBeenCalled();
});
test("returns forbidden when contacts are disabled for identified responses", async () => {
mockGetIsContactsEnabled.mockResolvedValue(false);
const { POST } = await import("./route");
const result = await (POST as unknown as PostHandler)({
props: { params: Promise.resolve({ environmentId }) },
req: createRequest(
JSON.stringify({
data: {},
finished: false,
surveyId,
userId: "user_123",
})
),
});
expect(result.response.status).toBe(403);
expect(mockCreateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("returns a bad request response when the survey belongs to another environment", async () => {
mockGetSurvey.mockResolvedValue({
blocks: [],
environmentId: "cm9differentenv000108l4zzz999",
id: surveyId,
isCaptureIpEnabled: false,
questions: [],
});
const { POST } = await import("./route");
const result = await (POST as unknown as PostHandler)({
props: { params: Promise.resolve({ environmentId }) },
req: createRequest(
JSON.stringify({
data: {},
finished: false,
surveyId,
})
),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
message: "Survey is part of another environment",
});
});
test("returns a bad request response for invalid file uploads", async () => {
mockValidateFileUploads.mockReturnValue(false);
const { POST } = await import("./route");
const result = await (POST as unknown as PostHandler)({
props: { params: Promise.resolve({ environmentId }) },
req: createRequest(
JSON.stringify({
data: { fileQuestion: ["invalid"] },
finished: false,
surveyId,
})
),
});
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toMatchObject({
message: "Invalid file upload response",
});
expect(mockCreateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("creates the response, enriches metadata, and enqueues BullMQ events", async () => {
const { POST } = await import("./route");
const result = await (POST as unknown as PostHandler)({
props: { params: Promise.resolve({ environmentId }) },
req: createRequest(
JSON.stringify({
data: { question_1: "hello" },
finished: true,
meta: {
action: "submit",
source: "link",
url: "https://example.com/form",
},
surveyId,
})
),
});
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
id: "resp_1",
quotaReached: false,
});
expect(mockCreateResponseWithQuotaEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
data: { question_1: "hello" },
meta: expect.objectContaining({
action: "submit",
country: "RO",
ipAddress: "203.0.113.10",
source: "link",
url: "https://example.com/form",
userAgent: expect.any(Object),
}),
surveyId,
})
);
expect(mockEnqueueResponsePipelineEvents).toHaveBeenCalledWith({
environmentId,
events: ["responseCreated", "responseFinished"],
response: expect.objectContaining({
id: "resp_1",
surveyId,
}),
responseId: "resp_1",
surveyId,
});
});
test("waits for the enqueue attempt before returning the create response", async () => {
let resolveEnqueue: (() => void) | undefined;
const enqueuePromise = new Promise<void>((resolve) => {
resolveEnqueue = resolve;
});
mockEnqueueResponsePipelineEvents.mockReturnValue(enqueuePromise);
const { POST } = await import("./route");
let settled = false;
const resultPromise = (POST as unknown as PostHandler)({
props: { params: Promise.resolve({ environmentId }) },
req: createRequest(
JSON.stringify({
data: { question_1: "hello" },
finished: true,
surveyId,
})
),
}).then((result) => {
settled = true;
return result;
});
await waitForEnqueueCall(mockEnqueueResponsePipelineEvents);
expect(settled).toBe(false);
resolveEnqueue?.();
const result = await resultPromise;
expect(result.response.status).toBe(200);
});
});

View File

@@ -1,217 +1,11 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { handleCreateResponseRequest } from "./lib/post-response-handler";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
// Cache CORS preflight responses for 1 hour (conservative approach)
// Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
return responses.successResponse({}, true, "public, s-maxage=3600, max-age=3600");
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await req.json();
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
};
}
if (!responseInputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
),
};
}
const userAgent = req.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", responseInputData.surveyId, true),
};
}
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
),
};
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
const validationResult = validateResponse(responseInputData, survey);
if (validationResult) {
return validationResult;
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
}
response = await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
response: responses.internalServerErrorResponse(
error instanceof Error ? error.message : "Unknown error occurred"
),
};
}
}
const { quotaFull, ...responseData } = response;
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
},
handler: handleCreateResponseRequest,
});

View File

@@ -21,9 +21,9 @@ const apiKeySelect = {
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
workspaceId: true,
appSetupCompleted: true,
project: {
workspace: {
select: {
id: true,
name: true,
@@ -49,9 +49,9 @@ type ApiKeyData = {
type: string;
createdAt: Date;
updatedAt: Date;
projectId: string;
workspaceId: string;
appSetupCompleted: boolean;
project: {
workspace: {
id: string;
name: string;
};
@@ -124,10 +124,13 @@ const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
project: {
id: env.projectId,
name: env.project.name,
workspace: {
id: env.workspaceId,
name: env.workspace.name,
},
// Backwards compat: old consumers expect project fields
projectId: env.workspaceId,
projectName: env.workspace.name,
});
};

View File

@@ -4,7 +4,7 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
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";
import { sendToPipeline } from "@/app/lib/pipelines";
import { enqueueResponsePipelineEvents } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
@@ -169,22 +169,14 @@ export const PUT = withV1ApiWrapper({
auditLog.newObject = updated;
}
sendToPipeline({
event: "responseUpdated",
await enqueueResponsePipelineEvents({
environmentId: result.survey.environmentId,
surveyId: result.survey.id,
events: updated.finished ? ["responseUpdated", "responseFinished"] : ["responseUpdated"],
response: updated,
responseId: updated.id,
surveyId: result.survey.id,
});
if (updated.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: result.survey.environmentId,
surveyId: result.survey.id,
response: updated,
});
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
};

View File

@@ -27,7 +27,7 @@ const mockOrganization = {
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
limits: { projects: 3, monthly: { responses: null } },
limits: { workspaces: 3, monthly: { responses: null } },
usageCycleAnchor: new Date(),
} as TOrganizationBilling, // Default no limit
} as unknown as Organization;

View File

@@ -4,7 +4,7 @@ import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/res
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { enqueueResponsePipelineEvents } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -174,22 +174,14 @@ export const POST = withV1ApiWrapper({
auditLog.newObject = response;
}
sendToPipeline({
event: "responseCreated",
await enqueueResponsePipelineEvents({
environmentId: surveyResult.survey.environmentId,
events: response.finished ? ["responseCreated", "responseFinished"] : ["responseCreated"],
response,
responseId: response.id,
surveyId: response.surveyId,
response: response,
});
if (response.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: surveyResult.survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
return {
response: responses.successResponse(response, true),
};

View File

@@ -52,8 +52,8 @@ describe("checkAuth", () => {
environmentId: "env-123",
permission: "read",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
workspaceId: "workspace-1",
workspaceName: "Workspace 1",
},
],
apiKeyId: "hashed-key",
@@ -84,8 +84,8 @@ describe("checkAuth", () => {
environmentId: "env-123",
permission: "write",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
workspaceId: "workspace-1",
workspaceName: "Workspace 1",
},
],
apiKeyId: "hashed-key",

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