diff --git a/apps/web/modules/ee/analysis/charts/components/ai-query-section.tsx b/apps/web/modules/ee/analysis/charts/components/ai-query-section.tsx index 314b3fe5a7..11f61a0d6f 100644 --- a/apps/web/modules/ee/analysis/charts/components/ai-query-section.tsx +++ b/apps/web/modules/ee/analysis/charts/components/ai-query-section.tsx @@ -7,17 +7,25 @@ import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions"; +import { + type TAIUnavailableActionLabelKey, + type TAIUnavailableReason, + getAIUnavailableAction, + getAIUnavailableMessageKey, +} from "@/modules/ee/analysis/charts/lib/ai-availability"; import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis"; import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; +type TAIUnavailableMessageKey = ReturnType; + interface AIQuerySectionProps { workspaceId: string; onChartGenerated: (data: AnalyticsResponse) => void; feedbackDirectoryId: string; isAIAvailable?: boolean; - aiUnavailableReason?: string; + aiUnavailableReason?: TAIUnavailableReason; } export function AIQuerySection({ @@ -31,12 +39,30 @@ export function AIQuerySection({ const [isGenerating, setIsGenerating] = useState(false); const { t } = useTranslation(); - const aiUnavailableMessage = - { - not_in_plan: t("workspace.analysis.charts.ai_not_in_plan"), - not_enabled: t("workspace.analysis.charts.ai_not_enabled"), - instance_not_configured: t("workspace.analysis.charts.ai_instance_not_configured"), - }[aiUnavailableReason ?? ""] ?? t("workspace.analysis.charts.ai_not_available"); + const translateAIUnavailableMessage = (messageKey: TAIUnavailableMessageKey): string => { + switch (messageKey) { + case "workspace.analysis.charts.ai_not_in_plan": + return t("workspace.analysis.charts.ai_not_in_plan"); + case "workspace.analysis.charts.ai_not_enabled": + return t("workspace.analysis.charts.ai_not_enabled"); + case "workspace.analysis.charts.ai_instance_not_configured": + return t("workspace.analysis.charts.ai_instance_not_configured"); + case "workspace.analysis.charts.ai_not_available": + return t("workspace.analysis.charts.ai_not_available"); + } + }; + + const translateAIUnavailableAction = (labelKey: TAIUnavailableActionLabelKey): string => { + switch (labelKey) { + case "workspace.analysis.charts.ai_enable_in_settings": + return t("workspace.analysis.charts.ai_enable_in_settings"); + case "workspace.analysis.charts.ai_upgrade_plan": + return t("workspace.analysis.charts.ai_upgrade_plan"); + } + }; + + const aiUnavailableMessage = translateAIUnavailableMessage(getAIUnavailableMessageKey(aiUnavailableReason)); + const aiUnavailableAction = getAIUnavailableAction(aiUnavailableReason, workspaceId); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -103,18 +129,9 @@ export function AIQuerySection({ {aiUnavailableMessage} - {aiUnavailableReason === "not_enabled" && ( - - {t("workspace.analysis.charts.ai_enable_in_settings")} - - )} - {aiUnavailableReason === "not_in_plan" && ( - - {t("workspace.analysis.charts.ai_upgrade_plan")} + {aiUnavailableAction && ( + + {translateAIUnavailableAction(aiUnavailableAction.labelKey)} )} diff --git a/apps/web/modules/ee/analysis/charts/components/create-chart-button.tsx b/apps/web/modules/ee/analysis/charts/components/create-chart-button.tsx index 9f135dc242..e24a5d61b2 100644 --- a/apps/web/modules/ee/analysis/charts/components/create-chart-button.tsx +++ b/apps/web/modules/ee/analysis/charts/components/create-chart-button.tsx @@ -4,6 +4,7 @@ import { PlusIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog"; +import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; import { Button, type ButtonProps } from "@/modules/ui/components/button"; interface CreateChartButtonProps { @@ -15,7 +16,7 @@ interface CreateChartButtonProps { showIcon?: boolean; buttonProps?: Omit; isAIAvailable?: boolean; - aiUnavailableReason?: string; + aiUnavailableReason?: TAIUnavailableReason; } export function CreateChartButton({ diff --git a/apps/web/modules/ee/analysis/charts/components/create-chart-dialog.tsx b/apps/web/modules/ee/analysis/charts/components/create-chart-dialog.tsx index 96d66d738d..7e25c15575 100644 --- a/apps/web/modules/ee/analysis/charts/components/create-chart-dialog.tsx +++ b/apps/web/modules/ee/analysis/charts/components/create-chart-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view"; +import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis"; export interface CreateChartDialogProps { @@ -13,7 +14,7 @@ export interface CreateChartDialogProps { onSuccess?: () => void; directories: { id: string; name: string }[]; isAIAvailable?: boolean; - aiUnavailableReason?: string; + aiUnavailableReason?: TAIUnavailableReason; } export function CreateChartDialog({ diff --git a/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx b/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx index 5ca1c34e76..3beb4c5f54 100644 --- a/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx +++ b/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx @@ -11,6 +11,7 @@ import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/ import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview"; import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder"; import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog"; +import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types"; import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis"; import { Alert } from "@/modules/ui/components/alert"; @@ -36,7 +37,7 @@ interface CreateChartViewProps { onSuccess?: () => void; directories: { id: string; name: string }[]; isAIAvailable?: boolean; - aiUnavailableReason?: string; + aiUnavailableReason?: TAIUnavailableReason; } export function CreateChartView({ diff --git a/apps/web/modules/ee/analysis/charts/lib/ai-availability.test.ts b/apps/web/modules/ee/analysis/charts/lib/ai-availability.test.ts new file mode 100644 index 0000000000..ee894700fc --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/ai-availability.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { getAIUnavailableAction, getAIUnavailableMessageKey } from "./ai-availability"; + +describe("ai availability helpers", () => { + test("returns the not enabled message key and organization settings action", () => { + expect(getAIUnavailableMessageKey("not_enabled")).toBe("workspace.analysis.charts.ai_not_enabled"); + expect(getAIUnavailableAction("not_enabled", "workspace-1")).toEqual({ + href: "/workspaces/workspace-1/settings/organization/general", + labelKey: "workspace.analysis.charts.ai_enable_in_settings", + }); + }); + + test("returns the not in plan message key and billing action", () => { + expect(getAIUnavailableMessageKey("not_in_plan")).toBe("workspace.analysis.charts.ai_not_in_plan"); + expect(getAIUnavailableAction("not_in_plan", "workspace-1")).toEqual({ + href: "/workspaces/workspace-1/settings/organization/billing", + labelKey: "workspace.analysis.charts.ai_upgrade_plan", + }); + }); + + test("returns the instance not configured message key without an action", () => { + expect(getAIUnavailableMessageKey("instance_not_configured")).toBe( + "workspace.analysis.charts.ai_instance_not_configured" + ); + expect(getAIUnavailableAction("instance_not_configured", "workspace-1")).toBeUndefined(); + }); + + test("returns the generic fallback message key without an action", () => { + expect(getAIUnavailableMessageKey()).toBe("workspace.analysis.charts.ai_not_available"); + expect(getAIUnavailableAction(undefined, "workspace-1")).toBeUndefined(); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/ai-availability.ts b/apps/web/modules/ee/analysis/charts/lib/ai-availability.ts new file mode 100644 index 0000000000..5a98d2f27c --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/ai-availability.ts @@ -0,0 +1,52 @@ +export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured"; +export type TAIUnavailableMessageKey = + | "workspace.analysis.charts.ai_not_in_plan" + | "workspace.analysis.charts.ai_not_enabled" + | "workspace.analysis.charts.ai_instance_not_configured" + | "workspace.analysis.charts.ai_not_available"; +export type TAIUnavailableActionLabelKey = + | "workspace.analysis.charts.ai_enable_in_settings" + | "workspace.analysis.charts.ai_upgrade_plan"; + +interface AIUnavailableAction { + href: string; + labelKey: TAIUnavailableActionLabelKey; +} + +export const AI_UNAVAILABLE_MESSAGE_KEYS: Record< + TAIUnavailableReason, + Exclude +> = { + not_in_plan: "workspace.analysis.charts.ai_not_in_plan", + not_enabled: "workspace.analysis.charts.ai_not_enabled", + instance_not_configured: "workspace.analysis.charts.ai_instance_not_configured", +}; + +export const getAIUnavailableMessageKey = (reason?: TAIUnavailableReason): TAIUnavailableMessageKey => { + if (!reason) { + return "workspace.analysis.charts.ai_not_available"; + } + + return AI_UNAVAILABLE_MESSAGE_KEYS[reason]; +}; + +export const getAIUnavailableAction = ( + reason: TAIUnavailableReason | undefined, + workspaceId: string +): AIUnavailableAction | undefined => { + if (reason === "not_enabled") { + return { + href: `/workspaces/${workspaceId}/settings/organization/general`, + labelKey: "workspace.analysis.charts.ai_enable_in_settings", + }; + } + + if (reason === "not_in_plan") { + return { + href: `/workspaces/${workspaceId}/settings/organization/billing`, + labelKey: "workspace.analysis.charts.ai_upgrade_plan", + }; + } + + return undefined; +}; diff --git a/apps/web/modules/ee/analysis/dashboards/components/add-existing-charts-dialog.tsx b/apps/web/modules/ee/analysis/dashboards/components/add-existing-charts-dialog.tsx index c0608ac122..cdb8e94318 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/add-existing-charts-dialog.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/add-existing-charts-dialog.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getChartsAction } from "@/modules/ee/analysis/charts/actions"; import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button"; +import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions"; import { Button } from "@/modules/ui/components/button"; import { @@ -31,7 +32,7 @@ interface AddExistingChartsDialogProps { existingChartIds: string[]; onSuccess: () => void; isAIAvailable?: boolean; - aiUnavailableReason?: string; + aiUnavailableReason?: TAIUnavailableReason; } interface ChartOption { diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-control-bar.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-control-bar.tsx index d4c3db081f..785bef8fd2 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-control-bar.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-control-bar.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions"; import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog"; import { Button } from "@/modules/ui/components/button"; @@ -22,7 +23,7 @@ interface DashboardControlBarProps { hasChanges: boolean; isReadOnly: boolean; isAIAvailable?: boolean; - aiUnavailableReason?: string; + aiUnavailableReason?: TAIUnavailableReason; onRefresh: () => void; onEditToggle: () => void; onSave: () => void; diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx index cadd319cb9..a2a49ecb6d 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx @@ -11,6 +11,7 @@ import "react-resizable/css/styles.css"; import type { TChartQuery } from "@formbricks/types/analysis"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog"; +import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar"; import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header"; import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget"; @@ -40,7 +41,7 @@ interface DashboardDetailClientProps { directories: { id: string; name: string }[]; isReadOnly: boolean; isAIAvailable: boolean; - aiUnavailableReason?: string; + aiUnavailableReason?: TAIUnavailableReason; } const widgetsToLayout = (widgets: TDashboardWidget[]): LayoutItem[] => { diff --git a/apps/web/modules/ee/feedback-directory/components/feedback-directory-settings/feedback-directory-settings-modal.tsx b/apps/web/modules/ee/feedback-directory/components/feedback-directory-settings/feedback-directory-settings-modal.tsx index 0c0571c73d..06af173f4f 100644 --- a/apps/web/modules/ee/feedback-directory/components/feedback-directory-settings/feedback-directory-settings-modal.tsx +++ b/apps/web/modules/ee/feedback-directory/components/feedback-directory-settings/feedback-directory-settings-modal.tsx @@ -15,6 +15,10 @@ import { updateFeedbackDirectoryAction, } from "@/modules/ee/feedback-directory/actions"; import { ArchiveFeedbackDirectory } from "@/modules/ee/feedback-directory/components/feedback-directory-settings/archive-feedback-directory"; +import { + getWorkspaceConflictDetails, + shouldShowWorkspaceAccessBlockedExplanation, +} from "@/modules/ee/feedback-directory/lib/workspace-access-conflicts"; import { TFeedbackDirectoryDetails, TFeedbackDirectoryUpdateInput, @@ -97,32 +101,18 @@ export const FeedbackDirectorySettingsModal = ({ [orgWorkspaces, workspaceAccessMap, directory?.id] ); - const workspaceConflictDetails = useMemo( - () => - orgWorkspaces - .map((workspace) => { - const assignment = workspaceAccessMap.get(workspace.id); - if (!assignment || assignment.feedbackDirectoryId === directory?.id) { - return null; - } + const workspaceConflictInput = useMemo( + () => ({ + orgWorkspaces, + workspaceAccessByWorkspace, + currentDirectoryId: directory?.id, + }), + [orgWorkspaces, workspaceAccessByWorkspace, directory?.id] + ); - return { - workspaceId: workspace.id, - workspaceName: workspace.name, - feedbackDirectoryName: assignment.feedbackDirectoryName, - }; - }) - .filter( - ( - conflict - ): conflict is { - workspaceId: string; - workspaceName: string; - feedbackDirectoryName: string; - } => conflict !== null - ) - .sort((a, b) => a.workspaceName.localeCompare(b.workspaceName, undefined, { sensitivity: "base" })), - [orgWorkspaces, workspaceAccessMap, directory?.id] + const workspaceConflictDetails = useMemo( + () => getWorkspaceConflictDetails(workspaceConflictInput), + [workspaceConflictInput] ); const initialWorkspaceIds = useMemo( @@ -147,9 +137,8 @@ export const FeedbackDirectorySettingsModal = ({ reset, } = form; const selectedWorkspaceIds = form.watch("workspaceIds") ?? []; - const hasUnassignedWorkspace = workspaceOptions.some((option) => !option.disabled); const showWorkspaceAccessBlockedExplanation = - workspaceConflictDetails.length > 0 && !hasUnassignedWorkspace; + shouldShowWorkspaceAccessBlockedExplanation(workspaceConflictInput); const workspaceNameById = useMemo(() => { const map = new Map(orgWorkspaces.map((workspace) => [workspace.id, workspace.name])); @@ -339,9 +328,7 @@ export const FeedbackDirectorySettingsModal = ({

- {t( - "workspace.settings.feedback_directories.no_unassigned_workspaces_description" - )} + {t("workspace.settings.feedback_directories.no_unassigned_workspaces_description")}

    {workspaceConflictDetails.map((conflict) => ( diff --git a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts index 898486f520..a13f76e864 100644 --- a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts +++ b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts @@ -424,6 +424,19 @@ describe("FeedbackDirectory Service", () => { }); }); + test("throws ResourceNotFoundError when unarchiving and directory cannot be loaded", async () => { + vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(null); + + await expect( + updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, { + isArchived: false, + }) + ).rejects.toThrow(ResourceNotFoundError); + + expect(prisma.feedbackDirectoryWorkspace.findFirst).not.toHaveBeenCalled(); + expect(prisma.feedbackDirectory.update).not.toHaveBeenCalled(); + }); + test("throws InvalidInputError when unarchiving would assign a workspace to two active directories", async () => { vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any); vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({ diff --git a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts index 8ab4f5fc43..a249dd673a 100644 --- a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts +++ b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts @@ -1,5 +1,5 @@ import "server-only"; -import { Prisma, PrismaClient } from "@prisma/client"; +import { Prisma, type PrismaClient } from "@prisma/client"; import { cache as reactCache } from "react"; import { z } from "zod"; import { prisma } from "@formbricks/database"; @@ -15,6 +15,11 @@ import { ZFeedbackDirectoryUpdateInput, } from "@/modules/ee/feedback-directory/types/feedback-directory"; +type FeedbackDirectoryPrismaClient = Pick< + PrismaClient, + "connector" | "feedbackDirectory" | "feedbackDirectoryWorkspace" | "workspace" +>; + /** * Retrieves all feedback directories for a given organization. * @@ -186,63 +191,70 @@ export const getWorkspaceFeedbackDirectoryAccess = reactCache( } ); +const getFeedbackDirectoryDetailsWithClient = async ( + prismaClient: FeedbackDirectoryPrismaClient, + directoryId: string +): Promise => { + const directory = await prismaClient.feedbackDirectory.findUnique({ + where: { + id: directoryId, + }, + select: { + id: true, + name: true, + isArchived: true, + organizationId: true, + workspaces: { + select: { + workspaceId: true, + workspace: { + select: { + name: true, + }, + }, + }, + }, + connectors: { + select: { + id: true, + name: true, + type: true, + workspaceId: true, + workspace: { select: { name: true } }, + }, + orderBy: { createdAt: "desc" }, + }, + }, + }); + + if (!directory) { + return null; + } + + return { + id: directory.id, + name: directory.name, + isArchived: directory.isArchived, + organizationId: directory.organizationId, + workspaces: directory.workspaces.map((dp) => ({ + workspaceId: dp.workspaceId, + workspaceName: dp.workspace.name, + })), + connectors: directory.connectors.map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + workspaceId: c.workspaceId, + workspaceName: c.workspace.name, + })), + }; +}; + export const getFeedbackDirectoryDetails = reactCache( async (directoryId: string): Promise => { validateInputs([directoryId, ZId]); try { - const directory = await prisma.feedbackDirectory.findUnique({ - where: { - id: directoryId, - }, - select: { - id: true, - name: true, - isArchived: true, - organizationId: true, - workspaces: { - select: { - workspaceId: true, - workspace: { - select: { - name: true, - }, - }, - }, - }, - connectors: { - select: { - id: true, - name: true, - type: true, - workspaceId: true, - workspace: { select: { name: true } }, - }, - orderBy: { createdAt: "desc" }, - }, - }, - }); - - if (!directory) { - return null; - } - - return { - id: directory.id, - name: directory.name, - isArchived: directory.isArchived, - organizationId: directory.organizationId, - workspaces: directory.workspaces.map((dp) => ({ - workspaceId: dp.workspaceId, - workspaceName: dp.workspace.name, - })), - connectors: directory.connectors.map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - workspaceId: c.workspaceId, - workspaceName: c.workspace.name, - })), - }; + return await getFeedbackDirectoryDetailsWithClient(prisma, directoryId); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); @@ -279,7 +291,7 @@ export const createFeedbackDirectory = async ( if (count !== workspaceIds.length) { throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"); } - await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds); + await assertWorkspacesNotAssignedElsewhere(prisma, undefined, workspaceIds); } const directory = await prisma.feedbackDirectory.create({ @@ -321,7 +333,7 @@ export const createFeedbackDirectory = async ( * @throws {InvalidInputError} If any workspace does not belong to the organization. */ const buildWorkspaceAssignmentPayload = async ( - prismaClient: PrismaClient, + prismaClient: FeedbackDirectoryPrismaClient, directoryId: string, workspaceIds: string[], organizationId: string, @@ -369,11 +381,12 @@ interface UpdateFeedbackDirectoryOptions { } const getArchiveUpdate = async ( + prismaClient: FeedbackDirectoryPrismaClient, directoryId: string, isArchived: boolean | undefined ): Promise> => { if (isArchived === true) { - const connectorCount = await prisma.connector.count({ + const connectorCount = await prismaClient.connector.count({ where: { feedbackDirectoryId: directoryId }, }); if (connectorCount > 0) { @@ -383,12 +396,13 @@ const getArchiveUpdate = async ( } if (isArchived === false) { - const currentDetails = await getFeedbackDirectoryDetails(directoryId); + const currentDetails = await getFeedbackDirectoryDetailsWithClient(prismaClient, directoryId); if (!currentDetails) { throw new ResourceNotFoundError("FeedbackDirectory", directoryId); } await assertWorkspacesNotAssignedElsewhere( + prismaClient, directoryId, currentDetails.workspaces.map((workspace) => workspace.workspaceId) ); @@ -400,6 +414,7 @@ const getArchiveUpdate = async ( }; const getWorkspaceAssignmentUpdate = async ( + prismaClient: FeedbackDirectoryPrismaClient, directoryId: string, organizationId: string, workspaceIds: string[] | undefined @@ -411,10 +426,10 @@ const getWorkspaceAssignmentUpdate = async ( return { removedWorkspaceIds: [] }; } - const currentDetails = await getFeedbackDirectoryDetails(directoryId); + const currentDetails = await getFeedbackDirectoryDetailsWithClient(prismaClient, directoryId); const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? []; const assignmentPayload = await buildWorkspaceAssignmentPayload( - prisma, + prismaClient, directoryId, workspaceIds, organizationId, @@ -456,12 +471,13 @@ const pauseConnectorsInWorkspaces = async ( * conflict check. Omit it on create — every active directory is a conflict. */ const assertWorkspacesNotAssignedElsewhere = async ( + prismaClient: FeedbackDirectoryPrismaClient, directoryId: string | undefined, workspaceIds: string[] ): Promise => { if (workspaceIds.length === 0) return; - const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({ + const conflicting = await prismaClient.feedbackDirectoryWorkspace.findFirst({ where: { workspaceId: { in: workspaceIds }, ...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }), @@ -504,33 +520,41 @@ export const updateFeedbackDirectory = async ( try { const { name, workspaceIds, isArchived } = data; - if (workspaceIds !== undefined) { - await assertWorkspacesNotAssignedElsewhere(directoryId, workspaceIds); - } + await prisma.$transaction( + async (tx) => { + if (workspaceIds !== undefined) { + await assertWorkspacesNotAssignedElsewhere(tx, directoryId, workspaceIds); + } - const archiveUpdate = await getArchiveUpdate(directoryId, isArchived); - const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate( - directoryId, - organizationId, - workspaceIds - ); + const archiveUpdate = await getArchiveUpdate(tx, directoryId, isArchived); + const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate( + tx, + directoryId, + organizationId, + workspaceIds + ); - const payload: Prisma.FeedbackDirectoryUpdateInput = { - ...(name !== undefined ? { name } : {}), - ...archiveUpdate, - ...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}), - }; + const payload: Prisma.FeedbackDirectoryUpdateInput = { + ...(name !== undefined ? { name } : {}), + ...archiveUpdate, + ...(workspaceAssignmentUpdate.workspaces + ? { workspaces: workspaceAssignmentUpdate.workspaces } + : {}), + }; - await prisma.$transaction(async (tx) => { - await tx.feedbackDirectory.update({ - where: { id: directoryId }, - data: payload, - }); + await tx.feedbackDirectory.update({ + where: { id: directoryId }, + data: payload, + }); - if (options?.pauseConnectorsInRemovedWorkspaces) { - await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds); + if (options?.pauseConnectorsInRemovedWorkspaces) { + await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds); + } + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, } - }); + ); return true; } catch (error) { diff --git a/apps/web/modules/ee/feedback-directory/lib/workspace-access-conflicts.test.ts b/apps/web/modules/ee/feedback-directory/lib/workspace-access-conflicts.test.ts new file mode 100644 index 0000000000..63abc98b27 --- /dev/null +++ b/apps/web/modules/ee/feedback-directory/lib/workspace-access-conflicts.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "vitest"; +import { + getWorkspaceConflictDetails, + hasSelectableWorkspace, + shouldShowWorkspaceAccessBlockedExplanation, +} from "./workspace-access-conflicts"; + +const orgWorkspaces = [ + { id: "workspace-b", name: "Beta" }, + { id: "workspace-a", name: "Alpha" }, +]; + +describe("workspace access conflict helpers", () => { + test("shows conflicts when every workspace is assigned to a different active directory", () => { + const input = { + orgWorkspaces, + workspaceAccessByWorkspace: [ + { + workspaceId: "workspace-b", + feedbackDirectoryId: "directory-2", + feedbackDirectoryName: "Directory B", + }, + { + workspaceId: "workspace-a", + feedbackDirectoryId: "directory-1", + feedbackDirectoryName: "Directory A", + }, + ], + currentDirectoryId: "directory-current", + }; + + expect(getWorkspaceConflictDetails(input)).toEqual([ + { + workspaceId: "workspace-a", + workspaceName: "Alpha", + feedbackDirectoryName: "Directory A", + }, + { + workspaceId: "workspace-b", + workspaceName: "Beta", + feedbackDirectoryName: "Directory B", + }, + ]); + expect(hasSelectableWorkspace(input)).toBe(false); + expect(shouldShowWorkspaceAccessBlockedExplanation(input)).toBe(true); + }); + + test("does not show the blocked explanation when some workspaces are still available", () => { + const input = { + orgWorkspaces, + workspaceAccessByWorkspace: [ + { + workspaceId: "workspace-a", + feedbackDirectoryId: "directory-1", + feedbackDirectoryName: "Directory A", + }, + ], + currentDirectoryId: "directory-current", + }; + + expect(getWorkspaceConflictDetails(input)).toEqual([ + { + workspaceId: "workspace-a", + workspaceName: "Alpha", + feedbackDirectoryName: "Directory A", + }, + ]); + expect(hasSelectableWorkspace(input)).toBe(true); + expect(shouldShowWorkspaceAccessBlockedExplanation(input)).toBe(false); + }); + + test("treats assignments to the current directory as selectable", () => { + const input = { + orgWorkspaces, + workspaceAccessByWorkspace: [ + { + workspaceId: "workspace-a", + feedbackDirectoryId: "directory-current", + feedbackDirectoryName: "Current Directory", + }, + { + workspaceId: "workspace-b", + feedbackDirectoryId: "directory-2", + feedbackDirectoryName: "Directory B", + }, + ], + currentDirectoryId: "directory-current", + }; + + expect(getWorkspaceConflictDetails(input)).toEqual([ + { + workspaceId: "workspace-b", + workspaceName: "Beta", + feedbackDirectoryName: "Directory B", + }, + ]); + expect(hasSelectableWorkspace(input)).toBe(true); + expect(shouldShowWorkspaceAccessBlockedExplanation(input)).toBe(false); + }); +}); diff --git a/apps/web/modules/ee/feedback-directory/lib/workspace-access-conflicts.ts b/apps/web/modules/ee/feedback-directory/lib/workspace-access-conflicts.ts new file mode 100644 index 0000000000..7a7520dd0e --- /dev/null +++ b/apps/web/modules/ee/feedback-directory/lib/workspace-access-conflicts.ts @@ -0,0 +1,70 @@ +interface WorkspaceAccessAssignment { + workspaceId: string; + feedbackDirectoryId: string; + feedbackDirectoryName: string; +} + +interface WorkspaceOptionSource { + id: string; + name: string; +} + +export interface WorkspaceConflictDetail { + workspaceId: string; + workspaceName: string; + feedbackDirectoryName: string; +} + +interface WorkspaceConflictInput { + orgWorkspaces: WorkspaceOptionSource[]; + workspaceAccessByWorkspace: WorkspaceAccessAssignment[]; + currentDirectoryId?: string; +} + +const sortByWorkspaceName = (a: WorkspaceConflictDetail, b: WorkspaceConflictDetail): number => + a.workspaceName.localeCompare(b.workspaceName, undefined, { sensitivity: "base" }); + +export const getWorkspaceConflictDetails = ({ + orgWorkspaces, + workspaceAccessByWorkspace, + currentDirectoryId, +}: WorkspaceConflictInput): WorkspaceConflictDetail[] => { + const workspaceAccessMap = new Map( + workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment]) + ); + + return orgWorkspaces + .flatMap((workspace) => { + const assignment = workspaceAccessMap.get(workspace.id); + if (!assignment || assignment.feedbackDirectoryId === currentDirectoryId) { + return []; + } + + return [ + { + workspaceId: workspace.id, + workspaceName: workspace.name, + feedbackDirectoryName: assignment.feedbackDirectoryName, + }, + ]; + }) + .sort(sortByWorkspaceName); +}; + +export const hasSelectableWorkspace = ({ + orgWorkspaces, + workspaceAccessByWorkspace, + currentDirectoryId, +}: WorkspaceConflictInput): boolean => { + const workspaceAccessMap = new Map( + workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment]) + ); + + return orgWorkspaces.some((workspace) => { + const assignment = workspaceAccessMap.get(workspace.id); + return !assignment || assignment.feedbackDirectoryId === currentDirectoryId; + }); +}; + +export const shouldShowWorkspaceAccessBlockedExplanation = (input: WorkspaceConflictInput): boolean => + getWorkspaceConflictDetails(input).length > 0 && !hasSelectableWorkspace(input); diff --git a/packages/database/src/scripts/generate-data-migration.ts b/packages/database/src/scripts/generate-data-migration.ts index 10d4d81b8d..ee2ba5d27a 100644 --- a/packages/database/src/scripts/generate-data-migration.ts +++ b/packages/database/src/scripts/generate-data-migration.ts @@ -1,8 +1,8 @@ +import { createId } from "@paralleldrive/cuid2"; import fs from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; import { fileURLToPath } from "node:url"; -import { createId } from "@paralleldrive/cuid2"; import { logger } from "@formbricks/logger"; const __filename = fileURLToPath(import.meta.url); diff --git a/packages/database/src/scripts/migration-runner.ts b/packages/database/src/scripts/migration-runner.ts index 780d658ba6..655f17e87b 100644 --- a/packages/database/src/scripts/migration-runner.ts +++ b/packages/database/src/scripts/migration-runner.ts @@ -1,9 +1,9 @@ +import { type Prisma, PrismaClient } from "@prisma/client"; import { exec } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { type Prisma, PrismaClient } from "@prisma/client"; import { logger } from "@formbricks/logger"; const __filename = fileURLToPath(import.meta.url); diff --git a/packages/survey-ui/src/components/general/button.tsx b/packages/survey-ui/src/components/general/button.tsx index 98d71b3768..cf6cb33981 100644 --- a/packages/survey-ui/src/components/general/button.tsx +++ b/packages/survey-ui/src/components/general/button.tsx @@ -5,10 +5,10 @@ import { cn } from "@/lib/utils"; export type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom"; export type ButtonSize = "default" | "custom" | "sm" | "lg" | "icon"; -interface ButtonVariantProps { +type ButtonVariantProps = { variant?: ButtonVariant | null; size?: ButtonSize | null; -} +}; type ButtonVariantClassProps = | (ButtonVariantProps & { class?: string; className?: never }) | (ButtonVariantProps & { class?: never; className?: string })