diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts
index 13953f6b51..64bfdb8013 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace";
+const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
+ feedbackRecordDirectoryId: ZId.optional(),
+});
+
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
- data: ZWorkspaceUpdateInput,
+ data: ZCreateWorkspaceInput,
});
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [
{
data: parsedInput.data,
- schema: ZWorkspaceUpdateInput,
+ schema: ZCreateWorkspaceInput,
type: "organization",
roles: ["owner", "manager"],
},
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
index 42ec0be9be..f7c2e14169 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
@@ -10,12 +10,12 @@ import {
Loader2,
LogOutIcon,
MessageCircle,
+ MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
- Shapes,
UserCircleIcon,
UserIcon,
} from "lucide-react";
@@ -146,58 +146,77 @@ export const MainNavigation = ({
}
}, [pathname]);
- const mainNavigation = useMemo(
+ const mainNavigationSections = useMemo(
() => [
{
- name: t("common.surveys"),
- href: `/workspaces/${workspace.id}/surveys`,
- icon: MessageCircle,
- isActive: pathname?.includes("/surveys"),
- isHidden: false,
- disabled: isMembershipPending || isBilling,
- },
- {
- href: `/workspaces/${workspace.id}/contacts`,
- name: t("common.contacts"),
- icon: UserIcon,
- isActive:
- pathname?.includes("/contacts") ||
- pathname?.includes("/segments") ||
- pathname?.includes("/attributes"),
- disabled: isMembershipPending || isBilling,
- },
- {
- name: t("common.analysis"),
- href: `/workspaces/${workspace.id}/dashboards`,
- icon: BarChart3Icon,
- isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
- isHidden: false,
- disabled: isMembershipPending || isBilling,
+ id: "ask",
+ name: "Ask",
+ items: [
+ {
+ name: t("common.surveys"),
+ href: `/workspaces/${workspace.id}/surveys`,
+ icon: MessageCircle,
+ isActive: pathname?.includes("/surveys"),
+ isHidden: false,
+ disabled: isMembershipPending || isBilling,
+ },
+ {
+ href: `/workspaces/${workspace.id}/contacts`,
+ name: t("common.contacts"),
+ icon: UserIcon,
+ isActive:
+ pathname?.includes("/contacts") ||
+ pathname?.includes("/segments") ||
+ pathname?.includes("/attributes"),
+ disabled: isMembershipPending || isBilling,
+ },
+ ],
},
{
+ id: "unify-feedback",
name: t("workspace.unify.unify_feedback"),
- href: `/workspaces/${workspace.id}/unify/sources`,
- icon: Shapes,
- isActive: pathname?.includes("/unify"),
- },
- {
- name: t("common.configuration"),
- href: `/workspaces/${workspace.id}/general`,
- icon: Cog,
- isActive:
- pathname?.includes("/general") ||
- pathname?.includes("/look") ||
- pathname?.includes("/app-connection") ||
- pathname?.includes("/integrations") ||
- pathname?.includes("/teams") ||
- pathname?.includes("/languages") ||
- pathname?.includes("/tags"),
- disabled: isMembershipPending || isBilling,
+ items: [
+ {
+ name: t("workspace.unify.feedback_records"),
+ href: `/workspaces/${workspace.id}/unify/feedback-records`,
+ icon: MessageSquareTextIcon,
+ isActive: pathname?.includes("/unify/feedback-records"),
+ isHidden: false,
+ disabled: isMembershipPending || isBilling,
+ },
+ {
+ name: t("common.dashboards"),
+ href: `/workspaces/${workspace.id}/dashboards`,
+ icon: BarChart3Icon,
+ isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
+ isHidden: false,
+ disabled: isMembershipPending || isBilling,
+ },
+ ],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
+ const configurationNavigationItem = useMemo(
+ () => ({
+ name: t("common.configuration"),
+ href: `/workspaces/${workspace.id}/general`,
+ icon: Cog,
+ isActive:
+ pathname?.includes("/general") ||
+ pathname?.includes("/look") ||
+ pathname?.includes("/app-connection") ||
+ pathname?.includes("/feedback-sources") ||
+ pathname?.includes("/integrations") ||
+ pathname?.includes("/teams") ||
+ pathname?.includes("/languages") ||
+ pathname?.includes("/tags"),
+ disabled: isMembershipPending || isBilling,
+ }),
+ [t, workspace.id, pathname, isMembershipPending, isBilling]
+ );
+
const dropdownNavigation = [
{
label: t("common.account"),
@@ -256,6 +275,11 @@ export const MainNavigation = ({
label: t("common.website_and_app_connection"),
href: `/workspaces/${workspace.id}/app-connection`,
},
+ {
+ id: "feedback-sources",
+ label: t("workspace.unify.feedback_sources"),
+ href: `/workspaces/${workspace.id}/feedback-sources`,
+ },
{
id: "integrations",
label: t("common.integrations"),
@@ -552,23 +576,50 @@ export const MainNavigation = ({
{/* Main Nav Switch */}
-
- {mainNavigation.map(
- (item) =>
- !item.isHidden && (
-
-
-
- )
- )}
+
+ {mainNavigationSections.map((section) => (
+ -
+ {!isCollapsed && !isTextVisible && (
+
+ {section.name}
+
+ )}
+
+
+ {section.items.map(
+ (item) =>
+ !item.isHidden && (
+
+
+
+ )
+ )}
+
+
+ ))}
+
+ -
+
+
+
+
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb.tsx
index 3a45622c55..394ad076e0 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb.tsx
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb.tsx
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
label: t("common.website_and_app_connection"),
href: `${workspaceBasePath}/app-connection`,
},
+ {
+ id: "feedback-sources",
+ label: t("workspace.unify.feedback_sources"),
+ href: `${workspaceBasePath}/feedback-sources`,
+ },
{
id: "integrations",
label: t("common.integrations"),
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard.tsx
index ce84fdfd4e..bcc13560fb 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard.tsx
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard.tsx
@@ -21,6 +21,7 @@ export const SettingsCard = ({
beta,
className,
buttonInfo,
+ cta,
}: {
title: string;
description: string;
@@ -30,6 +31,7 @@ export const SettingsCard = ({
beta?: boolean;
className?: string;
buttonInfo?: ButtonInfo;
+ cta?: React.ReactNode;
}) => {
const { t } = useTranslation();
return (
@@ -52,11 +54,12 @@ export const SettingsCard = ({
{description}
- {buttonInfo && (
-
- )}
+ {cta ??
+ (buttonInfo && (
+
+ ))}
{children}
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/components/UnifyConfigNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/components/UnifyConfigNavigation.tsx
index 03c32dadff..e58d3df6f2 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/components/UnifyConfigNavigation.tsx
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/components/UnifyConfigNavigation.tsx
@@ -1,6 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
+import { Badge } from "@/modules/ui/components/badge";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
@@ -17,15 +18,24 @@ export const UnifyConfigNavigation = ({
const { t } = useTranslation();
const baseHref = `/workspaces/${workspaceId}/unify`;
- const activeId = activeIdProp ?? "sources";
+ const activeId = activeIdProp ?? "feedback-records";
const navigation = [
- { id: "sources", label: t("workspace.unify.sources"), href: `${baseHref}/sources` },
{
id: "feedback-records",
label: t("workspace.unify.feedback_records"),
href: `${baseHref}/feedback-records`,
},
+ {
+ id: "topics-subtopics",
+ label: (
+
+ {t("workspace.unify.topics_and_subtopics")}
+
+
+ ),
+ disabled: true,
+ },
];
return ;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/page.tsx
index 7a567a8e20..3d32bd2007 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/page.tsx
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/page.tsx
@@ -2,5 +2,5 @@ import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
const params = await props.params;
- redirect(`/workspaces/${params.workspaceId}/unify/sources`);
+ redirect(`/workspaces/${params.workspaceId}/unify/feedback-records`);
}
diff --git a/apps/web/modules/ee/feedback-record-directory/actions.ts b/apps/web/modules/ee/feedback-record-directory/actions.ts
index cdf19bda11..c676f3f14c 100644
--- a/apps/web/modules/ee/feedback-record-directory/actions.ts
+++ b/apps/web/modules/ee/feedback-record-directory/actions.ts
@@ -74,6 +74,7 @@ export const getFeedbackRecordDirectoryDetailsAction = authenticatedActionClient
const ZUpdateFeedbackRecordDirectoryAction = z.object({
directoryId: ZId,
data: ZFeedbackRecordDirectoryUpdateInput,
+ pauseConnectorsInRemovedWorkspaces: z.boolean().optional(),
});
export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
@@ -99,7 +100,10 @@ export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
const result = await updateFeedbackRecordDirectory(
parsedInput.directoryId,
organizationId,
- parsedInput.data
+ parsedInput.data,
+ {
+ pauseConnectorsInRemovedWorkspaces: parsedInput.pauseConnectorsInRemovedWorkspaces,
+ }
);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
diff --git a/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal.tsx b/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal.tsx
index 32a958e240..c75ad02cc6 100644
--- a/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal.tsx
+++ b/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal.tsx
@@ -1,8 +1,9 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
+import { CircleAlert } from "lucide-react";
import { useRouter } from "next/navigation";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -17,6 +18,7 @@ import { ArchiveFeedbackRecordDirectory } from "@/modules/ee/feedback-record-dir
import {
TFeedbackRecordDirectoryDetails,
TFeedbackRecordDirectoryUpdateInput,
+ TWorkspaceFeedbackRecordDirectoryAccess,
ZFeedbackRecordDirectoryUpdateInput,
getTranslatedFeedbackRecordDirectoryError,
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
@@ -43,6 +45,7 @@ interface FeedbackRecordDirectorySettingsModalProps {
directory?: TFeedbackRecordDirectoryDetails;
organizationId: string;
orgWorkspaces: TOrganizationWorkspace[];
+ workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
membershipRole: TOrganizationRole;
}
@@ -52,24 +55,47 @@ export const FeedbackRecordDirectorySettingsModal = ({
directory,
organizationId,
orgWorkspaces,
+ workspaceAccessByWorkspace,
membershipRole,
-}: FeedbackRecordDirectorySettingsModalProps) => {
+}: Readonly) => {
const { t } = useTranslation();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const router = useRouter();
const isEdit = !!directory;
+ const [confirmPauseDialogOpen, setConfirmPauseDialogOpen] = useState(false);
+ const [pendingSubmitData, setPendingSubmitData] = useState(
+ null
+ );
+ const [connectorsToPauseCount, setConnectorsToPauseCount] = useState(0);
+
+ const workspaceAccessMap = useMemo(
+ () => new Map(workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])),
+ [workspaceAccessByWorkspace]
+ );
+
const workspaceOptions = useMemo(
() =>
orgWorkspaces
- .map((p) => ({ value: p.id, label: p.name }))
+ .map((workspace) => {
+ const assignment = workspaceAccessMap.get(workspace.id);
+ const isAssignedToDifferentDirectory = Boolean(
+ assignment && assignment.feedbackRecordDirectoryId !== directory?.id
+ );
+
+ return {
+ value: workspace.id,
+ label: workspace.name,
+ disabled: isAssignedToDifferentDirectory,
+ };
+ })
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })),
- [orgWorkspaces]
+ [orgWorkspaces, workspaceAccessMap, directory?.id]
);
const initialWorkspaceIds = useMemo(
- () => directory?.workspaces.map((p) => p.workspaceId) ?? [],
+ () => directory?.workspaces.map((workspace) => workspace.workspaceId) ?? [],
[directory?.workspaces]
);
@@ -91,21 +117,29 @@ export const FeedbackRecordDirectorySettingsModal = ({
} = form;
const closeModal = () => {
+ setConfirmPauseDialogOpen(false);
+ setPendingSubmitData(null);
+ setConnectorsToPauseCount(0);
reset();
setOpen(false);
};
- const handleSubmitForm: SubmitHandler = async (data) => {
- const response = isEdit
- ? await updateFeedbackRecordDirectoryAction({
- directoryId: directory.id,
- data: { name: data.name, workspaceIds: data.workspaceIds },
- })
- : await createFeedbackRecordDirectoryAction({
- organizationId,
- name: data.name ?? "",
- workspaceIds: data.workspaceIds,
- });
+ const submitDirectory = async (
+ data: TFeedbackRecordDirectoryUpdateInput,
+ pauseConnectorsInRemovedWorkspaces: boolean
+ ) => {
+ const response =
+ isEdit && directory
+ ? await updateFeedbackRecordDirectoryAction({
+ directoryId: directory.id,
+ data: { name: data.name, workspaceIds: data.workspaceIds },
+ pauseConnectorsInRemovedWorkspaces,
+ })
+ : await createFeedbackRecordDirectoryAction({
+ organizationId,
+ name: data.name ?? "",
+ workspaceIds: data.workspaceIds,
+ });
if (response?.data) {
toast.success(
@@ -115,12 +149,54 @@ export const FeedbackRecordDirectorySettingsModal = ({
);
closeModal();
router.refresh();
+ return true;
} else {
const errorCode = getFormattedErrorMessage(response);
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
+ return false;
}
};
+ const handleConfirmPauseAndSubmit = async () => {
+ if (!pendingSubmitData) {
+ return;
+ }
+
+ const wasSuccessful = await submitDirectory(pendingSubmitData, true);
+ if (wasSuccessful) {
+ setConfirmPauseDialogOpen(false);
+ setPendingSubmitData(null);
+ setConnectorsToPauseCount(0);
+ }
+ };
+
+ const handleSubmitForm: SubmitHandler = async (data) => {
+ if (!isEdit || !directory) {
+ await submitDirectory(data, false);
+ return;
+ }
+
+ const updatedWorkspaceIds = data.workspaceIds ?? [];
+ const removedWorkspaceIds = initialWorkspaceIds.filter(
+ (workspaceId) => !updatedWorkspaceIds.includes(workspaceId)
+ );
+
+ if (removedWorkspaceIds.length > 0) {
+ const affectedConnectors = directory.connectors.filter((connector) =>
+ removedWorkspaceIds.includes(connector.workspaceId)
+ );
+
+ if (affectedConnectors.length > 0) {
+ setPendingSubmitData(data);
+ setConnectorsToPauseCount(affectedConnectors.length);
+ setConfirmPauseDialogOpen(true);
+ return;
+ }
+ }
+
+ await submitDirectory(data, false);
+ };
+
return (
);
};
diff --git a/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-table.tsx b/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-table.tsx
index a53e310793..3c46ee4253 100644
--- a/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-table.tsx
+++ b/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-table.tsx
@@ -15,6 +15,7 @@ import { FeedbackRecordDirectorySettingsModal } from "@/modules/ee/feedback-reco
import {
TFeedbackRecordDirectory,
TFeedbackRecordDirectoryDetails,
+ TWorkspaceFeedbackRecordDirectoryAccess,
getTranslatedFeedbackRecordDirectoryError,
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
import { TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/workspace";
@@ -27,6 +28,7 @@ interface FeedbackRecordDirectoryTableProps {
directories: TFeedbackRecordDirectory[];
organizationId: string;
orgWorkspaces: TOrganizationWorkspace[];
+ workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
membershipRole: TOrganizationRole;
}
@@ -34,8 +36,9 @@ export const FeedbackRecordDirectoryTable = ({
directories,
organizationId,
orgWorkspaces,
+ workspaceAccessByWorkspace,
membershipRole,
-}: FeedbackRecordDirectoryTableProps) => {
+}: Readonly) => {
const { t } = useTranslation();
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
@@ -67,6 +70,27 @@ export const FeedbackRecordDirectoryTable = ({
const handleUnarchiveDirectory = async (directoryId: string) => {
setLoadingDirectoryId(directoryId);
try {
+ const directoryDetailsResponse = await getFeedbackRecordDirectoryDetailsAction({ directoryId });
+ if (!directoryDetailsResponse?.data) {
+ const errorCode = getFormattedErrorMessage(directoryDetailsResponse);
+ toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
+ return;
+ }
+
+ const workspaceAccessMap = new Map(
+ workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
+ );
+
+ const hasConflicts = directoryDetailsResponse.data.workspaces.some((workspace) => {
+ const assignment = workspaceAccessMap.get(workspace.workspaceId);
+ return assignment && assignment.feedbackRecordDirectoryId !== directoryId;
+ });
+
+ if (hasConflicts) {
+ toast.error(t("workspace.settings.feedback_record_directories.unarchive_workspace_conflict"));
+ return;
+ }
+
const response = await updateFeedbackRecordDirectoryAction({
directoryId,
data: { isArchived: false },
@@ -166,6 +190,7 @@ export const FeedbackRecordDirectoryTable = ({
setOpen={setOpenCreateModal}
organizationId={organizationId}
orgWorkspaces={orgWorkspaces}
+ workspaceAccessByWorkspace={workspaceAccessByWorkspace}
membershipRole={membershipRole}
/>
)}
@@ -177,6 +202,7 @@ export const FeedbackRecordDirectoryTable = ({
directory={selectedDirectory}
organizationId={organizationId}
orgWorkspaces={orgWorkspaces}
+ workspaceAccessByWorkspace={workspaceAccessByWorkspace}
membershipRole={membershipRole}
/>
)}
diff --git a/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-view.tsx b/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-view.tsx
index b1ad30cbf0..4308fce2da 100644
--- a/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-view.tsx
+++ b/apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-view.tsx
@@ -2,7 +2,10 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { getTranslate } from "@/lingodotdev/server";
import { FeedbackRecordDirectoryTable } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-table";
-import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
+import {
+ getFeedbackRecordDirectories,
+ getWorkspaceFeedbackRecordDirectoryAccess,
+} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspacesByOrganizationId } from "@/modules/ee/teams/team-list/lib/workspace";
interface FeedbackRecordDirectoryViewProps {
@@ -16,9 +19,10 @@ export const FeedbackRecordDirectoryView = async ({
}: FeedbackRecordDirectoryViewProps) => {
const t = await getTranslate();
- const [directories, orgWorkspaces] = await Promise.all([
+ const [directories, orgWorkspaces, workspaceAccessByWorkspace] = await Promise.all([
getFeedbackRecordDirectories(organizationId),
getWorkspacesByOrganizationId(organizationId),
+ getWorkspaceFeedbackRecordDirectoryAccess(organizationId),
]);
return (
@@ -29,6 +33,7 @@ export const FeedbackRecordDirectoryView = async ({
directories={directories}
organizationId={organizationId}
orgWorkspaces={orgWorkspaces}
+ workspaceAccessByWorkspace={workspaceAccessByWorkspace}
membershipRole={membershipRole}
/>
diff --git a/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.test.ts b/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.test.ts
index 3c285175fd..31aa62d8e6 100644
--- a/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.test.ts
+++ b/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.test.ts
@@ -8,6 +8,7 @@ import {
getFeedbackRecordDirectoriesByWorkspaceId,
getFeedbackRecordDirectoryDetails,
getOrganizationIdFromDirectoryId,
+ getWorkspaceFeedbackRecordDirectoryAccess,
updateFeedbackRecordDirectory,
} from "./feedback-record-directory";
@@ -33,6 +34,7 @@ vi.mock("@formbricks/database", () => ({
},
connector: {
count: vi.fn().mockResolvedValue(0),
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
},
}));
@@ -147,7 +149,7 @@ describe("FeedbackRecordDirectory Service", () => {
{
id: "conn-1",
name: "My Connector",
- type: "formbricks",
+ type: "formbricks_survey",
workspaceId: mockWorkspaceId1,
workspace: { name: "Workspace A" },
},
@@ -161,7 +163,7 @@ describe("FeedbackRecordDirectory Service", () => {
{
id: "conn-1",
name: "My Connector",
- type: "formbricks",
+ type: "formbricks_survey",
workspaceId: mockWorkspaceId1,
workspaceName: "Workspace A",
},
@@ -345,6 +347,34 @@ describe("FeedbackRecordDirectory Service", () => {
});
});
+ test("pauses connectors in removed workspaces when requested", async () => {
+ vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
+ mockDirectoryDetailsDbRow as any
+ );
+ vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
+ vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
+
+ const result = await updateFeedbackRecordDirectory(
+ mockDirectoryId,
+ mockOrganizationId,
+ {
+ workspaceIds: [mockWorkspaceId1],
+ },
+ { pauseConnectorsInRemovedWorkspaces: true }
+ );
+
+ expect(result).toBe(true);
+ expect(prisma.connector.updateMany).toHaveBeenCalledWith({
+ where: {
+ feedbackRecordDirectoryId: mockDirectoryId,
+ workspaceId: { in: [mockWorkspaceId2] },
+ },
+ data: {
+ status: "paused",
+ },
+ });
+ });
+
test("throws ResourceNotFoundError when directory does not exist (P2025)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
@@ -446,6 +476,85 @@ describe("FeedbackRecordDirectory Service", () => {
});
});
+ describe("getWorkspaceFeedbackRecordDirectoryAccess", () => {
+ test("returns one active assignment per workspace with directory details", async () => {
+ vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([
+ {
+ workspaceId: mockWorkspaceId1,
+ feedbackRecordDirectory: { id: mockDirectoryId, name: "Directory A" },
+ },
+ {
+ workspaceId: mockWorkspaceId1,
+ feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy2", name: "Directory B" },
+ },
+ {
+ workspaceId: mockWorkspaceId2,
+ feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy3", name: "Directory C" },
+ },
+ ] as any);
+
+ const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
+
+ expect(result).toEqual([
+ {
+ workspaceId: mockWorkspaceId1,
+ feedbackRecordDirectoryId: mockDirectoryId,
+ feedbackRecordDirectoryName: "Directory A",
+ },
+ {
+ workspaceId: mockWorkspaceId2,
+ feedbackRecordDirectoryId: "clj28r6va000409j3ep7h8xy3",
+ feedbackRecordDirectoryName: "Directory C",
+ },
+ ]);
+ expect(prisma.feedbackRecordDirectoryWorkspace.findMany).toHaveBeenCalledWith({
+ where: {
+ feedbackRecordDirectory: {
+ organizationId: mockOrganizationId,
+ isArchived: false,
+ },
+ },
+ select: {
+ workspaceId: true,
+ feedbackRecordDirectory: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
+ });
+ });
+
+ test("returns empty array when no active access assignments exist", async () => {
+ vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([]);
+
+ const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
+
+ expect(result).toEqual([]);
+ });
+
+ test("throws DatabaseError on Prisma error", async () => {
+ const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
+ code: "P2010",
+ clientVersion: "0.0.1",
+ });
+ vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(prismaError);
+
+ await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(
+ DatabaseError
+ );
+ });
+
+ test("re-throws unexpected errors", async () => {
+ const error = new Error("Unexpected");
+ vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(error);
+
+ await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(error);
+ });
+ });
+
describe("getOrganizationIdFromDirectoryId", () => {
test("returns organization ID for a valid directory", async () => {
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce({
diff --git a/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.ts b/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.ts
index 8dea4a256a..2bc1dbeb44 100644
--- a/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.ts
+++ b/apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.ts
@@ -11,6 +11,7 @@ import {
TFeedbackRecordDirectory,
TFeedbackRecordDirectoryDetails,
TFeedbackRecordDirectoryUpdateInput,
+ TWorkspaceFeedbackRecordDirectoryAccess,
ZFeedbackRecordDirectoryUpdateInput,
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
@@ -99,6 +100,55 @@ export const getFeedbackRecordDirectoriesByWorkspaceId = reactCache(
}
);
+/**
+ * Lists active feedback directory access assignments by workspace for an organization.
+ * Each workspace appears once with the first active directory assignment found.
+ */
+export const getWorkspaceFeedbackRecordDirectoryAccess = reactCache(
+ async (organizationId: string): Promise => {
+ validateInputs([organizationId, ZId]);
+ try {
+ const rows = await prisma.feedbackRecordDirectoryWorkspace.findMany({
+ where: {
+ feedbackRecordDirectory: {
+ organizationId,
+ isArchived: false,
+ },
+ },
+ select: {
+ workspaceId: true,
+ feedbackRecordDirectory: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
+ });
+
+ const accessByWorkspaceId = new Map();
+
+ for (const row of rows) {
+ if (!accessByWorkspaceId.has(row.workspaceId)) {
+ accessByWorkspaceId.set(row.workspaceId, {
+ workspaceId: row.workspaceId,
+ feedbackRecordDirectoryId: row.feedbackRecordDirectory.id,
+ feedbackRecordDirectoryName: row.feedbackRecordDirectory.name,
+ });
+ }
+ }
+
+ return Array.from(accessByWorkspaceId.values());
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
+ throw new DatabaseError(error.message);
+ }
+ throw error;
+ }
+ }
+);
+
export const getFeedbackRecordDirectoryDetails = reactCache(
async (directoryId: string): Promise => {
validateInputs([directoryId, ZId]);
@@ -238,7 +288,10 @@ const buildWorkspaceAssignmentPayload = async (
workspaceIds: string[],
organizationId: string,
currentWorkspaceIds: string[]
-): Promise => {
+): Promise<{
+ payload: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
+ deletedWorkspaceIds: string[];
+}> => {
if (workspaceIds.length > 0) {
const orgWorkspacesCount = await prismaClient.workspace.count({
where: {
@@ -254,22 +307,94 @@ const buildWorkspaceAssignmentPayload = async (
const deletedWorkspaceIds = currentWorkspaceIds.filter((id) => !workspaceIds.includes(id));
return {
- deleteMany: {
- workspaceId: { in: deletedWorkspaceIds },
- },
- upsert: workspaceIds.map((workspaceId) => ({
- where: {
- feedbackRecordDirectoryId_workspaceId: {
- feedbackRecordDirectoryId: directoryId,
- workspaceId,
- },
+ payload: {
+ deleteMany: {
+ workspaceId: { in: deletedWorkspaceIds },
},
- update: {},
- create: { workspaceId },
- })),
+ upsert: workspaceIds.map((workspaceId) => ({
+ where: {
+ feedbackRecordDirectoryId_workspaceId: {
+ feedbackRecordDirectoryId: directoryId,
+ workspaceId,
+ },
+ },
+ update: {},
+ create: { workspaceId },
+ })),
+ },
+ deletedWorkspaceIds,
};
};
+interface UpdateFeedbackRecordDirectoryOptions {
+ pauseConnectorsInRemovedWorkspaces?: boolean;
+}
+
+const getArchiveUpdate = async (
+ directoryId: string,
+ isArchived: boolean | undefined
+): Promise> => {
+ if (isArchived === true) {
+ const connectorCount = await prisma.connector.count({
+ where: { feedbackRecordDirectoryId: directoryId },
+ });
+ if (connectorCount > 0) {
+ throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
+ }
+ return { isArchived: true };
+ }
+
+ if (isArchived === false) {
+ return { isArchived: false };
+ }
+
+ return {};
+};
+
+const getWorkspaceAssignmentUpdate = async (
+ directoryId: string,
+ organizationId: string,
+ workspaceIds: string[] | undefined
+): Promise<{
+ workspaces?: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
+ removedWorkspaceIds: string[];
+}> => {
+ if (workspaceIds === undefined) {
+ return { removedWorkspaceIds: [] };
+ }
+
+ const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
+ const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? [];
+ const assignmentPayload = await buildWorkspaceAssignmentPayload(
+ prisma,
+ directoryId,
+ workspaceIds,
+ organizationId,
+ currentWorkspaceIds
+ );
+
+ return {
+ workspaces: assignmentPayload.payload,
+ removedWorkspaceIds: assignmentPayload.deletedWorkspaceIds,
+ };
+};
+
+const pauseConnectorsInWorkspaces = async (directoryId: string, workspaceIds: string[]): Promise => {
+ if (workspaceIds.length === 0) {
+ return;
+ }
+
+ await prisma.connector.updateMany({
+ where: {
+ feedbackRecordDirectoryId: directoryId,
+ workspaceId: { in: workspaceIds },
+ },
+ data: {
+ status: "paused",
+ },
+ });
+};
+
/**
* Updates a feedback record directory. Supports partial updates for name, workspace
* assignments, and archive status.
@@ -291,49 +416,36 @@ const buildWorkspaceAssignmentPayload = async (
export const updateFeedbackRecordDirectory = async (
directoryId: string,
organizationId: string,
- data: TFeedbackRecordDirectoryUpdateInput
+ data: TFeedbackRecordDirectoryUpdateInput,
+ options?: UpdateFeedbackRecordDirectoryOptions
): Promise => {
validateInputs([directoryId, ZId], [organizationId, ZId], [data, ZFeedbackRecordDirectoryUpdateInput]);
try {
const { name, workspaceIds, isArchived } = data;
- const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {};
+ const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
+ const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
+ directoryId,
+ organizationId,
+ workspaceIds
+ );
- if (name !== undefined) {
- payload.name = name;
- }
-
- if (isArchived === true) {
- const connectorCount = await prisma.connector.count({
- where: { feedbackRecordDirectoryId: directoryId },
- });
- if (connectorCount > 0) {
- throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
- }
- payload.isArchived = true;
- } else if (isArchived === false) {
- payload.isArchived = false;
- }
-
- if (workspaceIds !== undefined) {
- const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
- const currentWorkspaceIds = currentDetails?.workspaces.map((p) => p.workspaceId) ?? [];
-
- payload.workspaces = await buildWorkspaceAssignmentPayload(
- prisma,
- directoryId,
- workspaceIds,
- organizationId,
- currentWorkspaceIds
- );
- }
+ const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {
+ ...(name !== undefined ? { name } : {}),
+ ...archiveUpdate,
+ ...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
+ };
await prisma.feedbackRecordDirectory.update({
where: { id: directoryId },
data: payload,
});
+ if (options?.pauseConnectorsInRemovedWorkspaces) {
+ await pauseConnectorsInWorkspaces(directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
+ }
+
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
diff --git a/apps/web/modules/ee/feedback-record-directory/types/feedback-record-directory.ts b/apps/web/modules/ee/feedback-record-directory/types/feedback-record-directory.ts
index d1f9c87126..db8372e8b2 100644
--- a/apps/web/modules/ee/feedback-record-directory/types/feedback-record-directory.ts
+++ b/apps/web/modules/ee/feedback-record-directory/types/feedback-record-directory.ts
@@ -35,6 +35,12 @@ export const ZFeedbackRecordDirectoryDetails = z.object({
export type TFeedbackRecordDirectoryDetails = z.infer;
+export interface TWorkspaceFeedbackRecordDirectoryAccess {
+ workspaceId: string;
+ feedbackRecordDirectoryId: string;
+ feedbackRecordDirectoryName: string;
+}
+
export const ZFeedbackRecordDirectoryCreateInput = z.object({
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
workspaceIds: z.array(ZId).optional(),
diff --git a/apps/web/modules/ui/components/multi-select/index.tsx b/apps/web/modules/ui/components/multi-select/index.tsx
index e4417a9333..ff32625ef6 100644
--- a/apps/web/modules/ui/components/multi-select/index.tsx
+++ b/apps/web/modules/ui/components/multi-select/index.tsx
@@ -11,6 +11,7 @@ import { cn } from "@/modules/ui/lib/utils";
interface TOption {
value: T;
label: string;
+ disabled?: boolean;
}
interface MultiSelectProps["value"][]> {
@@ -225,17 +226,18 @@ export function MultiSelect["value"][]>(
{selectableOptions.map((option) => (
{
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
- if (disabled) return;
+ if (disabled || option.disabled) return;
isUserInitiatedRef.current = true; // Mark as user-initiated
setSelected((prev) => [...prev, option]);
setInputValue("");
}}
- className="cursor-pointer">
+ className={option.disabled ? "cursor-not-allowed" : "cursor-pointer"}>
{option.label}
))}
diff --git a/apps/web/modules/ui/components/secondary-navigation/index.tsx b/apps/web/modules/ui/components/secondary-navigation/index.tsx
index 834160fd71..da5018199f 100644
--- a/apps/web/modules/ui/components/secondary-navigation/index.tsx
+++ b/apps/web/modules/ui/components/secondary-navigation/index.tsx
@@ -4,7 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components
interface TSecondaryNavItem {
id: string;
- label: string;
+ label: React.ReactNode;
href?: string;
onClick?: (event: React.MouseEvent) => void;
hidden?: boolean;
diff --git a/apps/web/modules/workspaces/components/create-workspace-modal/index.tsx b/apps/web/modules/workspaces/components/create-workspace-modal/index.tsx
index 6bca9c4256..4e085de25b 100644
--- a/apps/web/modules/workspaces/components/create-workspace-modal/index.tsx
+++ b/apps/web/modules/workspaces/components/create-workspace-modal/index.tsx
@@ -31,11 +31,22 @@ import {
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
-import { getTeamsByOrganizationIdAction } from "@/modules/workspaces/settings/actions";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/modules/ui/components/select";
+import {
+ getFeedbackRecordDirectoriesByOrganizationIdAction,
+ getTeamsByOrganizationIdAction,
+} from "@/modules/workspaces/settings/actions";
const ZCreateWorkspaceForm = z.object({
name: ZWorkspace.shape.name,
teamIds: z.array(z.string()).optional(),
+ feedbackRecordDirectoryId: z.string().optional(),
});
type TCreateWorkspaceForm = z.infer;
@@ -57,27 +68,51 @@ export const CreateWorkspaceModal = ({
const router = useRouter();
const [organizationTeams, setOrganizationTeams] = useState([]);
-
- useEffect(() => {
- const fetchOrganizationTeams = async () => {
- const response = await getTeamsByOrganizationIdAction({ organizationId });
- if (response?.data) {
- setOrganizationTeams(response.data);
- } else {
- const errorMessage = getFormattedErrorMessage(response);
- toast.error(errorMessage);
- }
- };
- fetchOrganizationTeams();
- }, [organizationId]);
+ const [feedbackDirectories, setFeedbackDirectories] = useState<{ id: string; name: string }[]>([]);
const form = useForm({
resolver: zodResolver(ZCreateWorkspaceForm),
defaultValues: {
name: "",
teamIds: [],
+ feedbackRecordDirectoryId: undefined,
},
});
+ const { getValues, setValue } = form;
+
+ useEffect(() => {
+ const fetchModalData = async () => {
+ const [teamsResponse, directoriesResponse] = await Promise.all([
+ getTeamsByOrganizationIdAction({ organizationId }),
+ getFeedbackRecordDirectoriesByOrganizationIdAction({ organizationId }),
+ ]);
+
+ if (teamsResponse?.data) {
+ setOrganizationTeams(teamsResponse.data);
+ } else {
+ const errorMessage = getFormattedErrorMessage(teamsResponse);
+ toast.error(errorMessage);
+ }
+
+ if (directoriesResponse?.data) {
+ setFeedbackDirectories(directoriesResponse.data);
+ const selectedFeedbackDirectory = getValues("feedbackRecordDirectoryId");
+ const isSelectedDirectoryAvailable = directoriesResponse.data.some(
+ (directory) => directory.id === selectedFeedbackDirectory
+ );
+
+ if (directoriesResponse.data.length === 0) {
+ setValue("feedbackRecordDirectoryId", undefined);
+ } else if (!selectedFeedbackDirectory || !isSelectedDirectoryAvailable) {
+ setValue("feedbackRecordDirectoryId", directoriesResponse.data[0].id);
+ }
+ } else {
+ const errorMessage = getFormattedErrorMessage(directoriesResponse);
+ toast.error(errorMessage);
+ }
+ };
+ fetchModalData();
+ }, [organizationId, getValues, setValue]);
const { isSubmitting } = form.formState;
@@ -92,6 +127,7 @@ export const CreateWorkspaceModal = ({
data: {
name: data.name,
teamIds: data.teamIds || [],
+ feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
});
@@ -138,6 +174,40 @@ export const CreateWorkspaceModal = ({
)}
/>
+ (
+
+ {t("workspace.unify.feedback_record_directory")}
+
+
+
+ {error?.message && {error.message}}
+
+ )}
+ />
+
{isAccessControlAllowed && organizationTeams.length > 0 && (
{
+ await checkAuthorizationUpdated({
+ userId: ctx.user.id,
+ organizationId: parsedInput.organizationId,
+ access: [
+ {
+ type: "organization",
+ roles: ["owner", "manager"],
+ },
+ ],
+ });
+
+ const directories = await getFeedbackRecordDirectories(parsedInput.organizationId);
+ return directories.filter((directory) => !directory.isArchived).map(({ id, name }) => ({ id, name }));
+ });
diff --git a/apps/web/modules/workspaces/settings/components/workspace-config-navigation.tsx b/apps/web/modules/workspaces/settings/components/workspace-config-navigation.tsx
index f4c25e06b1..a987ae7e52 100644
--- a/apps/web/modules/workspaces/settings/components/workspace-config-navigation.tsx
+++ b/apps/web/modules/workspaces/settings/components/workspace-config-navigation.tsx
@@ -1,6 +1,14 @@
"use client";
-import { BlocksIcon, BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
+import {
+ BlocksIcon,
+ BrushIcon,
+ LanguagesIcon,
+ ListChecksIcon,
+ ShapesIcon,
+ TagIcon,
+ UsersIcon,
+} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
@@ -39,6 +47,13 @@ export const WorkspaceConfigNavigation = ({ activeId, loading }: WorkspaceConfig
href: `${workspaceBasePath}/app-connection`,
current: pathname?.includes("/app-connection"),
},
+ {
+ id: "feedback-sources",
+ label: t("workspace.unify.feedback_sources"),
+ icon: ,
+ href: `${workspaceBasePath}/feedback-sources`,
+ current: pathname?.includes("/feedback-sources"),
+ },
{
id: "integrations",
label: t("common.integrations"),
diff --git a/apps/web/modules/workspaces/settings/lib/workspace.test.ts b/apps/web/modules/workspaces/settings/lib/workspace.test.ts
index 63d89ff8c7..03a54ca125 100644
--- a/apps/web/modules/workspaces/settings/lib/workspace.test.ts
+++ b/apps/web/modules/workspaces/settings/lib/workspace.test.ts
@@ -40,6 +40,7 @@ vi.mock("@formbricks/database", () => ({
},
feedbackRecordDirectory: {
upsert: vi.fn(),
+ findFirst: vi.fn(),
},
feedbackRecordDirectoryWorkspace: {
count: vi.fn(),
@@ -136,6 +137,34 @@ describe("workspace lib", () => {
});
});
+ test("creates workspace and links selected feedback directory when provided", async () => {
+ const createdWorkspace = { ...baseWorkspace, id: "p-selected" };
+ vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce({
+ id: "frd-selected",
+ } as any);
+ vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
+ vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
+
+ const result = await createWorkspace("org1", {
+ name: "Workspace with Selected Directory",
+ feedbackRecordDirectoryId: "frd-selected",
+ });
+
+ expect(result).toEqual(createdWorkspace);
+ expect(prisma.feedbackRecordDirectory.findFirst).toHaveBeenCalledWith({
+ where: {
+ id: "frd-selected",
+ organizationId: "org1",
+ isArchived: false,
+ },
+ select: { id: true },
+ });
+ expect(prisma.feedbackRecordDirectoryWorkspace.create).toHaveBeenCalledWith({
+ data: { feedbackRecordDirectoryId: "frd-selected", workspaceId: "p-selected" },
+ });
+ expect(prisma.feedbackRecordDirectory.upsert).not.toHaveBeenCalled();
+ });
+
test("skips FRD link when default FRD already has links", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p4" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
@@ -147,6 +176,19 @@ describe("workspace lib", () => {
expect(prisma.feedbackRecordDirectoryWorkspace.create).not.toHaveBeenCalled();
});
+ test("throws InvalidInputError when selected feedback directory is invalid", async () => {
+ vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce(null);
+
+ await expect(
+ createWorkspace("org1", {
+ name: "Workspace with Invalid Directory",
+ feedbackRecordDirectoryId: "frd-missing",
+ })
+ ).rejects.toThrow(InvalidInputError);
+
+ expect(prisma.workspace.create).not.toHaveBeenCalled();
+ });
+
test("throws ValidationError if name is missing", async () => {
await expect(createWorkspace("org1", {})).rejects.toThrow(ValidationError);
});
diff --git a/apps/web/modules/workspaces/settings/lib/workspace.ts b/apps/web/modules/workspaces/settings/lib/workspace.ts
index 217fbb0ea8..25c5630656 100644
--- a/apps/web/modules/workspaces/settings/lib/workspace.ts
+++ b/apps/web/modules/workspaces/settings/lib/workspace.ts
@@ -29,6 +29,14 @@ const selectWorkspace = {
customHeadScripts: true,
};
+type TCreateWorkspaceInput = Partial & {
+ feedbackRecordDirectoryId?: string;
+};
+
+const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.partial().extend({
+ feedbackRecordDirectoryId: ZId.optional(),
+});
+
export const updateWorkspace = async (
workspaceId: string,
inputWorkspace: TWorkspaceUpdateInput
@@ -56,17 +64,32 @@ export const updateWorkspace = async (
export const createWorkspace = async (
organizationId: string,
- workspaceInput: Partial
+ workspaceInput: TCreateWorkspaceInput
): Promise => {
- validateInputs([organizationId, ZString], [workspaceInput, ZWorkspaceUpdateInput.partial()]);
+ validateInputs([organizationId, ZString], [workspaceInput, ZCreateWorkspaceInput]);
if (!workspaceInput.name) {
throw new ValidationError("Workspace Name is required");
}
- const { teamIds, ...data } = workspaceInput;
+ const { teamIds, feedbackRecordDirectoryId, ...data } = workspaceInput;
try {
+ if (feedbackRecordDirectoryId) {
+ const feedbackDirectory = await prisma.feedbackRecordDirectory.findFirst({
+ where: {
+ id: feedbackRecordDirectoryId,
+ organizationId,
+ isArchived: false,
+ },
+ select: { id: true },
+ });
+
+ if (!feedbackDirectory) {
+ throw new InvalidInputError("FEEDBACK_RECORD_DIRECTORY_NOT_FOUND");
+ }
+ }
+
const workspace = await prisma.workspace.create({
data: {
config: {
@@ -89,6 +112,17 @@ export const createWorkspace = async (
});
}
+ if (feedbackRecordDirectoryId) {
+ await prisma.feedbackRecordDirectoryWorkspace.create({
+ data: {
+ feedbackRecordDirectoryId,
+ workspaceId: workspace.id,
+ },
+ });
+
+ return workspace;
+ }
+
// Ensure default FRD exists + link to first workspace atomically
const defaultFrd = await prisma.feedbackRecordDirectory.upsert({
where: {