mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 11:29:31 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c7112e559 |
@@ -23,9 +23,7 @@ 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 ZCreateWorkspaceInput = ZWorkspaceUpdateInput;
|
||||
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
|
||||
@@ -337,15 +337,6 @@ export const MainNavigation = ({
|
||||
href: `/workspaces/${workspace.id}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
];
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
|
||||
@@ -179,15 +179,6 @@ export const OrganizationBreadcrumb = ({
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `${workspaceBasePath}/settings/feedback-record-directories`,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
-7
@@ -42,13 +42,6 @@ export const OrganizationSettingsNavbar = ({
|
||||
href: `${workspaceBasePath}/settings/teams`,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.nav_label"),
|
||||
href: `${workspaceBasePath}/settings/feedback-record-directories`,
|
||||
current: pathname?.includes("/feedback-record-directories"),
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
|
||||
@@ -4,7 +4,6 @@ import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
|
||||
import {
|
||||
@@ -39,13 +38,8 @@ const ensureAccess = async (
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string>> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
return new Set(directories.map((directory) => directory.id));
|
||||
};
|
||||
|
||||
const assertRecordBelongsToWorkspace = (directoryIds: Set<string>, tenantId: string): void => {
|
||||
if (!directoryIds.has(tenantId)) {
|
||||
const assertRecordBelongsToWorkspace = (workspaceId: string, tenantId: string): void => {
|
||||
if (tenantId !== workspaceId) {
|
||||
// Throw a generic error indistinguishable from "not found" to prevent IDOR
|
||||
throw new Error("Feedback record not found");
|
||||
}
|
||||
@@ -61,17 +55,14 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TRetrieveFeedbackRecordAction;
|
||||
}) => {
|
||||
const [, workspaceDirectoryIds] = await Promise.all([
|
||||
ensureAccess(ctx.user.id, parsedInput.workspaceId, "read"),
|
||||
getWorkspaceDirectoryIds(parsedInput.workspaceId),
|
||||
]);
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!recordResult.data || recordResult.error) {
|
||||
throw new Error("Feedback record not found");
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, recordResult.data.tenant_id);
|
||||
assertRecordBelongsToWorkspace(parsedInput.workspaceId, recordResult.data.tenant_id);
|
||||
|
||||
return recordResult.data;
|
||||
}
|
||||
@@ -89,8 +80,7 @@ export const createFeedbackRecordAction = authenticatedActionClient
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
|
||||
assertRecordBelongsToWorkspace(parsedInput.workspaceId, parsedInput.recordInput.tenant_id);
|
||||
|
||||
const { recordInput } = parsedInput;
|
||||
const createParams: FeedbackRecordCreateParams = {
|
||||
@@ -133,17 +123,14 @@ export const updateFeedbackRecordAction = authenticatedActionClient
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUpdateFeedbackRecordAction;
|
||||
}) => {
|
||||
const [, workspaceDirectoryIds] = await Promise.all([
|
||||
ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"),
|
||||
getWorkspaceDirectoryIds(parsedInput.workspaceId),
|
||||
]);
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!currentRecordResult.data || currentRecordResult.error) {
|
||||
throw new Error("Feedback record not found");
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
|
||||
assertRecordBelongsToWorkspace(parsedInput.workspaceId, currentRecordResult.data.tenant_id);
|
||||
|
||||
const { updateInput } = parsedInput;
|
||||
const updateParams: FeedbackRecordUpdateParams = {
|
||||
|
||||
+5
-20
@@ -66,7 +66,6 @@ interface FeedbackRecordFormDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
recordId?: string;
|
||||
onSuccess: () => Promise<void> | void;
|
||||
@@ -77,7 +76,6 @@ export const FeedbackRecordFormDrawer = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
directories,
|
||||
canWrite,
|
||||
recordId,
|
||||
onSuccess,
|
||||
@@ -88,7 +86,7 @@ export const FeedbackRecordFormDrawer = ({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
|
||||
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
|
||||
const defaultValues = useMemo(() => getCreateDefaults(workspaceId), [workspaceId]);
|
||||
|
||||
const form = useForm<TFeedbackRecordFormValues>({
|
||||
resolver: zodResolver(ZFeedbackRecordFormValues),
|
||||
@@ -111,12 +109,12 @@ export const FeedbackRecordFormDrawer = ({
|
||||
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
|
||||
|
||||
const resetForCreate = useCallback(() => {
|
||||
const nextDefaults = getCreateDefaults(directories);
|
||||
const nextDefaults = getCreateDefaults(workspaceId);
|
||||
form.reset(nextDefaults);
|
||||
setRecord(null);
|
||||
setSourceTypeMode(nextDefaults.source_type);
|
||||
setCustomSourceType("");
|
||||
}, [directories, form]);
|
||||
}, [workspaceId, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -361,22 +359,9 @@ export const FeedbackRecordFormDrawer = ({
|
||||
name="tenant_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
|
||||
<FormLabel>{t("workspace.unify.workspace")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("workspace.unify.select_feedback_record_directory")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((directory) => (
|
||||
<SelectItem key={directory.id} value={directory.id}>
|
||||
{directory.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input {...field} disabled />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
|
||||
-3
@@ -10,7 +10,6 @@ import { FeedbackRecordsTable } from "./feedback-records-table";
|
||||
interface FeedbackRecordsPageClientProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
@@ -18,7 +17,6 @@ interface FeedbackRecordsPageClientProps {
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsPageClientProps>) {
|
||||
@@ -33,7 +31,6 @@ export function FeedbackRecordsPageClient({
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
initialRecords={initialRecords}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
|
||||
+8
-27
@@ -12,7 +12,7 @@ import {
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
@@ -63,7 +63,6 @@ function truncate(str: string, maxLen: number): string {
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
@@ -71,7 +70,6 @@ interface FeedbackRecordsTableProps {
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsTableProps>) => {
|
||||
@@ -84,33 +82,19 @@ export const FeedbackRecordsTable = ({
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
Object.entries(frdMap)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[frdMap]
|
||||
);
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
const directoryIds = Object.keys(frdMap);
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((frdId) =>
|
||||
listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
})
|
||||
)
|
||||
);
|
||||
const result = await listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
});
|
||||
|
||||
if (results.some((result) => !result?.data)) {
|
||||
const firstErrorResult = results.find((result) => !result?.data);
|
||||
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
|
||||
if (!result?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
|
||||
id: toastId,
|
||||
});
|
||||
@@ -118,9 +102,7 @@ export const FeedbackRecordsTable = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
|
||||
|
||||
const mergedRecords = successfulRecords
|
||||
const mergedRecords = (result.data.data ?? [])
|
||||
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, RECORDS_PER_PAGE);
|
||||
setRecords(mergedRecords);
|
||||
@@ -268,7 +250,6 @@ export const FeedbackRecordsTable = ({
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
canWrite={canWrite}
|
||||
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
|
||||
onSuccess={handleRefresh}
|
||||
|
||||
+5
-6
@@ -73,17 +73,16 @@ describe("toISOOrUndefined", () => {
|
||||
});
|
||||
|
||||
describe("getCreateDefaults", () => {
|
||||
test("uses first directory as tenant_id", () => {
|
||||
const dirs = [{ id: "dir-1", name: "Dir 1" }];
|
||||
const result = getCreateDefaults(dirs);
|
||||
expect(result.tenant_id).toBe("dir-1");
|
||||
test("uses workspaceId as tenant_id", () => {
|
||||
const result = getCreateDefaults("ws-1");
|
||||
expect(result.tenant_id).toBe("ws-1");
|
||||
expect(result.submission_id).toBe("mock-uuid-v7");
|
||||
expect(result.field_type).toBe("text");
|
||||
expect(result.metadataEntries).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles empty directories", () => {
|
||||
const result = getCreateDefaults([]);
|
||||
test("returns empty string for empty workspaceId", () => {
|
||||
const result = getCreateDefaults("");
|
||||
expect(result.tenant_id).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,13 +50,12 @@ export const toISOOrUndefined = (dateTimeValue: string | undefined): string | un
|
||||
return parsed.toISOString();
|
||||
};
|
||||
|
||||
export const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => {
|
||||
export const getCreateDefaults = (workspaceId: string): TFeedbackRecordFormValues => {
|
||||
const now = new Date();
|
||||
const defaultDirectoryId = directories[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
id: "",
|
||||
tenant_id: defaultDirectoryId,
|
||||
tenant_id: workspaceId,
|
||||
submission_id: uuidv7(),
|
||||
collected_at: toLocalDateTimeInput(now.toISOString()),
|
||||
created_at: "",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { FeedbackRecordsPageClient } from "./components/feedback-records-page-client";
|
||||
@@ -27,24 +26,14 @@ export default async function UnifyFeedbackRecordsPage(
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [frds, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
const [recordsResult, connectors] = await Promise.all([
|
||||
listFeedbackRecords({ tenant_id: params.workspaceId, limit: INITIAL_PAGE_SIZE }),
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
]);
|
||||
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
|
||||
);
|
||||
|
||||
// Don't crash if Hub is unreachable — show empty state
|
||||
const successfulResults = results.filter((r) => !r.error);
|
||||
const initialRecords = recordsResult.error ? [] : (recordsResult.data?.data ?? []);
|
||||
|
||||
const merged = successfulResults
|
||||
.flatMap((r) => r.data?.data ?? [])
|
||||
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, INITIAL_PAGE_SIZE);
|
||||
|
||||
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
|
||||
const csvSources = connectors
|
||||
.filter((connector) => connector.type === "csv")
|
||||
.map((connector) => ({ id: connector.id, name: connector.name }));
|
||||
@@ -52,8 +41,7 @@ export default async function UnifyFeedbackRecordsPage(
|
||||
return (
|
||||
<FeedbackRecordsPageClient
|
||||
workspaceId={params.workspaceId}
|
||||
initialRecords={merged}
|
||||
frdMap={frdMap}
|
||||
initialRecords={initialRecords}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
|
||||
-26
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
updateConnectorWithMappingsAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
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";
|
||||
@@ -28,34 +26,22 @@ interface ConnectorsSectionProps {
|
||||
workspaceId: string;
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
workspaceId,
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const directoryNames = directories.map((directory) => directory.name).join(", ");
|
||||
const feedbackDirectoryAccessText =
|
||||
directories.length === 1
|
||||
? t("workspace.unify.feedback_sources_directory_access_single", {
|
||||
directoryNames,
|
||||
})
|
||||
: t("workspace.unify.feedback_sources_directory_access_multiple", {
|
||||
directoryNames,
|
||||
});
|
||||
|
||||
const handleCreateConnector = async (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}): Promise<string | undefined> => {
|
||||
@@ -64,7 +50,6 @@ export function ConnectorsSection({
|
||||
connectorInput: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
formbricksMappings:
|
||||
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
@@ -187,16 +172,6 @@ export function ConnectorsSection({
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<CreateConnectorModal
|
||||
@@ -205,7 +180,6 @@ export function ConnectorsSection({
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
showTrigger={false}
|
||||
/>
|
||||
|
||||
|
||||
+1
-38
@@ -70,13 +70,11 @@ interface CreateConnectorModalProps {
|
||||
onCreateConnector: (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<string | undefined>;
|
||||
surveys: TUnifySurvey[];
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const getDialogTitle = (
|
||||
@@ -121,7 +119,6 @@ export const CreateConnectorModal = ({
|
||||
onCreateConnector,
|
||||
surveys,
|
||||
workspaceId,
|
||||
directories,
|
||||
}: CreateConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -155,7 +152,6 @@ export const CreateConnectorModal = ({
|
||||
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
|
||||
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
@@ -226,7 +222,6 @@ export const CreateConnectorModal = ({
|
||||
setCsvConnectorName("");
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories[0]?.id ?? null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -329,13 +324,11 @@ export const CreateConnectorModal = ({
|
||||
};
|
||||
|
||||
const handleCreateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (!selectedDirectoryId) return;
|
||||
setIsCreating(true);
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: values.sourceName.trim(),
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
});
|
||||
|
||||
@@ -349,7 +342,7 @@ export const CreateConnectorModal = ({
|
||||
};
|
||||
|
||||
const handleCreateCsvConnector = async () => {
|
||||
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
|
||||
if (!isConnectorNameValid(csvConnectorName)) return;
|
||||
if (csvParsedData.length > 0) {
|
||||
const errors = validateEnumMappings(mappings, csvParsedData);
|
||||
if (errors.length > 0) {
|
||||
@@ -364,7 +357,6 @@ export const CreateConnectorModal = ({
|
||||
const connectorId = await onCreateConnector({
|
||||
name: csvConnectorName.trim(),
|
||||
type: "csv",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
@@ -440,10 +432,6 @@ export const CreateConnectorModal = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
@@ -524,10 +512,6 @@ export const CreateConnectorModal = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<CsvConnectorUI
|
||||
sourceFields={sourceFields}
|
||||
@@ -595,7 +579,6 @@ export const CreateConnectorModal = ({
|
||||
disabled={
|
||||
isCreating ||
|
||||
isImporting ||
|
||||
!selectedDirectoryId ||
|
||||
(selectedType === "formbricks_survey"
|
||||
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
@@ -612,23 +595,3 @@ export const CreateConnectorModal = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface NoFeedbackRecordDirectoryAlertProps {
|
||||
workspaceId: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
|
||||
return (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
getOrganizationIdFromSurveyId,
|
||||
getOrganizationIdFromWorkspaceId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordListParams, FeedbackRecordListResponse } from "@/modules/hub/types";
|
||||
import { importCsvData } from "./csv-import";
|
||||
@@ -172,15 +170,6 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
// Verify FRD belongs to same org
|
||||
const frd = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: { id: parsedInput.connectorInput.feedbackRecordDirectoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (frd?.organizationId !== organizationId) {
|
||||
throw new AuthorizationError("Invalid feedback record directory");
|
||||
}
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
const { formbricksMappings, fieldMappings } = parsedInput;
|
||||
@@ -333,7 +322,6 @@ export const duplicateConnectorAction = authenticatedActionClient
|
||||
{
|
||||
name: `${source.name} (copy)`,
|
||||
type: source.type,
|
||||
feedbackRecordDirectoryId: source.feedbackRecordDirectoryId,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
mappingsInput
|
||||
@@ -476,7 +464,6 @@ export const importCsvDataAction = authenticatedActionClient
|
||||
|
||||
const ZListFeedbackRecordsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
frdId: ZId,
|
||||
limit: z.number().min(1).max(1000).optional(),
|
||||
cursor: z.string().optional(),
|
||||
sourceType: z.string().optional(),
|
||||
@@ -514,14 +501,8 @@ export const listFeedbackRecordsAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
// Verify FRD belongs to workspace's accessible FRDs
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(parsedInput.workspaceId);
|
||||
if (!frds.some((f) => f.id === parsedInput.frdId)) {
|
||||
throw new Error("Feedback record directory not accessible");
|
||||
}
|
||||
|
||||
const params: FeedbackRecordListParams = {
|
||||
tenant_id: parsedInput.frdId,
|
||||
tenant_id: parsedInput.workspaceId,
|
||||
limit: parsedInput.limit ?? 50,
|
||||
};
|
||||
if (parsedInput.cursor) params.cursor = parsedInput.cursor;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const importCsvData = async (
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(
|
||||
csvRows,
|
||||
connector.fieldMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
connector.workspaceId
|
||||
);
|
||||
|
||||
let successes = 0;
|
||||
|
||||
@@ -50,12 +50,7 @@ export const importHistoricalResponses = async (
|
||||
const responses = await getResponses(survey.id, IMPORT_BATCH_SIZE, offset);
|
||||
if (responses.length === 0) break;
|
||||
|
||||
const batch = await processBatch(
|
||||
responses,
|
||||
survey,
|
||||
connector.formbricksMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
);
|
||||
const batch = await processBatch(responses, survey, connector.formbricksMappings, connector.workspaceId);
|
||||
successes += batch.successes;
|
||||
failures += batch.failures;
|
||||
skipped += batch.skipped;
|
||||
|
||||
@@ -56,7 +56,6 @@ function createConnector(
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
feedbackRecordDirectoryId: "frd-1",
|
||||
lastSyncAt: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
@@ -120,7 +119,7 @@ describe("handleConnectorPipeline", () => {
|
||||
mockResponse,
|
||||
mockSurvey,
|
||||
connector.formbricksMappings,
|
||||
"frd-1"
|
||||
"env-1"
|
||||
);
|
||||
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
|
||||
@@ -41,7 +41,7 @@ const processConnector = async (
|
||||
response,
|
||||
survey,
|
||||
connector.formbricksMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
connector.workspaceId
|
||||
);
|
||||
|
||||
if (feedbackRecords.length === 0) {
|
||||
|
||||
@@ -39,7 +39,6 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
|
||||
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
|
||||
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
|
||||
const FRD_ID = "clxxxxxxxxxxxxxxxx004";
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const mockConnector = {
|
||||
@@ -304,7 +303,6 @@ describe("createConnectorWithMappings", () => {
|
||||
const result = await createConnectorWithMappings(ENV_ID, {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
});
|
||||
|
||||
expect(tx.connector.create).toHaveBeenCalledWith(
|
||||
@@ -313,7 +311,6 @@ describe("createConnectorWithMappings", () => {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: ENV_ID,
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -330,7 +327,7 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
|
||||
{ name: "FB", type: "formbricks_survey" },
|
||||
{
|
||||
type: "formbricks_survey",
|
||||
mappings: [
|
||||
@@ -366,7 +363,7 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "CSV", type: "csv", feedbackRecordDirectoryId: FRD_ID },
|
||||
{ name: "CSV", type: "csv" },
|
||||
{
|
||||
type: "field",
|
||||
mappings: [{ sourceFieldId: "col-1", targetFieldId: "value_text" }],
|
||||
@@ -398,7 +395,6 @@ describe("createConnectorWithMappings", () => {
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
@@ -411,9 +407,9 @@ describe("createConnectorWithMappings", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackRecordDirectoryId: FRD_ID })
|
||||
).rejects.toThrow(DatabaseError);
|
||||
await expect(createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv" })).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ const selectConnectorWithMappings = {
|
||||
type: true,
|
||||
status: true,
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
lastSyncAt: true,
|
||||
createdBy: true,
|
||||
creator: { select: { name: true } },
|
||||
@@ -63,7 +62,6 @@ const selectConnector = {
|
||||
type: true,
|
||||
status: true,
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
lastSyncAt: true,
|
||||
createdBy: true,
|
||||
} satisfies Prisma.ConnectorSelect;
|
||||
@@ -238,7 +236,6 @@ export const createConnectorWithMappings = async (
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
workspaceId,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ export type AuditLoggingCtx = {
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
feedbackRecordDirectoryId?: string;
|
||||
};
|
||||
|
||||
export type ActionClientCtx = {
|
||||
|
||||
@@ -213,7 +213,6 @@ export const getChartsAction = authenticatedActionClient
|
||||
const ZExecuteQueryAction = z.object({
|
||||
workspaceId: ZId,
|
||||
query: ZChartQuery,
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
});
|
||||
|
||||
export const executeQueryAction = authenticatedActionClient
|
||||
@@ -230,7 +229,7 @@ export const executeQueryAction = authenticatedActionClient
|
||||
|
||||
validateQueryMembers(parsedInput.query);
|
||||
|
||||
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.feedbackRecordDirectoryId);
|
||||
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.workspaceId);
|
||||
|
||||
try {
|
||||
return await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
@@ -280,7 +279,6 @@ const ZGenerateAIQueryResponse = z.object({
|
||||
const ZGenerateAIChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
prompt: z.string().min(1).max(2000),
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
});
|
||||
|
||||
export const generateAIChartAction = authenticatedActionClient
|
||||
@@ -335,10 +333,7 @@ export const generateAIChartAction = authenticatedActionClient
|
||||
|
||||
validateQueryMembers(cleanQuery as TChartQuery);
|
||||
|
||||
const scopedQuery = injectTenantFilter(
|
||||
cleanQuery as TChartQuery,
|
||||
parsedInput.feedbackRecordDirectoryId
|
||||
);
|
||||
const scopedQuery = injectTenantFilter(cleanQuery as TChartQuery, parsedInput.workspaceId);
|
||||
|
||||
const data = await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ interface AdvancedChartBuilderProps {
|
||||
initialQuery?: TChartQuery;
|
||||
hidePreview?: boolean;
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string | null;
|
||||
runQueryCtaLabel?: string;
|
||||
}
|
||||
|
||||
@@ -84,7 +83,6 @@ export function AdvancedChartBuilder({
|
||||
initialQuery,
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
runQueryCtaLabel,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
@@ -95,11 +93,7 @@ export function AdvancedChartBuilder({
|
||||
initialQuery ? { ...initialState, ...parsedInitial } : initialState
|
||||
);
|
||||
|
||||
const { chartData, query, isLoading, error, runQuery } = useChartQuery(
|
||||
workspaceId,
|
||||
feedbackRecordDirectoryId,
|
||||
initialQuery
|
||||
);
|
||||
const { chartData, query, isLoading, error, runQuery } = useChartQuery(workspaceId, initialQuery);
|
||||
|
||||
const currentQuery = useMemo(() => buildCubeQuery(state), [state]);
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
|
||||
@@ -13,14 +13,9 @@ import { Input } from "@/modules/ui/components/input";
|
||||
interface AIQuerySectionProps {
|
||||
workspaceId: string;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string;
|
||||
}
|
||||
|
||||
export function AIQuerySection({
|
||||
workspaceId,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
}: Readonly<AIQuerySectionProps>) {
|
||||
export function AIQuerySection({ workspaceId, onChartGenerated }: Readonly<AIQuerySectionProps>) {
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
@@ -33,7 +28,6 @@ export function AIQuerySection({
|
||||
const result = await generateAIChartAction({
|
||||
workspaceId,
|
||||
prompt: userQuery.trim(),
|
||||
feedbackRecordDirectoryId,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
|
||||
@@ -13,10 +13,9 @@ interface ChartRowProps {
|
||||
chart: TChartWithCreator;
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readonly<ChartRowProps>) {
|
||||
export function ChartRow({ chart, workspaceId, isReadOnly }: Readonly<ChartRowProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const IconComponent = CHART_TYPE_ICONS[chart.type] ?? BarChart3Icon;
|
||||
@@ -87,7 +86,6 @@ export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readon
|
||||
chartId={chart.id}
|
||||
initialChart={chart}
|
||||
onSuccess={() => setIsEditDialogOpen(false)}
|
||||
directories={directories}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -6,29 +6,20 @@ import { CreateChartButton } from "@/modules/ee/analysis/charts/components/creat
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
interface ChartsListContentProps {
|
||||
chartsPromise: Promise<TChartWithCreator[]>;
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const ChartsListContent = ({
|
||||
chartsPromise,
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
}: Readonly<ChartsListContentProps>) => {
|
||||
const ChartsListContent = ({ chartsPromise, workspaceId, isReadOnly }: Readonly<ChartsListContentProps>) => {
|
||||
const charts = use(chartsPromise);
|
||||
|
||||
return (
|
||||
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
|
||||
);
|
||||
return <ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
interface ChartsListPageProps {
|
||||
@@ -38,13 +29,10 @@ interface ChartsListPageProps {
|
||||
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const [directories, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(workspaceId),
|
||||
const [hasFeedbackRecords, connectors] = await Promise.all([
|
||||
hasWorkspaceFeedbackRecords(workspaceId),
|
||||
getConnectorsWithMappings(workspaceId),
|
||||
]);
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
);
|
||||
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
|
||||
|
||||
return (
|
||||
@@ -53,20 +41,11 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
|
||||
workspaceId={workspaceId}
|
||||
cta={
|
||||
isReadOnly ? undefined : (
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
buttonProps={{ disabled: !hasFeedbackRecords }}
|
||||
/>
|
||||
<CreateChartButton workspaceId={workspaceId} buttonProps={{ disabled: !hasFeedbackRecords }} />
|
||||
)
|
||||
}>
|
||||
{hasFeedbackRecords && chartsPromise ? (
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
<ChartsListContent chartsPromise={chartsPromise} workspaceId={workspaceId} isReadOnly={isReadOnly} />
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
|
||||
)}
|
||||
|
||||
@@ -6,15 +6,9 @@ interface ChartsListProps {
|
||||
charts: TChartWithCreator[];
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export const ChartsList = async ({
|
||||
charts,
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
}: Readonly<ChartsListProps>) => {
|
||||
export const ChartsList = async ({ charts, workspaceId, isReadOnly }: Readonly<ChartsListProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
@@ -32,13 +26,7 @@ export const ChartsList = async ({
|
||||
</p>
|
||||
) : (
|
||||
charts.map((chart) => (
|
||||
<ChartRow
|
||||
key={chart.id}
|
||||
chart={chart}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
<ChartRow key={chart.id} chart={chart} workspaceId={workspaceId} isReadOnly={isReadOnly} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button, type ButtonProps } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateChartButtonProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
autoAddToDashboardId?: string;
|
||||
label?: string;
|
||||
onSuccess?: () => void;
|
||||
@@ -18,7 +17,6 @@ interface CreateChartButtonProps {
|
||||
|
||||
export function CreateChartButton({
|
||||
workspaceId,
|
||||
directories,
|
||||
autoAddToDashboardId,
|
||||
label,
|
||||
onSuccess,
|
||||
@@ -39,7 +37,6 @@ export function CreateChartButton({
|
||||
onOpenChange={setIsDialogOpen}
|
||||
workspaceId={workspaceId}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
directories={directories}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface CreateChartDialogProps {
|
||||
autoAddToDashboardId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function CreateChartDialog({
|
||||
@@ -22,7 +21,6 @@ export function CreateChartDialog({
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartDialogProps>) {
|
||||
return (
|
||||
<CreateChartView
|
||||
@@ -33,7 +31,6 @@ export function CreateChartDialog({
|
||||
initialChart={initialChart}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
@@ -12,7 +11,6 @@ import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manu
|
||||
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
|
||||
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";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -33,7 +31,6 @@ interface CreateChartViewProps {
|
||||
initialChart?: TChartWithCreator;
|
||||
autoAddToDashboardId?: string;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function CreateChartView({
|
||||
@@ -44,7 +41,6 @@ export function CreateChartView({
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!chartId;
|
||||
@@ -61,7 +57,6 @@ export function CreateChartView({
|
||||
handleChartGenerated,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
selectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({
|
||||
open,
|
||||
@@ -71,7 +66,6 @@ export function CreateChartView({
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
@@ -108,7 +102,6 @@ export function CreateChartView({
|
||||
}
|
||||
|
||||
const chartType = selectedChartType ?? (isEditing ? (initialChart?.type ?? DEFAULT_CHART_TYPE) : undefined);
|
||||
const hasSelectedDirectory = !!selectedDirectoryId;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
@@ -130,76 +123,56 @@ export function CreateChartView({
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4">
|
||||
{hasSelectedDirectory ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<AIQuerySection workspaceId={workspaceId} onChartGenerated={handleChartGenerated} />
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<AIQuerySection
|
||||
workspaceId={workspaceId}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
|
||||
{chartType && (
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
runQueryCtaLabel={
|
||||
chartData
|
||||
? t("workspace.analysis.charts.update_chart")
|
||||
: t("workspace.analysis.charts.preview_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isEditing || chartData) && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
|
||||
<Link
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
|
||||
</Link>
|
||||
</div>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
|
||||
{chartType && (
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
runQueryCtaLabel={
|
||||
chartData
|
||||
? t("workspace.analysis.charts.update_chart")
|
||||
: t("workspace.analysis.charts.preview_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isEditing || chartData) && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
@@ -57,7 +57,6 @@ vi.mock("next/navigation", () => ({
|
||||
const { useChartDialog } = await import("./use-chart-dialog");
|
||||
|
||||
const WORKSPACE_ID = "ws-123";
|
||||
const DIRECTORY_ID = "frd-1";
|
||||
const CHART_ID = "chart-1";
|
||||
const NEW_CHART_ID = "chart-new";
|
||||
const DASHBOARD_ID = "dash-1";
|
||||
@@ -66,7 +65,6 @@ const baseProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
workspaceId: WORKSPACE_ID,
|
||||
directories: [{ id: DIRECTORY_ID, name: "Dir 1" }],
|
||||
};
|
||||
|
||||
const sampleChartData = {
|
||||
@@ -287,29 +285,6 @@ describe("useChartDialog", () => {
|
||||
expect(mockToastError).toHaveBeenCalledWith("workspace.analysis.charts.please_select_dashboard");
|
||||
expect(mockAddChartToDashboardAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("toasts when no directory available for new chart creation", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useChartDialog({
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
workspaceId: WORKSPACE_ID,
|
||||
directories: [],
|
||||
})
|
||||
);
|
||||
|
||||
await setHookReady(result);
|
||||
await act(async () => {
|
||||
result.current.setSelectedDashboardId(DASHBOARD_ID);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAddToDashboard();
|
||||
});
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith("workspace.analysis.charts.select_data_source_first");
|
||||
expect(mockCreateChartAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleSaveChart - validation + error paths", () => {
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface UseChartDialogProps {
|
||||
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function useChartDialog({
|
||||
@@ -41,7 +40,6 @@ export function useChartDialog({
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<UseChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -55,7 +53,6 @@ export function useChartDialog({
|
||||
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
|
||||
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -84,7 +81,6 @@ export function useChartDialog({
|
||||
setChartName("");
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,12 +105,10 @@ export function useChartDialog({
|
||||
setChartName(chart.name);
|
||||
setSelectedChartType(resolveChartType(chart.type));
|
||||
setCurrentChartId(chart.id);
|
||||
setSelectedDirectoryId(chart.feedbackRecordDirectoryId);
|
||||
|
||||
const queryResult = await executeQueryAction({
|
||||
workspaceId,
|
||||
query: chart.query,
|
||||
feedbackRecordDirectoryId: chart.feedbackRecordDirectoryId,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -167,11 +161,6 @@ export function useChartDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDirectoryId) {
|
||||
toast.error(t("workspace.analysis.charts.select_data_source_first"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
let newlyCreatedChartId: string | null = null;
|
||||
try {
|
||||
@@ -204,7 +193,6 @@ export function useChartDialog({
|
||||
type: chartData.chartType,
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -264,11 +252,6 @@ export function useChartDialog({
|
||||
const ensureChartForDashboard = async (data: AnalyticsResponse): Promise<string | null> => {
|
||||
if (currentChartId) return currentChartId;
|
||||
|
||||
if (!selectedDirectoryId) {
|
||||
toast.error(t("workspace.analysis.charts.select_data_source_first"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const chartResult = await createChartAction({
|
||||
workspaceId,
|
||||
chartInput: {
|
||||
@@ -276,7 +259,6 @@ export function useChartDialog({
|
||||
type: data.chartType,
|
||||
query: data.query,
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -349,7 +331,6 @@ export function useChartDialog({
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setChartLoadError(null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -378,8 +359,6 @@ export function useChartDialog({
|
||||
isSaving,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
selectedDirectoryId,
|
||||
setSelectedDirectoryId,
|
||||
handleChartGenerated,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
|
||||
@@ -13,11 +13,7 @@ export interface QueryResult {
|
||||
data: TChartDataRow[];
|
||||
}
|
||||
|
||||
export function useChartQuery(
|
||||
workspaceId: string,
|
||||
feedbackRecordDirectoryId: string | null,
|
||||
initialQuery?: TChartQuery
|
||||
) {
|
||||
export function useChartQuery(workspaceId: string, initialQuery?: TChartQuery) {
|
||||
const { t } = useTranslation();
|
||||
const [chartData, setChartData] = useState<TChartDataRow[] | null>(null);
|
||||
const [query, setQuery] = useState<TChartQuery | null>(initialQuery ?? null);
|
||||
@@ -25,12 +21,6 @@ export function useChartQuery(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runQuery = async (cubeQuery: TChartQuery): Promise<QueryResult | null> => {
|
||||
if (!feedbackRecordDirectoryId) {
|
||||
const msg = t("workspace.analysis.charts.select_data_source_first");
|
||||
toast.error(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -38,7 +28,6 @@ export function useChartQuery(
|
||||
const result = await executeQueryAction({
|
||||
workspaceId,
|
||||
query: cubeQuery,
|
||||
feedbackRecordDirectoryId,
|
||||
});
|
||||
|
||||
if (result?.serverError) {
|
||||
|
||||
@@ -119,7 +119,7 @@ export function validateQueryMembers(query: TChartQuery): void {
|
||||
|
||||
/**
|
||||
* Injects a tenant_id filter into a Cube.js query to scope results to a specific
|
||||
* FeedbackRecordDirectory. Called server-side before every query execution.
|
||||
* workspace. Called server-side before every query execution.
|
||||
*/
|
||||
export function injectTenantFilter(query: TChartQuery, tenantId: string): TChartQuery {
|
||||
const tenantFilter: TCubeFilter = {
|
||||
|
||||
@@ -45,20 +45,16 @@ const selectChart = {
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
const mockFeedbackRecordDirectoryId = "frd-abc-123";
|
||||
|
||||
const mockChart = {
|
||||
id: mockChartId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
};
|
||||
@@ -82,7 +78,6 @@ describe("Chart Service", () => {
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
@@ -94,7 +89,6 @@ describe("Chart Service", () => {
|
||||
workspaceId: mockWorkspaceId,
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectChart,
|
||||
@@ -114,7 +108,6 @@ describe("Chart Service", () => {
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
@@ -133,7 +126,6 @@ describe("Chart Service", () => {
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
@@ -371,7 +363,6 @@ describe("Chart Service", () => {
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
creator: { select: { name: true } },
|
||||
|
||||
@@ -22,7 +22,6 @@ export const selectChart = {
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
@@ -39,7 +38,6 @@ export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
createdBy: data.createdBy,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
@@ -156,7 +154,6 @@ export const duplicateChart = async (
|
||||
type: ZChartType.parse(sourceChart.type),
|
||||
query: ZChartQuery.parse(sourceChart.query),
|
||||
config: ZChartConfig.parse(sourceChart.config ?? {}),
|
||||
feedbackRecordDirectoryId: sourceChart.feedbackRecordDirectoryId,
|
||||
createdBy,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,7 +27,6 @@ interface AddExistingChartsDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
dashboardId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
existingChartIds: string[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
@@ -42,7 +41,6 @@ export function AddExistingChartsDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
dashboardId,
|
||||
directories,
|
||||
existingChartIds,
|
||||
onSuccess,
|
||||
}: Readonly<AddExistingChartsDialogProps>) {
|
||||
@@ -151,7 +149,6 @@ export function AddExistingChartsDialog({
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
autoAddToDashboardId={dashboardId}
|
||||
label={t("workspace.analysis.dashboards.create_new_chart")}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
interface DashboardControlBarProps {
|
||||
workspaceId: string;
|
||||
dashboardId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
existingChartIds: string[];
|
||||
isEditing: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -30,7 +29,6 @@ interface DashboardControlBarProps {
|
||||
export const DashboardControlBar = ({
|
||||
workspaceId,
|
||||
dashboardId,
|
||||
directories,
|
||||
existingChartIds,
|
||||
isEditing,
|
||||
isSaving,
|
||||
@@ -133,7 +131,6 @@ export const DashboardControlBar = ({
|
||||
onOpenChange={setIsAddExistingDialogOpen}
|
||||
workspaceId={workspaceId}
|
||||
dashboardId={dashboardId}
|
||||
directories={directories}
|
||||
existingChartIds={existingChartIds}
|
||||
onSuccess={() => {
|
||||
setIsAddExistingDialogOpen(false);
|
||||
|
||||
@@ -28,7 +28,6 @@ interface DashboardDetailClientProps {
|
||||
workspaceId: string;
|
||||
dashboard: TDashboardDetail;
|
||||
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
|
||||
directories: { id: string; name: string }[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
@@ -145,7 +144,6 @@ export function DashboardDetailClient({
|
||||
workspaceId,
|
||||
dashboard,
|
||||
widgetDataPromises,
|
||||
directories,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardDetailClientProps>) {
|
||||
const router = useRouter();
|
||||
@@ -287,7 +285,6 @@ export function DashboardDetailClient({
|
||||
<DashboardControlBar
|
||||
workspaceId={workspaceId}
|
||||
dashboardId={dashboard.id}
|
||||
directories={directories}
|
||||
existingChartIds={widgets.map((w) => w.chartId)}
|
||||
isEditing={isEditing}
|
||||
isSaving={isSaving}
|
||||
@@ -358,7 +355,6 @@ export function DashboardDetailClient({
|
||||
setEditingChartId(null);
|
||||
router.refresh();
|
||||
}}
|
||||
directories={directories}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -60,7 +60,6 @@ vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { DashboardDetailClient } from "../components/dashboard-detail-client";
|
||||
import { getDashboard } from "../lib/dashboards";
|
||||
@@ -16,10 +15,10 @@ interface WidgetQueryResult {
|
||||
|
||||
async function executeWidgetQuery(
|
||||
query: TChartQuery,
|
||||
feedbackRecordDirectoryId: string
|
||||
workspaceId: string
|
||||
): Promise<WidgetQueryResult | { error: string }> {
|
||||
try {
|
||||
const scopedQuery = injectTenantFilter(query, feedbackRecordDirectoryId);
|
||||
const scopedQuery = injectTenantFilter(query, workspaceId);
|
||||
const data = await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
return { data: Array.isArray(data) ? data : [], query };
|
||||
} catch (error) {
|
||||
@@ -35,7 +34,6 @@ export async function DashboardDetailPage({
|
||||
}>) {
|
||||
const { workspaceId, dashboardId } = await params;
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
|
||||
let dashboard;
|
||||
try {
|
||||
@@ -52,10 +50,7 @@ export async function DashboardDetailPage({
|
||||
(w): w is typeof w & { chart: NonNullable<typeof w.chart> } => !!w.chart
|
||||
);
|
||||
for (const widget of widgetsWithCharts) {
|
||||
widgetDataPromises.set(
|
||||
widget.id,
|
||||
executeWidgetQuery(widget.chart.query, widget.chart.feedbackRecordDirectoryId)
|
||||
);
|
||||
widgetDataPromises.set(widget.id, executeWidgetQuery(widget.chart.query, workspaceId));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -63,7 +58,6 @@ export async function DashboardDetailPage({
|
||||
workspaceId={workspaceId}
|
||||
dashboard={dashboard}
|
||||
widgetDataPromises={widgetDataPromises}
|
||||
directories={directories}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,20 +3,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockListFeedbackRecords = vi.fn();
|
||||
const mockGetFeedbackRecordDirectoriesByWorkspaceId = vi.fn();
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
listFeedbackRecords: (...args: any[]) => mockListFeedbackRecords(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/feedback-record-directory/lib/feedback-record-directory", () => ({
|
||||
getFeedbackRecordDirectoriesByWorkspaceId: (...args: any[]) =>
|
||||
mockGetFeedbackRecordDirectoriesByWorkspaceId(...args),
|
||||
}));
|
||||
|
||||
const mockWorkspaceId = "workspace-abc-123";
|
||||
const mockDirectoryId1 = "frd-1";
|
||||
const mockDirectoryId2 = "frd-2";
|
||||
|
||||
const recordsResult = (count: number) => ({
|
||||
data: { data: Array.from({ length: count }, (_, i) => ({ id: `rec-${i}` })) },
|
||||
@@ -33,143 +25,41 @@ const nullDataResult = () => ({
|
||||
error: null,
|
||||
});
|
||||
|
||||
describe("hasFeedbackRecordsInDirectories", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns false for empty directory list without calling the hub", async () => {
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockListFeedbackRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns true when any directory has at least one record", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(0)).mockResolvedValueOnce(recordsResult(1));
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([mockDirectoryId1, mockDirectoryId2]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockListFeedbackRecords).toHaveBeenCalledTimes(2);
|
||||
expect(mockListFeedbackRecords).toHaveBeenCalledWith({ tenant_id: mockDirectoryId1, limit: 1 });
|
||||
expect(mockListFeedbackRecords).toHaveBeenCalledWith({ tenant_id: mockDirectoryId2, limit: 1 });
|
||||
});
|
||||
|
||||
test("returns false when all directories are empty and no errors occur", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(0)).mockResolvedValueOnce(recordsResult(0));
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([mockDirectoryId1, mockDirectoryId2]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when all directories error (unknown availability does not lock flows)", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(errorResult()).mockResolvedValueOnce(errorResult());
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([mockDirectoryId1, mockDirectoryId2]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when one directory errors and the rest are empty", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(0)).mockResolvedValueOnce(errorResult());
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([mockDirectoryId1, mockDirectoryId2]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when records exist even if another directory errored", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(errorResult()).mockResolvedValueOnce(recordsResult(2));
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([mockDirectoryId1, mockDirectoryId2]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("treats null data with no error as zero records", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(nullDataResult());
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([mockDirectoryId1]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for a single directory with records", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(5));
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
const result = await hasFeedbackRecordsInDirectories([mockDirectoryId1]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("queries hub with tenant_id and limit 1 for each directory", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValue(recordsResult(0));
|
||||
const { hasFeedbackRecordsInDirectories } = await import("./feedback-records");
|
||||
|
||||
await hasFeedbackRecordsInDirectories([mockDirectoryId1, mockDirectoryId2]);
|
||||
|
||||
expect(mockListFeedbackRecords).toHaveBeenCalledTimes(2);
|
||||
expect(mockListFeedbackRecords).toHaveBeenNthCalledWith(1, { tenant_id: mockDirectoryId1, limit: 1 });
|
||||
expect(mockListFeedbackRecords).toHaveBeenNthCalledWith(2, { tenant_id: mockDirectoryId2, limit: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasWorkspaceFeedbackRecords", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns false when the workspace has no directories", async () => {
|
||||
mockGetFeedbackRecordDirectoriesByWorkspaceId.mockResolvedValueOnce([]);
|
||||
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
|
||||
|
||||
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetFeedbackRecordDirectoriesByWorkspaceId).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
expect(mockListFeedbackRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns true when at least one workspace directory has records", async () => {
|
||||
mockGetFeedbackRecordDirectoriesByWorkspaceId.mockResolvedValueOnce([
|
||||
{ id: mockDirectoryId1, name: "Dir 1" },
|
||||
{ id: mockDirectoryId2, name: "Dir 2" },
|
||||
]);
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(0)).mockResolvedValueOnce(recordsResult(3));
|
||||
test("returns true when workspace has at least one record", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(3));
|
||||
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
|
||||
|
||||
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockListFeedbackRecords).toHaveBeenCalledWith({ tenant_id: mockWorkspaceId, limit: 1 });
|
||||
});
|
||||
|
||||
test("returns false when all workspace directories are empty", async () => {
|
||||
mockGetFeedbackRecordDirectoriesByWorkspaceId.mockResolvedValueOnce([
|
||||
{ id: mockDirectoryId1, name: "Dir 1" },
|
||||
]);
|
||||
test("returns false when workspace has no records", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(0));
|
||||
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
|
||||
|
||||
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockListFeedbackRecords).toHaveBeenCalledWith({ tenant_id: mockWorkspaceId, limit: 1 });
|
||||
});
|
||||
|
||||
test("propagates the hub-error fallback (returns true) for known directories", async () => {
|
||||
mockGetFeedbackRecordDirectoriesByWorkspaceId.mockResolvedValueOnce([
|
||||
{ id: mockDirectoryId1, name: "Dir 1" },
|
||||
]);
|
||||
test("returns false when data is null with no error", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(nullDataResult());
|
||||
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
|
||||
|
||||
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when Hub returns an error (unknown availability does not lock flows)", async () => {
|
||||
mockListFeedbackRecords.mockResolvedValueOnce(errorResult());
|
||||
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
|
||||
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
import "server-only";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
|
||||
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
|
||||
if (directoryIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
|
||||
const result = await listFeedbackRecords({ tenant_id: workspaceId, limit: 1 });
|
||||
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
|
||||
);
|
||||
|
||||
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
|
||||
if (hasRecords) {
|
||||
if (result.error) {
|
||||
// Do not lock creation flows when record availability is unknown.
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasErrors = results.some((result) => Boolean(result.error));
|
||||
|
||||
// Do not lock creation flows when record availability is unknown.
|
||||
return hasErrors;
|
||||
};
|
||||
|
||||
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
|
||||
return hasFeedbackRecordsInDirectories(directories.map((directory) => directory.id));
|
||||
return (result.data?.data?.length ?? 0) > 0;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ export const ZChartCreateInput = z.object({
|
||||
query: ZChartQuery,
|
||||
config: ZChartConfig,
|
||||
createdBy: ZId,
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
});
|
||||
export type TChartCreateInput = z.infer<typeof ZChartCreateInput>;
|
||||
|
||||
@@ -35,7 +34,6 @@ export const ZChart = z.object({
|
||||
type: ZChartType,
|
||||
query: ZChartQuery,
|
||||
config: ZChartConfig,
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
@@ -314,9 +314,6 @@ export const withAuditLogging = <
|
||||
case "dashboardWidget":
|
||||
targetId = auditLoggingCtx.dashboardWidgetId;
|
||||
break;
|
||||
case "feedbackRecordDirectory":
|
||||
targetId = auditLoggingCtx.feedbackRecordDirectoryId;
|
||||
break;
|
||||
default:
|
||||
targetId = UNKNOWN_DATA;
|
||||
break;
|
||||
|
||||
@@ -28,7 +28,6 @@ export const ZAuditTarget = z.enum([
|
||||
"chart",
|
||||
"dashboard",
|
||||
"dashboardWidget",
|
||||
"feedbackRecordDirectory",
|
||||
]);
|
||||
export const ZAuditAction = z.enum([
|
||||
"created",
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
createFeedbackRecordDirectory,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { ZFeedbackRecordDirectoryUpdateInput } from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
|
||||
const ZCreateFeedbackRecordDirectoryAction = z.object({
|
||||
organizationId: ZId,
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
workspaceIds: z.array(ZId).optional(),
|
||||
});
|
||||
|
||||
export const createFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateFeedbackRecordDirectoryAction)
|
||||
.action(
|
||||
withAuditLogging("created", "feedbackRecordDirectory", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await createFeedbackRecordDirectory(
|
||||
parsedInput.organizationId,
|
||||
parsedInput.name,
|
||||
parsedInput.workspaceIds
|
||||
);
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.feedbackRecordDirectoryId = result;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
...(await getFeedbackRecordDirectoryDetails(result)),
|
||||
};
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZGetFeedbackRecordDirectoryDetailsAction = z.object({
|
||||
directoryId: ZId,
|
||||
});
|
||||
|
||||
export const getFeedbackRecordDirectoryDetailsAction = authenticatedActionClient
|
||||
.inputSchema(ZGetFeedbackRecordDirectoryDetailsAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
});
|
||||
|
||||
const ZUpdateFeedbackRecordDirectoryAction = z.object({
|
||||
directoryId: ZId,
|
||||
data: ZFeedbackRecordDirectoryUpdateInput,
|
||||
pauseConnectorsInRemovedWorkspaces: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateFeedbackRecordDirectoryAction)
|
||||
.action(
|
||||
withAuditLogging("updated", "feedbackRecordDirectory", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.feedbackRecordDirectoryId = parsedInput.directoryId;
|
||||
const oldObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
parsedInput.directoryId,
|
||||
organizationId,
|
||||
parsedInput.data,
|
||||
{
|
||||
pauseConnectorsInRemovedWorkspaces: parsedInput.pauseConnectorsInRemovedWorkspaces,
|
||||
}
|
||||
);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
return result;
|
||||
})
|
||||
);
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateFeedbackRecordDirectoryAction } from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { getTranslatedFeedbackRecordDirectoryError } from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ArchiveFeedbackRecordDirectoryProps {
|
||||
directoryId: string;
|
||||
onArchive: () => void;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const ArchiveFeedbackRecordDirectory = ({
|
||||
directoryId,
|
||||
onArchive,
|
||||
isOwnerOrManager,
|
||||
}: ArchiveFeedbackRecordDirectoryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isArchiveDialogOpen, setIsArchiveDialogOpen] = useState(false);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleArchive = async () => {
|
||||
setIsArchiving(true);
|
||||
|
||||
const response = await updateFeedbackRecordDirectoryAction({ directoryId, data: { isArchived: true } });
|
||||
if (response?.serverError) {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
setIsArchiveDialogOpen(false);
|
||||
setIsArchiving(false);
|
||||
return;
|
||||
}
|
||||
if (response?.data) {
|
||||
toast.success(t("workspace.settings.feedback_record_directories.directory_archived_successfully"));
|
||||
onArchive?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
|
||||
setIsArchiveDialogOpen(false);
|
||||
setIsArchiving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-baseline space-x-2">
|
||||
<TooltipRenderer
|
||||
shouldRender={!isOwnerOrManager}
|
||||
tooltipContent={t("workspace.settings.feedback_record_directories.archive_not_allowed")}
|
||||
className="w-auto">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
className="w-auto"
|
||||
disabled={!isOwnerOrManager}
|
||||
onClick={() => setIsArchiveDialogOpen(true)}>
|
||||
{t("workspace.settings.feedback_record_directories.archive_directory")}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
|
||||
{isArchiveDialogOpen && (
|
||||
<Dialog open={isArchiveDialogOpen} onOpenChange={setIsArchiveDialogOpen}>
|
||||
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<DialogTitle>
|
||||
{t("workspace.settings.feedback_record_directories.archive_directory")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<p>{t("workspace.settings.feedback_record_directories.are_you_sure_you_want_to_archive")}</p>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsArchiveDialogOpen(false)}
|
||||
disabled={isArchiving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleArchive} loading={isArchiving}>
|
||||
{t("workspace.settings.feedback_record_directories.archive")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-368
@@ -1,368 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
createFeedbackRecordDirectoryAction,
|
||||
updateFeedbackRecordDirectoryAction,
|
||||
} from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { ArchiveFeedbackRecordDirectory } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/archive-feedback-record-directory";
|
||||
import {
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/workspace";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import { Muted } from "@/modules/ui/components/typography";
|
||||
|
||||
interface FeedbackRecordDirectorySettingsModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
directory?: TFeedbackRecordDirectoryDetails;
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const FeedbackRecordDirectorySettingsModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
directory,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: Readonly<FeedbackRecordDirectorySettingsModalProps>) => {
|
||||
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<TFeedbackRecordDirectoryUpdateInput | null>(
|
||||
null
|
||||
);
|
||||
const [connectorsToPauseCount, setConnectorsToPauseCount] = useState(0);
|
||||
|
||||
const workspaceAccessMap = useMemo(
|
||||
() => new Map(workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])),
|
||||
[workspaceAccessByWorkspace]
|
||||
);
|
||||
|
||||
const workspaceOptions = useMemo(
|
||||
() =>
|
||||
orgWorkspaces
|
||||
.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, workspaceAccessMap, directory?.id]
|
||||
);
|
||||
|
||||
const initialWorkspaceIds = useMemo(
|
||||
() => directory?.workspaces.map((workspace) => workspace.workspaceId) ?? [],
|
||||
[directory?.workspaces]
|
||||
);
|
||||
|
||||
const form = useForm<TFeedbackRecordDirectoryUpdateInput>({
|
||||
defaultValues: {
|
||||
name: directory?.name ?? "",
|
||||
workspaceIds: initialWorkspaceIds,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZFeedbackRecordDirectoryUpdateInput),
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
reset,
|
||||
} = form;
|
||||
|
||||
const closeModal = () => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
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(
|
||||
isEdit
|
||||
? t("workspace.settings.feedback_record_directories.directory_updated_successfully")
|
||||
: t("workspace.settings.feedback_record_directories.directory_created_successfully")
|
||||
);
|
||||
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<TFeedbackRecordDirectoryUpdateInput> = 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 (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => (newOpen ? setOpen(true) : closeModal())}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle>
|
||||
{isEdit
|
||||
? t("workspace.settings.feedback_record_directories.directory_settings_title", {
|
||||
directoryName: directory.name,
|
||||
})
|
||||
: t("workspace.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? t("workspace.settings.feedback_record_directories.directory_settings_description")
|
||||
: t("workspace.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form className="contents space-y-4" onSubmit={handleSubmit(handleSubmitForm)}>
|
||||
<DialogBody className="flex-grow space-y-6 overflow-y-auto">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("workspace.settings.feedback_record_directories.directory_name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("workspace.settings.feedback_record_directories.directory_name")}
|
||||
{...field}
|
||||
disabled={!isOwnerOrManager}
|
||||
/>
|
||||
</FormControl>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">
|
||||
{getTranslatedFeedbackRecordDirectoryError(error.message, t)}
|
||||
</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("workspace.settings.feedback_record_directories.workspace_access")}</FormLabel>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.assign_workspaces_description")}
|
||||
</Muted>
|
||||
<MultiSelect
|
||||
options={workspaceOptions}
|
||||
value={form.watch("workspaceIds") ?? []}
|
||||
onChange={(selected) => {
|
||||
setValue("workspaceIds", selected, { shouldDirty: true });
|
||||
}}
|
||||
disabled={!isOwnerOrManager}
|
||||
placeholder={t(
|
||||
"workspace.settings.feedback_record_directories.select_workspaces_placeholder"
|
||||
)}
|
||||
containerClassName="focus-within:ring-0 focus-within:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("workspace.unify.connectors")}</FormLabel>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.connectors_description")}
|
||||
</Muted>
|
||||
{directory.connectors.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-slate-200 p-3 text-center text-sm text-slate-400">
|
||||
{t("workspace.settings.feedback_record_directories.no_connectors")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{directory.connectors.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{c.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{c.type} · {c.workspaceName}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="text-xs font-medium text-slate-700 hover:text-slate-900 hover:underline"
|
||||
href={`/workspaces/${c.workspaceId}/feedback-sources`}>
|
||||
{t("common.view")}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{isEdit && (
|
||||
<div className="w-full">
|
||||
<ArchiveFeedbackRecordDirectory
|
||||
directoryId={directory.id}
|
||||
onArchive={closeModal}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button size="default" type="button" variant="outline" onClick={closeModal}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="default" loading={isSubmitting} disabled={!isOwnerOrManager}>
|
||||
{isEdit ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
|
||||
{confirmPauseDialogOpen && (
|
||||
<Dialog open={confirmPauseDialogOpen} onOpenChange={setConfirmPauseDialogOpen}>
|
||||
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<DialogTitle>
|
||||
{t("workspace.settings.feedback_record_directories.pause_connectors_confirmation_title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<p>
|
||||
{t(
|
||||
"workspace.settings.feedback_record_directories.pause_connectors_confirmation_description",
|
||||
{
|
||||
count: connectorsToPauseCount,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
}}
|
||||
disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmPauseAndSubmit} loading={isSubmitting}>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
-211
@@ -1,211 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
getFeedbackRecordDirectoryDetailsAction,
|
||||
updateFeedbackRecordDirectoryAction,
|
||||
} from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { FeedbackRecordDirectorySettingsModal } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal";
|
||||
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";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
interface FeedbackRecordDirectoryTableProps {
|
||||
directories: TFeedbackRecordDirectory[];
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const FeedbackRecordDirectoryTable = ({
|
||||
directories,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: Readonly<FeedbackRecordDirectoryTableProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
const [selectedDirectory, setSelectedDirectory] = useState<TFeedbackRecordDirectoryDetails>();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [loadingDirectoryId, setLoadingDirectoryId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
const handleManageDirectory = async (directoryId: string) => {
|
||||
setLoadingDirectoryId(directoryId);
|
||||
try {
|
||||
const response = await getFeedbackRecordDirectoryDetailsAction({ directoryId });
|
||||
|
||||
if (response?.data) {
|
||||
setSelectedDirectory(response.data);
|
||||
setOpenSettingsModal(true);
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
}
|
||||
} finally {
|
||||
setLoadingDirectoryId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 },
|
||||
});
|
||||
if (response?.data) {
|
||||
toast.success(t("workspace.settings.feedback_record_directories.directory_unarchived_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
}
|
||||
} finally {
|
||||
setLoadingDirectoryId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDirectories = showArchived ? directories : directories.filter((d) => !d.isArchived);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOwnerOrManager && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={showArchived} onCheckedChange={setShowArchived} />
|
||||
<span className="text-sm text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.show_archived")}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setOpenCreateModal(true)}>
|
||||
{t("workspace.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border" aria-label="Feedback record directories list">
|
||||
<Table>
|
||||
<TableHeader role="rowgroup">
|
||||
<TableRow className="bg-slate-100" role="row">
|
||||
<TableHead className="font-medium text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.directory_name")}
|
||||
</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.workspaces")}</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.status")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_tr:last-child]:border-b">
|
||||
{filteredDirectories.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center hover:bg-transparent">
|
||||
{t("workspace.settings.feedback_record_directories.empty_state")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredDirectories.map((directory) => (
|
||||
<TableRow key={directory.id} className="hover:bg-transparent">
|
||||
<TableCell>{directory.name}</TableCell>
|
||||
<TableCell>{directory.workspaceCount}</TableCell>
|
||||
<TableCell>
|
||||
{directory.isArchived ? (
|
||||
<Badge type="gray" size="tiny" text={t("common.archived")} />
|
||||
) : (
|
||||
<Badge type="success" size="tiny" text={t("common.active")} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="flex justify-end gap-2">
|
||||
{isOwnerOrManager && !directory.isArchived && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={loadingDirectoryId === directory.id}
|
||||
disabled={loadingDirectoryId !== null}
|
||||
onClick={() => handleManageDirectory(directory.id)}>
|
||||
{t("common.manage")}
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrManager && directory.isArchived && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={loadingDirectoryId === directory.id}
|
||||
disabled={loadingDirectoryId !== null}
|
||||
onClick={() => handleUnarchiveDirectory(directory.id)}>
|
||||
{t("workspace.settings.feedback_record_directories.unarchive")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{openCreateModal && (
|
||||
<FeedbackRecordDirectorySettingsModal
|
||||
open={openCreateModal}
|
||||
setOpen={setOpenCreateModal}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openSettingsModal && selectedDirectory && (
|
||||
<FeedbackRecordDirectorySettingsModal
|
||||
open={openSettingsModal}
|
||||
setOpen={setOpenSettingsModal}
|
||||
directory={selectedDirectory}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspacesByOrganizationId } from "@/modules/ee/teams/team-list/lib/workspace";
|
||||
|
||||
interface FeedbackRecordDirectoryViewProps {
|
||||
organizationId: string;
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const FeedbackRecordDirectoryView = async ({
|
||||
organizationId,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectoryViewProps) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const [directories, orgWorkspaces, workspaceAccessByWorkspace] = await Promise.all([
|
||||
getFeedbackRecordDirectories(organizationId),
|
||||
getWorkspacesByOrganizationId(organizationId),
|
||||
getWorkspaceFeedbackRecordDirectoryAccess(organizationId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.feedback_record_directories.title")}
|
||||
description={t("workspace.settings.feedback_record_directories.description")}>
|
||||
<FeedbackRecordDirectoryTable
|
||||
directories={directories}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface FrdPickerProps {
|
||||
directories: { id: string; name: string }[];
|
||||
selectedDirectoryId: string | null;
|
||||
onChange: (id: string) => void;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const FrdPicker = ({ directories, selectedDirectoryId, onChange, workspaceId }: FrdPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (directories.length === 0) {
|
||||
return (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (directories.length === 1) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.analysis.charts.data_source")}</Label>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{directories[0].name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<Label htmlFor="feedbackRecordDirectory">{t("workspace.analysis.charts.data_source")}</Label>
|
||||
<Select value={selectedDirectoryId ?? ""} onValueChange={onChange}>
|
||||
<SelectTrigger id="feedbackRecordDirectory">
|
||||
<SelectValue placeholder={t("workspace.analysis.charts.select_data_source")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,582 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createFeedbackRecordDirectory,
|
||||
getFeedbackRecordDirectories,
|
||||
getFeedbackRecordDirectoriesByWorkspaceId,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "./feedback-record-directory";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => {
|
||||
const prismaMock = {
|
||||
feedbackRecordDirectory: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectoryWorkspace: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
workspace: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
connector: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
$transaction: vi.fn(async (cb: (tx: unknown) => Promise<unknown>) => cb(prismaMock)),
|
||||
};
|
||||
return { prisma: prismaMock };
|
||||
});
|
||||
|
||||
const mockDirectoryId = "clj28r6va000409j3ep7h8xzk";
|
||||
const mockOrganizationId = "clj28r6va000409j3ep7h8xyz";
|
||||
const mockWorkspaceId1 = "clj28r6va000409j3ep7h8ab1";
|
||||
const mockWorkspaceId2 = "clj28r6va000409j3ep7h8ab2";
|
||||
|
||||
const mockDirectoryDbRow = {
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
_count: { workspaces: 2, connectors: 1 },
|
||||
};
|
||||
|
||||
const mockDirectoryDetailsDbRow = {
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
organizationId: mockOrganizationId,
|
||||
workspaces: [
|
||||
{ workspaceId: mockWorkspaceId1, workspace: { name: "Workspace A" } },
|
||||
{ workspaceId: mockWorkspaceId2, workspace: { name: "Workspace B" } },
|
||||
],
|
||||
connectors: [],
|
||||
};
|
||||
|
||||
describe("FeedbackRecordDirectory Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getFeedbackRecordDirectories", () => {
|
||||
test("returns directories with workspace counts", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockResolvedValueOnce([mockDirectoryDbRow] as any);
|
||||
|
||||
const result = await getFeedbackRecordDirectories(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
workspaceCount: 2,
|
||||
connectorCount: 1,
|
||||
},
|
||||
]);
|
||||
expect(prisma.feedbackRecordDirectory.findMany).toHaveBeenCalledWith({
|
||||
where: { organizationId: mockOrganizationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isArchived: true,
|
||||
_count: { select: { workspaces: true, connectors: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no directories exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getFeedbackRecordDirectories(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getFeedbackRecordDirectories(mockOrganizationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected error");
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getFeedbackRecordDirectories(mockOrganizationId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeedbackRecordDirectoryDetails", () => {
|
||||
test("returns directory details with workspace assignments", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
|
||||
const result = await getFeedbackRecordDirectoryDetails(mockDirectoryId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
organizationId: mockOrganizationId,
|
||||
workspaces: [
|
||||
{ workspaceId: mockWorkspaceId1, workspaceName: "Workspace A" },
|
||||
{ workspaceId: mockWorkspaceId2, workspaceName: "Workspace B" },
|
||||
],
|
||||
connectors: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns directory details with connectors", async () => {
|
||||
const dbRowWithConnectors = {
|
||||
...mockDirectoryDetailsDbRow,
|
||||
connectors: [
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspace: { name: "Workspace A" },
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(dbRowWithConnectors as any);
|
||||
|
||||
const result = await getFeedbackRecordDirectoryDetails(mockDirectoryId);
|
||||
|
||||
expect(result?.connectors).toEqual([
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspaceName: "Workspace A",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null when directory not found", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getFeedbackRecordDirectoryDetails(mockDirectoryId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getFeedbackRecordDirectoryDetails(mockDirectoryId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordDirectory", () => {
|
||||
test("creates a directory and returns its ID", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecordDirectory(mockOrganizationId, "New Directory");
|
||||
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
expect(prisma.feedbackRecordDirectory.create).toHaveBeenCalledWith({
|
||||
data: { name: "New Directory", organizationId: mockOrganizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a directory with workspace links", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(2);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecordDirectory(mockOrganizationId, "With Workspaces", [
|
||||
mockWorkspaceId1,
|
||||
mockWorkspaceId2,
|
||||
]);
|
||||
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
expect(prisma.workspace.count).toHaveBeenCalledWith({
|
||||
where: { id: { in: [mockWorkspaceId1, mockWorkspaceId2] }, organizationId: mockOrganizationId },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectory.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "With Workspaces",
|
||||
organizationId: mockOrganizationId,
|
||||
workspaces: {
|
||||
create: [{ workspaceId: mockWorkspaceId1 }, { workspaceId: mockWorkspaceId2 }],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when workspaceIds belong to different org", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
createFeedbackRecordDirectory(mockOrganizationId, "Bad Workspaces", [mockWorkspaceId1])
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(createFeedbackRecordDirectory(mockOrganizationId, "Duplicate")).rejects.toThrow(
|
||||
new InvalidInputError("DIRECTORY_NAME_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(createFeedbackRecordDirectory(mockOrganizationId, "Test")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected");
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(createFeedbackRecordDirectory(mockOrganizationId, "Test")).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateFeedbackRecordDirectory", () => {
|
||||
test("updates directory name", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.feedbackRecordDirectory.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
data: { name: "Updated Name" },
|
||||
});
|
||||
});
|
||||
|
||||
test("archives directory when no connectors linked", async () => {
|
||||
vi.mocked(prisma.connector.count).mockResolvedValueOnce(0);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
isArchived: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.connector.count).toHaveBeenCalledWith({
|
||||
where: { feedbackRecordDirectoryId: mockDirectoryId },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectory.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
data: { isArchived: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when archiving directory with connectors", async () => {
|
||||
vi.mocked(prisma.connector.count).mockResolvedValueOnce(2);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { isArchived: true })
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_HAS_CONNECTORS"));
|
||||
});
|
||||
|
||||
test("unarchives directory", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.feedbackRecordDirectory.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
data: { isArchived: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("updates workspace assignments with diff", async () => {
|
||||
// getFeedbackRecordDirectoryDetails call
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
// Keep workspace1, remove workspace2 (by not including it)
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
workspaceIds: [mockWorkspaceId1],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.workspace.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: { in: [mockWorkspaceId1] },
|
||||
organizationId: mockOrganizationId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { name: "Test" })
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when workspaces belong to different org", async () => {
|
||||
// getFeedbackRecordDirectoryDetails call
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
|
||||
// count returns 0 — none of the workspaces belong to this org
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
workspaceIds: [mockWorkspaceId1],
|
||||
})
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { name: "Duplicate" })
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { name: "Test" })
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeedbackRecordDirectoriesByWorkspaceId", () => {
|
||||
test("returns directories assigned to workspace", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([
|
||||
{ feedbackRecordDirectory: { id: mockDirectoryId, name: "Test Directory" } },
|
||||
] as any);
|
||||
|
||||
const result = await getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1);
|
||||
|
||||
expect(result).toEqual([{ id: mockDirectoryId, name: "Test Directory" }]);
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { isArchived: false },
|
||||
},
|
||||
select: {
|
||||
feedbackRecordDirectory: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no directories assigned", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1);
|
||||
|
||||
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(getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected");
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
organizationId: mockOrganizationId,
|
||||
} as any);
|
||||
|
||||
const result = await getOrganizationIdFromDirectoryId(mockDirectoryId);
|
||||
|
||||
expect(result).toBe(mockOrganizationId);
|
||||
expect(prisma.feedbackRecordDirectory.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when directory does not exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getOrganizationIdFromDirectoryId(mockDirectoryId)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,521 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
|
||||
/**
|
||||
* Retrieves all feedback record directories for a given organization.
|
||||
*
|
||||
* @param organizationId - The ID of the organization to fetch directories for.
|
||||
* @returns An array of feedback record directories with their id, name, archive status, and assigned workspace count.
|
||||
* @throws {ValidationError} If the organizationId fails input validation.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
export const getFeedbackRecordDirectories = reactCache(
|
||||
async (organizationId: string): Promise<TFeedbackRecordDirectory[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const directories = await prisma.feedbackRecordDirectory.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isArchived: true,
|
||||
_count: {
|
||||
select: {
|
||||
workspaces: true,
|
||||
connectors: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return directories.map((dir) => ({
|
||||
id: dir.id,
|
||||
name: dir.name,
|
||||
isArchived: dir.isArchived,
|
||||
workspaceCount: dir._count.workspaces,
|
||||
connectorCount: dir._count.connectors,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieves the full details of a feedback record directory, including its assigned workspaces.
|
||||
*
|
||||
* @param directoryId - The ID of the directory to fetch.
|
||||
* @returns The directory details with workspace assignments, or `null` if not found.
|
||||
* @throws {ValidationError} If the directoryId fails input validation.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
/**
|
||||
* Lists feedback record directories assigned to a workspace.
|
||||
* Used by connector creation to pick an FRD.
|
||||
*/
|
||||
export const getFeedbackRecordDirectoriesByWorkspaceId = reactCache(
|
||||
async (workspaceId: string): Promise<{ id: string; name: string }[]> => {
|
||||
validateInputs([workspaceId, ZId]);
|
||||
try {
|
||||
const rows = await prisma.feedbackRecordDirectoryWorkspace.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
feedbackRecordDirectory: { isArchived: false },
|
||||
},
|
||||
select: {
|
||||
feedbackRecordDirectory: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => r.feedbackRecordDirectory);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 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<TWorkspaceFeedbackRecordDirectoryAccess[]> => {
|
||||
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<string, TWorkspaceFeedbackRecordDirectoryAccess>();
|
||||
|
||||
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<TFeedbackRecordDirectoryDetails | null> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
try {
|
||||
const directory = await prisma.feedbackRecordDirectory.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,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a new feedback record directory within an organization.
|
||||
*
|
||||
* @param organizationId - The ID of the organization to create the directory in.
|
||||
* @param name - The name for the new directory.
|
||||
* @returns The ID of the newly created directory.
|
||||
* @throws {ValidationError} If the inputs fail validation.
|
||||
* @throws {InvalidInputError} If a directory with the same name already exists in the organization,
|
||||
* or if the name is empty.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
export const createFeedbackRecordDirectory = async (
|
||||
organizationId: string,
|
||||
name: string,
|
||||
workspaceIds?: string[]
|
||||
): Promise<string> => {
|
||||
validateInputs([organizationId, ZId], [name, z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED")]);
|
||||
try {
|
||||
// Verify workspaces belong to same org
|
||||
if (workspaceIds?.length) {
|
||||
const count = await prisma.workspace.count({
|
||||
where: { id: { in: workspaceIds }, organizationId },
|
||||
});
|
||||
if (count !== workspaceIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
|
||||
}
|
||||
}
|
||||
|
||||
const directory = await prisma.feedbackRecordDirectory.create({
|
||||
data: {
|
||||
name,
|
||||
organizationId,
|
||||
workspaces: workspaceIds?.length
|
||||
? { create: workspaceIds.map((workspaceId) => ({ workspaceId })) }
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return directory.id;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the Prisma nested write payload for updating workspace assignments on a directory.
|
||||
* Validates that all specified workspaces belong to the directory's organization,
|
||||
* diffs against current assignments, and returns deleteMany + upsert operations.
|
||||
*
|
||||
* @param prismaClient - The Prisma client instance used for database queries.
|
||||
* @param directoryId - The ID of the directory being updated.
|
||||
* @param workspaceIds - The desired workspace IDs to assign.
|
||||
* @param organizationId - The organization the directory belongs to.
|
||||
* @param currentWorkspaceIds - The currently assigned workspace IDs (avoids a redundant fetch).
|
||||
* @returns The Prisma nested write payload for the `workspaces` relation.
|
||||
* @throws {InvalidInputError} If any workspace does not belong to the organization.
|
||||
*/
|
||||
const buildWorkspaceAssignmentPayload = async (
|
||||
prismaClient: PrismaClient,
|
||||
directoryId: string,
|
||||
workspaceIds: string[],
|
||||
organizationId: string,
|
||||
currentWorkspaceIds: string[]
|
||||
): Promise<{
|
||||
payload: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
|
||||
deletedWorkspaceIds: string[];
|
||||
}> => {
|
||||
if (workspaceIds.length > 0) {
|
||||
const orgWorkspacesCount = await prismaClient.workspace.count({
|
||||
where: {
|
||||
id: { in: workspaceIds },
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
if (orgWorkspacesCount !== workspaceIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
|
||||
}
|
||||
}
|
||||
|
||||
const deletedWorkspaceIds = currentWorkspaceIds.filter((id) => !workspaceIds.includes(id));
|
||||
|
||||
return {
|
||||
payload: {
|
||||
deleteMany: {
|
||||
workspaceId: { in: deletedWorkspaceIds },
|
||||
},
|
||||
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<Pick<Prisma.FeedbackRecordDirectoryUpdateInput, "isArchived">> => {
|
||||
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 (
|
||||
tx: Prisma.TransactionClient,
|
||||
directoryId: string,
|
||||
workspaceIds: string[]
|
||||
): Promise<void> => {
|
||||
if (workspaceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.connector.updateMany({
|
||||
where: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId: { in: workspaceIds },
|
||||
},
|
||||
data: {
|
||||
status: "paused",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
|
||||
* assigning a workspace to multiple active directories, but the server must also
|
||||
* reject such payloads to keep this guarantee under direct API access.
|
||||
*/
|
||||
const assertWorkspacesNotAssignedElsewhere = async (
|
||||
directoryId: string,
|
||||
workspaceIds: string[]
|
||||
): Promise<void> => {
|
||||
if (workspaceIds.length === 0) return;
|
||||
|
||||
const conflicting = await prisma.feedbackRecordDirectoryWorkspace.findFirst({
|
||||
where: {
|
||||
workspaceId: { in: workspaceIds },
|
||||
feedbackRecordDirectoryId: { not: directoryId },
|
||||
feedbackRecordDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
if (conflicting) {
|
||||
throw new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a feedback record directory. Supports partial updates for name, workspace
|
||||
* assignments, and archive status.
|
||||
*
|
||||
* When `workspaceIds` is provided, performs a diff against current assignments: removes
|
||||
* unassigned workspaces via `deleteMany` on the join table and upserts new/existing assignments.
|
||||
*
|
||||
* @param directoryId - The ID of the directory to update.
|
||||
* @param organizationId - The organization that owns the directory (avoids an extra fetch).
|
||||
* @param data - The partial update payload. All fields are optional.
|
||||
* @returns `true` on successful update.
|
||||
* @throws {ValidationError} If the inputs fail validation.
|
||||
* @throws {ResourceNotFoundError} If the directory does not exist (Prisma P2025).
|
||||
* @throws {InvalidInputError} If any specified workspace does not belong to the directory's organization,
|
||||
* or if the name conflicts with an existing directory in the same organization.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
export const updateFeedbackRecordDirectory = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
data: TFeedbackRecordDirectoryUpdateInput,
|
||||
options?: UpdateFeedbackRecordDirectoryOptions
|
||||
): Promise<boolean> => {
|
||||
validateInputs([directoryId, ZId], [organizationId, ZId], [data, ZFeedbackRecordDirectoryUpdateInput]);
|
||||
|
||||
try {
|
||||
const { name, workspaceIds, isArchived } = data;
|
||||
|
||||
if (workspaceIds !== undefined) {
|
||||
await assertWorkspacesNotAssignedElsewhere(directoryId, workspaceIds);
|
||||
}
|
||||
|
||||
const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
|
||||
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
|
||||
directoryId,
|
||||
organizationId,
|
||||
workspaceIds
|
||||
);
|
||||
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...archiveUpdate,
|
||||
...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
|
||||
};
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.feedbackRecordDirectory.update({
|
||||
where: { id: directoryId },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
if (options?.pauseConnectorsInRemovedWorkspaces) {
|
||||
await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE");
|
||||
}
|
||||
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
|
||||
throw new ResourceNotFoundError("FeedbackRecordDirectory", directoryId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the owning organization ID for a given directory.
|
||||
*
|
||||
* Used by server actions to determine the organization context for authorization checks.
|
||||
*
|
||||
* @param directoryId - The ID of the directory to look up.
|
||||
* @returns The organization ID that owns the directory.
|
||||
* @throws {ValidationError} If the directoryId fails input validation.
|
||||
* @throws {ResourceNotFoundError} If the directory does not exist.
|
||||
*/
|
||||
export const getOrganizationIdFromDirectoryId = async (directoryId: string): Promise<string> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
const directory = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: { id: directoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (!directory) {
|
||||
throw new ResourceNotFoundError("FeedbackRecordDirectory", directoryId);
|
||||
}
|
||||
|
||||
return directory.organizationId;
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackRecordDirectoryView } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-view";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const FeedbackRecordDirectoriesPage = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { currentUserMembership, organization } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(currentUserMembership.role);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership.role}
|
||||
activeId="feedback-record-directories"
|
||||
/>
|
||||
</PageHeader>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.no_access")}
|
||||
</p>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership.role}
|
||||
activeId="feedback-record-directories"
|
||||
/>
|
||||
</PageHeader>
|
||||
<FeedbackRecordDirectoryView
|
||||
organizationId={organization.id}
|
||||
membershipRole={currentUserMembership.role}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
export const ZFeedbackRecordDirectory = z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
workspaceCount: z.number(),
|
||||
connectorCount: z.number(),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectory = z.infer<typeof ZFeedbackRecordDirectory>;
|
||||
|
||||
export const ZFeedbackRecordDirectoryDetails = z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
organizationId: ZId,
|
||||
workspaces: z.array(
|
||||
z.object({
|
||||
workspaceId: ZId,
|
||||
workspaceName: z.string(),
|
||||
})
|
||||
),
|
||||
connectors: z.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
workspaceId: ZId,
|
||||
workspaceName: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryDetails = z.infer<typeof ZFeedbackRecordDirectoryDetails>;
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryCreateInput = z.infer<typeof ZFeedbackRecordDirectoryCreateInput>;
|
||||
|
||||
export const ZFeedbackRecordDirectoryUpdateInput = z.object({
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED").optional(),
|
||||
workspaceIds: z.array(ZId).optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryUpdateInput = z.infer<typeof ZFeedbackRecordDirectoryUpdateInput>;
|
||||
|
||||
/**
|
||||
* Translates a feedback record directory error code using the provided `t` function.
|
||||
* Returns the translated message, or the raw error code if no mapping exists.
|
||||
*/
|
||||
export const getTranslatedFeedbackRecordDirectoryError = (
|
||||
errorCode: string,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
switch (errorCode) {
|
||||
case "DIRECTORY_NAME_REQUIRED":
|
||||
return t("workspace.settings.feedback_record_directories.error_directory_name_required");
|
||||
case "DIRECTORY_NAME_DUPLICATE":
|
||||
return t("workspace.settings.feedback_record_directories.error_directory_name_duplicate");
|
||||
case "DIRECTORY_WORKSPACES_INVALID_ORG":
|
||||
return t("workspace.settings.feedback_record_directories.error_directory_workspaces_invalid_org");
|
||||
case "DIRECTORY_HAS_CONNECTORS":
|
||||
return t("workspace.settings.feedback_record_directories.error_directory_has_connectors");
|
||||
case "WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY":
|
||||
return t("workspace.settings.feedback_record_directories.error_workspace_already_assigned");
|
||||
default:
|
||||
return errorCode;
|
||||
}
|
||||
};
|
||||
@@ -7,10 +7,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import {
|
||||
TOrganizationFeedbackRecordDirectory,
|
||||
TOrganizationWorkspace,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -40,14 +37,9 @@ interface AddApiKeyModalProps {
|
||||
permission: ApiKeyPermission;
|
||||
workspaceId: string;
|
||||
}>;
|
||||
feedbackRecordDirectoryPermissions: Array<{
|
||||
permission: ApiKeyPermission;
|
||||
feedbackRecordDirectoryId: string;
|
||||
}>;
|
||||
organizationAccess: TOrganizationAccess;
|
||||
}) => Promise<void>;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackRecordDirectories: TOrganizationFeedbackRecordDirectory[];
|
||||
isCreatingAPIKey: boolean;
|
||||
}
|
||||
|
||||
@@ -56,23 +48,12 @@ interface WorkspaceOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FeedbackRecordDirectoryOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PermissionRecord {
|
||||
workspaceId: string;
|
||||
permission: ApiKeyPermission;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
interface DirectoryPermissionRecord {
|
||||
feedbackRecordDirectoryId: string;
|
||||
permission: ApiKeyPermission;
|
||||
feedbackRecordDirectoryName: string;
|
||||
}
|
||||
|
||||
const permissionOptions = [ApiKeyPermission.read, ApiKeyPermission.write, ApiKeyPermission.manage];
|
||||
|
||||
export const AddApiKeyModal = ({
|
||||
@@ -80,7 +61,6 @@ export const AddApiKeyModal = ({
|
||||
setOpen,
|
||||
onSubmit,
|
||||
workspaces,
|
||||
feedbackRecordDirectories,
|
||||
isCreatingAPIKey,
|
||||
}: AddApiKeyModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -109,34 +89,14 @@ export const AddApiKeyModal = ({
|
||||
return {};
|
||||
};
|
||||
|
||||
const getInitialDirectoryPermission = (): DirectoryPermissionRecord | null => {
|
||||
if (feedbackRecordDirectories.length > 0) {
|
||||
return {
|
||||
feedbackRecordDirectoryId: feedbackRecordDirectories[0].id,
|
||||
permission: ApiKeyPermission.read,
|
||||
feedbackRecordDirectoryName: feedbackRecordDirectories[0].name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Initialize with one permission by default
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>({});
|
||||
const [selectedDirectoryPermissions, setSelectedDirectoryPermissions] = useState<
|
||||
Record<string, DirectoryPermissionRecord>
|
||||
>({});
|
||||
const [nextDirectoryPermissionId, setNextDirectoryPermissionId] = useState(0);
|
||||
|
||||
const workspaceOptions: WorkspaceOption[] = workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
}));
|
||||
|
||||
const directoryOptions: FeedbackRecordDirectoryOption[] = feedbackRecordDirectories.map((directory) => ({
|
||||
id: directory.id,
|
||||
name: directory.name,
|
||||
}));
|
||||
|
||||
const removePermission = (index: number) => {
|
||||
const updatedPermissions = { ...selectedPermissions };
|
||||
delete updatedPermissions[`permission-${index}`];
|
||||
@@ -179,59 +139,12 @@ export const AddApiKeyModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const removeDirectoryPermission = (key: string) => {
|
||||
setSelectedDirectoryPermissions((prev) =>
|
||||
Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key))
|
||||
);
|
||||
};
|
||||
|
||||
const addDirectoryPermission = () => {
|
||||
const initial = getInitialDirectoryPermission();
|
||||
if (initial) {
|
||||
setSelectedDirectoryPermissions({
|
||||
...selectedDirectoryPermissions,
|
||||
[`directory-permission-${nextDirectoryPermissionId}`]: initial,
|
||||
});
|
||||
setNextDirectoryPermissionId((id) => id + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDirectoryPermissionLevel = (key: string, permission: ApiKeyPermission) => {
|
||||
setSelectedDirectoryPermissions({
|
||||
...selectedDirectoryPermissions,
|
||||
[key]: {
|
||||
...selectedDirectoryPermissions[key],
|
||||
permission,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateDirectorySelection = (key: string, directoryId: string) => {
|
||||
const directory = feedbackRecordDirectories.find((d) => d.id === directoryId);
|
||||
if (directory) {
|
||||
setSelectedDirectoryPermissions({
|
||||
...selectedDirectoryPermissions,
|
||||
[key]: {
|
||||
...selectedDirectoryPermissions[key],
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
feedbackRecordDirectoryName: directory.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkForDuplicatePermissions = () => {
|
||||
const permissions = Object.values(selectedPermissions);
|
||||
const uniquePermissions = new Set(permissions.map((p) => p.workspaceId));
|
||||
return uniquePermissions.size !== permissions.length;
|
||||
};
|
||||
|
||||
const checkForDuplicateDirectoryPermissions = () => {
|
||||
const permissions = Object.values(selectedDirectoryPermissions);
|
||||
const unique = new Set(permissions.map((p) => p.feedbackRecordDirectoryId));
|
||||
return unique.size !== permissions.length;
|
||||
};
|
||||
|
||||
const submitAPIKey = async () => {
|
||||
const data = getValues();
|
||||
|
||||
@@ -240,33 +153,20 @@ export const AddApiKeyModal = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkForDuplicateDirectoryPermissions()) {
|
||||
toast.error(t("workspace.api_keys.duplicate_directory_access"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert permissions to the format expected by the API
|
||||
const workspacePermissions = Object.values(selectedPermissions).map((permission) => ({
|
||||
permission: permission.permission,
|
||||
workspaceId: permission.workspaceId,
|
||||
}));
|
||||
|
||||
const feedbackRecordDirectoryPermissions = Object.values(selectedDirectoryPermissions).map((p) => ({
|
||||
permission: p.permission,
|
||||
feedbackRecordDirectoryId: p.feedbackRecordDirectoryId,
|
||||
}));
|
||||
|
||||
await onSubmit({
|
||||
label: data.label,
|
||||
workspacePermissions,
|
||||
feedbackRecordDirectoryPermissions,
|
||||
organizationAccess: selectedOrganizationAccess,
|
||||
});
|
||||
|
||||
reset();
|
||||
setSelectedPermissions({});
|
||||
setSelectedDirectoryPermissions({});
|
||||
setNextDirectoryPermissionId(0);
|
||||
setSelectedOrganizationAccess(defaultOrganizationAccess);
|
||||
};
|
||||
|
||||
@@ -278,14 +178,13 @@ export const AddApiKeyModal = ({
|
||||
|
||||
// Check if at least one workspace permission is set or one organization access toggle is ON
|
||||
const hasWorkspaceAccess = Object.keys(selectedPermissions).length > 0;
|
||||
const hasDirectoryAccess = Object.keys(selectedDirectoryPermissions).length > 0;
|
||||
|
||||
const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) =>
|
||||
Object.values(accessGroup).some((value) => value === true)
|
||||
);
|
||||
|
||||
// Disable submit if no access rights are granted
|
||||
return !(hasWorkspaceAccess || hasDirectoryAccess || hasOrganizationAccess);
|
||||
return !(hasWorkspaceAccess || hasOrganizationAccess);
|
||||
};
|
||||
|
||||
const setSelectedOrganizationAccessValue = (key: string, accessType: string, value: boolean) => {
|
||||
@@ -403,95 +302,6 @@ export const AddApiKeyModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.api_keys.feedback_record_directory_access")}</Label>
|
||||
<div className="space-y-2">
|
||||
{Object.keys(selectedDirectoryPermissions).map((key) => {
|
||||
const permission = selectedDirectoryPermissions[key];
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
{/* Directory dropdown */}
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left">
|
||||
{permission.feedbackRecordDirectoryName}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex h-full items-center border-l pl-3">
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
|
||||
{directoryOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
updateDirectorySelection(key, option.id);
|
||||
}}>
|
||||
{option.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Permission level dropdown */}
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left capitalize">
|
||||
{permission.permission}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex h-full items-center border-l pl-3">
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[8rem] capitalize">
|
||||
{permissionOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option}
|
||||
onClick={() => {
|
||||
updateDirectoryPermissionLevel(key, option);
|
||||
}}>
|
||||
{option}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button type="button" className="p-2" onClick={() => removeDirectoryPermission(key)}>
|
||||
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
id="add_directory_permission__button"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addDirectoryPermission}
|
||||
disabled={feedbackRecordDirectories.length === 0}
|
||||
data-testid="add_directory_permission__button__test">
|
||||
<span className="mr-2">+</span> {t("workspace.settings.api_keys.add_permission")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label>{t("workspace.api_keys.organization_access")}</Label>
|
||||
{Object.keys(selectedOrganizationAccess).map((key) => (
|
||||
@@ -531,8 +341,6 @@ export const AddApiKeyModal = ({
|
||||
setOpen(false);
|
||||
reset();
|
||||
setSelectedPermissions({});
|
||||
setSelectedDirectoryPermissions({});
|
||||
setNextDirectoryPermissionId(0);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getApiKeysWithEnvironmentPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import {
|
||||
TOrganizationFeedbackRecordDirectory,
|
||||
TOrganizationWorkspace,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { EditAPIKeys } from "./edit-api-keys";
|
||||
|
||||
interface ApiKeyListProps {
|
||||
@@ -11,16 +8,9 @@ interface ApiKeyListProps {
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackRecordDirectories: TOrganizationFeedbackRecordDirectory[];
|
||||
}
|
||||
|
||||
export const ApiKeyList = async ({
|
||||
organizationId,
|
||||
locale,
|
||||
isReadOnly,
|
||||
workspaces,
|
||||
feedbackRecordDirectories,
|
||||
}: ApiKeyListProps) => {
|
||||
export const ApiKeyList = async ({ organizationId, locale, isReadOnly, workspaces }: ApiKeyListProps) => {
|
||||
const apiKeys = await getApiKeysWithEnvironmentPermissions(organizationId);
|
||||
|
||||
return (
|
||||
@@ -30,7 +20,6 @@ export const ApiKeyList = async ({
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
workspaces={workspaces}
|
||||
feedbackRecordDirectories={feedbackRecordDirectories}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/co
|
||||
import {
|
||||
TApiKeyUpdateInput,
|
||||
TApiKeyWithEnvironmentPermission,
|
||||
TOrganizationFeedbackRecordDirectory,
|
||||
TOrganizationWorkspace,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -27,7 +26,6 @@ interface EditAPIKeysProps {
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackRecordDirectories: TOrganizationFeedbackRecordDirectory[];
|
||||
}
|
||||
|
||||
export const EditAPIKeys = ({
|
||||
@@ -36,7 +34,6 @@ export const EditAPIKeys = ({
|
||||
locale,
|
||||
isReadOnly,
|
||||
workspaces,
|
||||
feedbackRecordDirectories,
|
||||
}: EditAPIKeysProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAddAPIKeyModalOpen, setIsAddAPIKeyModalOpen] = useState(false);
|
||||
@@ -76,10 +73,6 @@ export const EditAPIKeys = ({
|
||||
permission: ApiKeyPermission;
|
||||
workspaceId: string;
|
||||
}>;
|
||||
feedbackRecordDirectoryPermissions: Array<{
|
||||
permission: ApiKeyPermission;
|
||||
feedbackRecordDirectoryId: string;
|
||||
}>;
|
||||
organizationAccess: TOrganizationAccess;
|
||||
}): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
@@ -88,7 +81,6 @@ export const EditAPIKeys = ({
|
||||
apiKeyData: {
|
||||
label: data.label,
|
||||
workspacePermissions: data.workspacePermissions,
|
||||
feedbackRecordDirectoryPermissions: data.feedbackRecordDirectoryPermissions,
|
||||
organizationAccess: data.organizationAccess,
|
||||
},
|
||||
});
|
||||
@@ -245,7 +237,6 @@ export const EditAPIKeys = ({
|
||||
setOpen={setIsAddAPIKeyModalOpen}
|
||||
onSubmit={handleAddAPIKey}
|
||||
workspaces={workspaces}
|
||||
feedbackRecordDirectories={feedbackRecordDirectories}
|
||||
isCreatingAPIKey={isLoading}
|
||||
/>
|
||||
{activeKey && (
|
||||
@@ -255,7 +246,6 @@ export const EditAPIKeys = ({
|
||||
onSubmit={handleUpdateAPIKey}
|
||||
apiKey={activeKey}
|
||||
workspaces={workspaces}
|
||||
feedbackRecordDirectories={feedbackRecordDirectories}
|
||||
isUpdating={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { type TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/
|
||||
import {
|
||||
type TApiKeyUpdateInput,
|
||||
type TApiKeyWithEnvironmentPermission,
|
||||
TOrganizationFeedbackRecordDirectory,
|
||||
ZApiKeyUpdateInput,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -32,7 +31,6 @@ interface ViewPermissionModalProps {
|
||||
onSubmit: (data: TApiKeyUpdateInput) => Promise<void>;
|
||||
apiKey: TApiKeyWithEnvironmentPermission;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackRecordDirectories: TOrganizationFeedbackRecordDirectory[];
|
||||
isUpdating: boolean;
|
||||
}
|
||||
|
||||
@@ -42,7 +40,6 @@ export const ViewPermissionModal = ({
|
||||
onSubmit,
|
||||
apiKey,
|
||||
workspaces,
|
||||
feedbackRecordDirectories,
|
||||
isUpdating,
|
||||
}: ViewPermissionModalProps) => {
|
||||
const { register, getValues, handleSubmit, reset, watch } = useForm<TApiKeyUpdateInput>({
|
||||
@@ -76,11 +73,6 @@ export const ViewPermissionModal = ({
|
||||
return name ?? `${t("workspace.api_keys.unknown_workspace")} (${workspaceId})`;
|
||||
};
|
||||
|
||||
const getDirectoryName = (directoryId: string) => {
|
||||
const name = feedbackRecordDirectories.find((d) => d.id === directoryId)?.name;
|
||||
return name ?? `${t("workspace.api_keys.unknown_directory")} (${directoryId})`;
|
||||
};
|
||||
|
||||
const updateApiKey = async () => {
|
||||
const data = getValues();
|
||||
await onSubmit(data);
|
||||
@@ -154,50 +146,6 @@ export const ViewPermissionModal = ({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.api_keys.feedback_record_directory_access")}</Label>
|
||||
{apiKey.apiKeyFeedbackRecordDirectories?.length === 0 && (
|
||||
<div className="text-center text-sm">
|
||||
{t("workspace.api_keys.no_directory_permissions_found")}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{apiKey.apiKeyFeedbackRecordDirectories?.map((permission) => (
|
||||
<div key={permission.feedbackRecordDirectoryId} className="flex items-center gap-2">
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left">
|
||||
{getDirectoryName(permission.feedbackRecordDirectoryId)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left capitalize">
|
||||
{permission.permission}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Label>{t("workspace.api_keys.organization_access")}</Label>
|
||||
{Object.keys(organizationAccess).map((key) => (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSecret, hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -38,12 +38,6 @@ export const getApiKeysWithEnvironmentPermissions = reactCache(
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
select: {
|
||||
permission: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return apiKeys;
|
||||
@@ -71,16 +65,6 @@ export const getApiKeyWithPermissions = reactCache(
|
||||
},
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
include: {
|
||||
feedbackRecordDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Try v2 format first (fbk_{secret})
|
||||
@@ -172,10 +156,6 @@ export const createApiKey = async (
|
||||
workspaceId: string;
|
||||
permission: ApiKeyPermission;
|
||||
}>;
|
||||
feedbackRecordDirectoryPermissions?: Array<{
|
||||
feedbackRecordDirectoryId: string;
|
||||
permission: ApiKeyPermission;
|
||||
}>;
|
||||
organizationAccess: TOrganizationAccess;
|
||||
}
|
||||
): Promise<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
|
||||
@@ -191,22 +171,7 @@ export const createApiKey = async (
|
||||
// 2. bcrypt hash
|
||||
const hashedKey = await hashSecret(secret, 12);
|
||||
|
||||
const {
|
||||
workspacePermissions,
|
||||
feedbackRecordDirectoryPermissions,
|
||||
organizationAccess,
|
||||
...apiKeyDataWithoutPermissions
|
||||
} = apiKeyData;
|
||||
|
||||
if (feedbackRecordDirectoryPermissions && feedbackRecordDirectoryPermissions.length > 0) {
|
||||
const directoryIds = feedbackRecordDirectoryPermissions.map((p) => p.feedbackRecordDirectoryId);
|
||||
const ownedCount = await prisma.feedbackRecordDirectory.count({
|
||||
where: { id: { in: directoryIds }, organizationId },
|
||||
});
|
||||
if (ownedCount !== directoryIds.length) {
|
||||
throw new ResourceNotFoundError("FeedbackRecordDirectory", null);
|
||||
}
|
||||
}
|
||||
const { workspacePermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData;
|
||||
|
||||
// Create the API key
|
||||
const result = await prisma.apiKey.create({
|
||||
@@ -227,20 +192,9 @@ export const createApiKey = async (
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(feedbackRecordDirectoryPermissions && feedbackRecordDirectoryPermissions.length > 0
|
||||
? {
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
create: feedbackRecordDirectoryPermissions.map((dirPerm) => ({
|
||||
permission: dirPerm.permission,
|
||||
feedbackRecordDirectoryId: dirPerm.feedbackRecordDirectoryId,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackRecordDirectories: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiKey, ApiKeyPermission, 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 { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
|
||||
import {
|
||||
createApiKey,
|
||||
@@ -36,7 +36,6 @@ const mockApiKeyWithEnvironments: TApiKeyWithEnvironmentPermission = {
|
||||
permission: ApiKeyPermission.manage,
|
||||
},
|
||||
],
|
||||
apiKeyFeedbackRecordDirectories: [],
|
||||
};
|
||||
|
||||
// Mock modules before tests
|
||||
@@ -50,9 +49,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectory: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -119,12 +115,6 @@ describe("API Key Management", () => {
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
select: {
|
||||
permission: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
id: true,
|
||||
label: true,
|
||||
@@ -339,88 +329,6 @@ describe("API Key Management", () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(errToThrow);
|
||||
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(errToThrow);
|
||||
});
|
||||
|
||||
test("includes apiKeyFeedbackRecordDirectories with nested directory in v2 lookup", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: new Date(Date.now() - 1000 * 10),
|
||||
} as any);
|
||||
|
||||
await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { lookupHash: "sha256LookupHashValue" },
|
||||
include: {
|
||||
apiKeyWorkspaces: {
|
||||
include: {
|
||||
workspace: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
include: {
|
||||
feedbackRecordDirectory: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("includes apiKeyFeedbackRecordDirectories with nested directory in legacy lookup", async () => {
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: new Date(Date.now() - 1000 * 10),
|
||||
} as any);
|
||||
|
||||
await getApiKeyWithPermissions("legacy-api-key");
|
||||
|
||||
expect(prisma.apiKey.findFirst).toHaveBeenCalledWith({
|
||||
where: { hashedKey: "sha256HashValue" },
|
||||
include: {
|
||||
apiKeyWorkspaces: {
|
||||
include: {
|
||||
workspace: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
include: {
|
||||
feedbackRecordDirectory: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns directory permissions on the api key payload", async () => {
|
||||
const payload = {
|
||||
...mockApiKey,
|
||||
lastUsedAt: new Date(Date.now() - 1000 * 10),
|
||||
apiKeyWorkspaces: [],
|
||||
apiKeyFeedbackRecordDirectories: [
|
||||
{
|
||||
id: "dir-perm-1",
|
||||
apiKeyId: "apikey123",
|
||||
feedbackRecordDirectoryId: "dir1",
|
||||
permission: ApiKeyPermission.read,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
feedbackRecordDirectory: { id: "dir1", name: "Directory 1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(payload as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result?.apiKeyFeedbackRecordDirectories).toEqual(payload.apiKeyFeedbackRecordDirectories);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteApiKey", () => {
|
||||
@@ -495,7 +403,6 @@ describe("API Key Management", () => {
|
||||
}),
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackRecordDirectories: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -512,110 +419,6 @@ describe("API Key Management", () => {
|
||||
expect(prisma.apiKey.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates an API key with feedback record directory permissions", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.count).mockResolvedValueOnce(2);
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
await createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackRecordDirectoryPermissions: [
|
||||
{ feedbackRecordDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackRecordDirectoryId: "dir2", permission: ApiKeyPermission.write },
|
||||
],
|
||||
});
|
||||
|
||||
expect(prisma.feedbackRecordDirectory.count).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["dir1", "dir2"] }, organizationId: "org123" },
|
||||
});
|
||||
|
||||
expect(prisma.apiKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
create: [
|
||||
{ feedbackRecordDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackRecordDirectoryId: "dir2", permission: ApiKeyPermission.write },
|
||||
],
|
||||
},
|
||||
}),
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackRecordDirectories: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("omits apiKeyFeedbackRecordDirectories when feedbackRecordDirectoryPermissions is empty", async () => {
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
await createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackRecordDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const callArg = vi.mocked(prisma.apiKey.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(callArg.data.apiKeyFeedbackRecordDirectories).toBeUndefined();
|
||||
});
|
||||
|
||||
test("creates an API key with both workspace and directory permissions", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
await createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
workspacePermissions: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
|
||||
feedbackRecordDirectoryPermissions: [
|
||||
{ feedbackRecordDirectoryId: "dir1", permission: ApiKeyPermission.manage },
|
||||
],
|
||||
});
|
||||
|
||||
expect(prisma.apiKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
apiKeyWorkspaces: {
|
||||
create: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
|
||||
},
|
||||
apiKeyFeedbackRecordDirectories: {
|
||||
create: [{ feedbackRecordDirectoryId: "dir1", permission: ApiKeyPermission.manage }],
|
||||
},
|
||||
}),
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackRecordDirectories: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects when a feedbackRecordDirectoryId is not owned by the organization", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.count).mockResolvedValueOnce(1);
|
||||
|
||||
await expect(
|
||||
createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackRecordDirectoryPermissions: [
|
||||
{ feedbackRecordDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackRecordDirectoryId: "foreign-dir", permission: ApiKeyPermission.read },
|
||||
],
|
||||
})
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(prisma.feedbackRecordDirectory.count).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["dir1", "foreign-dir"] }, organizationId: "org123" },
|
||||
});
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects create input with duplicate feedbackRecordDirectoryId", async () => {
|
||||
await expect(
|
||||
createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackRecordDirectoryPermissions: [
|
||||
{ feedbackRecordDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackRecordDirectoryId: "dir1", permission: ApiKeyPermission.manage },
|
||||
],
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects create input with duplicate workspaceId", async () => {
|
||||
await expect(
|
||||
createApiKey("org123", "user123", {
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TOrganizationFeedbackRecordDirectory } from "../types/api-keys";
|
||||
import { getFeedbackRecordDirectoriesByOrganizationId } from "./feedback-record-directories";
|
||||
|
||||
const mockDirectories: TOrganizationFeedbackRecordDirectory[] = [
|
||||
{
|
||||
id: "dir1",
|
||||
name: "Directory 1",
|
||||
},
|
||||
{
|
||||
id: "dir2",
|
||||
name: "Directory 2",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
feedbackRecordDirectory: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Feedback Record Directories Management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getFeedbackRecordDirectoriesByOrganizationId", () => {
|
||||
test("retrieves non-archived directories by organization ID successfully", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockResolvedValueOnce(
|
||||
mockDirectories as unknown as Awaited<ReturnType<typeof prisma.feedbackRecordDirectory.findMany>>
|
||||
);
|
||||
|
||||
const result = await getFeedbackRecordDirectoriesByOrganizationId("org123");
|
||||
|
||||
expect(result).toEqual(mockDirectories);
|
||||
expect(prisma.feedbackRecordDirectory.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org123",
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no directories exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getFeedbackRecordDirectoriesByOrganizationId("org123");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.feedbackRecordDirectory.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org123",
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on prisma known request error", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockRejectedValueOnce(errToThrow);
|
||||
|
||||
await expect(getFeedbackRecordDirectoriesByOrganizationId("org123")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("bubbles up unexpected errors", async () => {
|
||||
const unexpectedError = new Error("Unexpected error");
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockRejectedValueOnce(unexpectedError);
|
||||
|
||||
await expect(getFeedbackRecordDirectoriesByOrganizationId("org123")).rejects.toThrow(unexpectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TOrganizationFeedbackRecordDirectory } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
|
||||
export const getFeedbackRecordDirectoriesByOrganizationId = reactCache(
|
||||
async (organizationId: string): Promise<TOrganizationFeedbackRecordDirectory[]> => {
|
||||
try {
|
||||
const directories = await prisma.feedbackRecordDirectory.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return directories;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -3,7 +3,6 @@ import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/comp
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/feedback-record-directories";
|
||||
import { getWorkspacesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/workspaces";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -16,9 +15,8 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
|
||||
|
||||
const { currentUserMembership, organization, session } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const [workspaces, feedbackRecordDirectories, locale] = await Promise.all([
|
||||
const [workspaces, locale] = await Promise.all([
|
||||
getWorkspacesByOrganizationId(organization.id),
|
||||
getFeedbackRecordDirectoriesByOrganizationId(organization.id),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
@@ -43,7 +41,6 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
isReadOnly={!canAccessApiKeys}
|
||||
workspaces={workspaces}
|
||||
feedbackRecordDirectories={feedbackRecordDirectories}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -8,16 +8,10 @@ export const ZApiKeyWorkspacePermission = z.object({
|
||||
permission: z.enum(ApiKeyPermission),
|
||||
});
|
||||
|
||||
export const ZApiKeyFeedbackRecordDirectoryPermission = z.object({
|
||||
feedbackRecordDirectoryId: z.string(),
|
||||
permission: z.enum(ApiKeyPermission),
|
||||
});
|
||||
|
||||
export const ZApiKeyCreateInput = z
|
||||
.object({
|
||||
label: z.string(),
|
||||
workspacePermissions: z.array(ZApiKeyWorkspacePermission).optional(),
|
||||
feedbackRecordDirectoryPermissions: z.array(ZApiKeyFeedbackRecordDirectoryPermission).optional(),
|
||||
organizationAccess: ZOrganizationAccess,
|
||||
})
|
||||
.refine(
|
||||
@@ -27,17 +21,6 @@ export const ZApiKeyCreateInput = z
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{ message: "Duplicate workspace permissions are not allowed", path: ["workspacePermissions"] }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.feedbackRecordDirectoryPermissions) return true;
|
||||
const ids = data.feedbackRecordDirectoryPermissions.map((p) => p.feedbackRecordDirectoryId);
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{
|
||||
message: "Duplicate feedback record directory permissions are not allowed",
|
||||
path: ["feedbackRecordDirectoryPermissions"],
|
||||
}
|
||||
);
|
||||
|
||||
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
|
||||
@@ -61,25 +44,13 @@ export type TOrganizationWorkspace = z.infer<typeof OrganizationWorkspace>;
|
||||
|
||||
export type TApiKeyWorkspacePermission = z.infer<typeof ZApiKeyWorkspacePermission>;
|
||||
|
||||
export type TApiKeyFeedbackRecordDirectoryPermission = z.infer<
|
||||
typeof ZApiKeyFeedbackRecordDirectoryPermission
|
||||
>;
|
||||
|
||||
export interface TApiKeyWithEnvironmentPermission extends Pick<
|
||||
ApiKey,
|
||||
"id" | "label" | "createdAt" | "organizationAccess"
|
||||
> {
|
||||
apiKeyWorkspaces: TApiKeyWorkspacePermission[];
|
||||
apiKeyFeedbackRecordDirectories: TApiKeyFeedbackRecordDirectoryPermission[];
|
||||
}
|
||||
|
||||
export const OrganizationFeedbackRecordDirectory = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export type TOrganizationFeedbackRecordDirectory = z.infer<typeof OrganizationFeedbackRecordDirectory>;
|
||||
|
||||
const ZApiKeyWorkspaceWithWorkspace = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -90,19 +61,6 @@ const ZApiKeyWorkspaceWithWorkspace = z.object({
|
||||
workspace: ZWorkspace.pick({ id: true, name: true }),
|
||||
});
|
||||
|
||||
const ZApiKeyFeedbackRecordDirectoryWithDirectory = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
apiKeyId: z.string(),
|
||||
feedbackRecordDirectoryId: z.string(),
|
||||
permission: z.enum(ApiKeyPermission),
|
||||
feedbackRecordDirectory: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const ZApiKey = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -118,7 +76,6 @@ const ZApiKey = z.object({
|
||||
|
||||
export const ZApiKeyWithEnvironmentAndWorkspace = ZApiKey.extend({
|
||||
apiKeyWorkspaces: z.array(ZApiKeyWorkspaceWithWorkspace),
|
||||
apiKeyFeedbackRecordDirectories: z.array(ZApiKeyFeedbackRecordDirectoryWithDirectory),
|
||||
});
|
||||
|
||||
export type TApiKeyWithEnvironmentAndWorkspace = z.infer<typeof ZApiKeyWithEnvironmentAndWorkspace>;
|
||||
|
||||
@@ -31,22 +31,11 @@ import {
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
getFeedbackRecordDirectoriesByOrganizationIdAction,
|
||||
getTeamsByOrganizationIdAction,
|
||||
} from "@/modules/workspaces/settings/actions";
|
||||
import { 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<typeof ZCreateWorkspaceForm>;
|
||||
@@ -68,26 +57,20 @@ export const CreateWorkspaceModal = ({
|
||||
const router = useRouter();
|
||||
|
||||
const [organizationTeams, setOrganizationTeams] = useState<TOrganizationTeam[]>([]);
|
||||
const [feedbackDirectories, setFeedbackDirectories] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const form = useForm<TCreateWorkspaceForm>({
|
||||
resolver: zodResolver(ZCreateWorkspaceForm),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
teamIds: [],
|
||||
feedbackRecordDirectoryId: undefined,
|
||||
},
|
||||
});
|
||||
const { getValues, setValue } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const fetchModalData = async () => {
|
||||
const [teamsResponse, directoriesResponse] = await Promise.all([
|
||||
getTeamsByOrganizationIdAction({ organizationId }),
|
||||
getFeedbackRecordDirectoriesByOrganizationIdAction({ organizationId }),
|
||||
]);
|
||||
const teamsResponse = await getTeamsByOrganizationIdAction({ organizationId });
|
||||
|
||||
if (teamsResponse?.data) {
|
||||
setOrganizationTeams(teamsResponse.data);
|
||||
@@ -95,26 +78,9 @@ export const CreateWorkspaceModal = ({
|
||||
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();
|
||||
}, [open, organizationId, getValues, setValue]);
|
||||
}, [open, organizationId]);
|
||||
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
@@ -129,7 +95,6 @@ export const CreateWorkspaceModal = ({
|
||||
data: {
|
||||
name: data.name,
|
||||
teamIds: data.teamIds || [],
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -176,40 +141,6 @@ export const CreateWorkspaceModal = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="feedbackRecordDirectoryId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
disabled={feedbackDirectories.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
feedbackDirectories.length > 0
|
||||
? t("workspace.unify.select_feedback_record_directory")
|
||||
: t("workspace.unify.no_feedback_record_directory_available")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{feedbackDirectories.map((directory) => (
|
||||
<SelectItem key={directory.id} value={directory.id}>
|
||||
{directory.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAccessControlAllowed && organizationTeams.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
|
||||
@@ -97,25 +96,3 @@ export const getTeamsByOrganizationIdAction = authenticatedActionClient
|
||||
const teams = await getTeamsByOrganizationId(parsedInput.organizationId);
|
||||
return teams;
|
||||
});
|
||||
|
||||
const ZGetFeedbackRecordDirectoriesByOrganizationIdAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const getFeedbackRecordDirectoriesByOrganizationIdAction = authenticatedActionClient
|
||||
.inputSchema(ZGetFeedbackRecordDirectoriesByOrganizationIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
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 }));
|
||||
});
|
||||
|
||||
@@ -38,14 +38,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
workspaceTeam: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectory: {
|
||||
upsert: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectoryWorkspace: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -102,91 +94,18 @@ describe("workspace lib", () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p2" };
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
vi.mocked(prisma.workspaceTeam.createMany).mockResolvedValueOnce({} as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.count).mockResolvedValueOnce(0);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
|
||||
const result = await createWorkspace("org1", { name: "Workspace 1", teamIds: ["t1"] });
|
||||
expect(result).toEqual(createdWorkspace);
|
||||
expect(prisma.workspace.create).toHaveBeenCalled();
|
||||
expect(prisma.workspaceTeam.createMany).toHaveBeenCalled();
|
||||
expect(prisma.feedbackRecordDirectory.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates workspace and links default FRD when first workspace", async () => {
|
||||
test("creates workspace without teams", async () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p3" };
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.count).mockResolvedValueOnce(0);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
|
||||
|
||||
await createWorkspace("org1", { name: "Workspace No Teams" });
|
||||
|
||||
expect(prisma.feedbackRecordDirectory.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId_name: { organizationId: "org1", name: "Default Feedback Record Directory" },
|
||||
},
|
||||
create: { name: "Default Feedback Record Directory", organizationId: "org1" },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.count).toHaveBeenCalledWith({
|
||||
where: { feedbackRecordDirectoryId: "frd-1" },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.create).toHaveBeenCalledWith({
|
||||
data: { feedbackRecordDirectoryId: "frd-1", workspaceId: "p3" },
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
const result = await createWorkspace("org1", { name: "Workspace No Teams" });
|
||||
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);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.count).mockResolvedValueOnce(1);
|
||||
|
||||
await createWorkspace("org1", { name: "Second Workspace" });
|
||||
|
||||
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();
|
||||
expect(prisma.workspace.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws ValidationError if name is missing", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -30,11 +31,11 @@ const selectWorkspace = {
|
||||
};
|
||||
|
||||
type TCreateWorkspaceInput = Partial<TWorkspaceUpdateInput> & {
|
||||
feedbackRecordDirectoryId?: string;
|
||||
teamIds?: string[];
|
||||
};
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.partial().extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
teamIds: z.array(ZId).optional(),
|
||||
});
|
||||
|
||||
export const updateWorkspace = async (
|
||||
@@ -72,24 +73,9 @@ export const createWorkspace = async (
|
||||
throw new ValidationError("Workspace Name is required");
|
||||
}
|
||||
|
||||
const { teamIds, feedbackRecordDirectoryId, ...data } = workspaceInput;
|
||||
const { teamIds, ...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: {
|
||||
@@ -112,41 +98,6 @@ 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: {
|
||||
organizationId_name: { organizationId, name: "Default Feedback Record Directory" },
|
||||
},
|
||||
create: { name: "Default Feedback Record Directory", organizationId },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Link only if this is the first workspace (no existing links for this FRD)
|
||||
const existingLinks = await prisma.feedbackRecordDirectoryWorkspace.count({
|
||||
where: { feedbackRecordDirectoryId: defaultFrd.id },
|
||||
});
|
||||
|
||||
if (existingLinks === 0) {
|
||||
await prisma.feedbackRecordDirectoryWorkspace.create({
|
||||
data: {
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { transformToUnifySurvey } from "@/app/(app)/workspaces/[workspaceId]/uni
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const WorkspaceFeedbackSourcesPage = async (
|
||||
@@ -25,10 +24,9 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [connectors, surveys, directories] = await Promise.all([
|
||||
const [connectors, surveys] = await Promise.all([
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
getSurveys(params.workspaceId),
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
]);
|
||||
|
||||
const unifySurveys = surveys.map(transformToUnifySurvey);
|
||||
@@ -38,7 +36,6 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
workspaceId={params.workspaceId}
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ApiKeyFeedbackRecordDirectory" DROP CONSTRAINT "ApiKeyFeedbackRecordDirectory_apiKeyId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ApiKeyFeedbackRecordDirectory" DROP CONSTRAINT "ApiKeyFeedbackRecordDirectory_feedbackRecordDirectoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Chart" DROP CONSTRAINT "Chart_feedbackRecordDirectoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Connector" DROP CONSTRAINT "Connector_feedbackRecordDirectoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectory" DROP CONSTRAINT "FeedbackRecordDirectory_organizationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" DROP CONSTRAINT "FeedbackRecordDirectoryWorkspace_feedbackRecordDirectoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" DROP CONSTRAINT "FeedbackRecordDirectoryWorkspace_workspaceId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Chart_feedbackRecordDirectoryId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Connector_feedbackRecordDirectoryId_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chart" DROP COLUMN "feedbackRecordDirectoryId";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Connector" DROP COLUMN "feedbackRecordDirectoryId";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ApiKeyFeedbackRecordDirectory";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "FeedbackRecordDirectory";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "FeedbackRecordDirectoryWorkspace";
|
||||
+71
-142
@@ -596,32 +596,31 @@ enum SurveyOverlay {
|
||||
/// @property recontactDays - Default recontact delay for surveys
|
||||
/// @property placement - Default widget placement for in-app surveys
|
||||
model Workspace {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
legacyEnvironmentId String? @unique
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
legacyEnvironmentId String? @unique
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
/// [Styling]
|
||||
styling Json @default("{\"allowStyleOverwrite\":true}")
|
||||
styling Json @default("{\"allowStyleOverwrite\":true}")
|
||||
/// [WorkspaceConfig]
|
||||
config Json @default("{}")
|
||||
recontactDays Int @default(7)
|
||||
linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys
|
||||
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys
|
||||
placement WidgetPlacement @default(bottomRight)
|
||||
clickOutsideClose Boolean @default(true)
|
||||
overlay SurveyOverlay @default(none)
|
||||
languages Language[]
|
||||
config Json @default("{}")
|
||||
recontactDays Int @default(7)
|
||||
linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys
|
||||
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys
|
||||
placement WidgetPlacement @default(bottomRight)
|
||||
clickOutsideClose Boolean @default(true)
|
||||
overlay SurveyOverlay @default(none)
|
||||
languages Language[]
|
||||
/// [Logo]
|
||||
logo Json?
|
||||
workspaceTeams WorkspaceTeam[]
|
||||
appSetupCompleted Boolean @default(false)
|
||||
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
|
||||
feedbackRecordDirectoryWorkspaces FeedbackRecordDirectoryWorkspace[]
|
||||
charts Chart[]
|
||||
dashboards Dashboard[]
|
||||
logo Json?
|
||||
workspaceTeams WorkspaceTeam[]
|
||||
appSetupCompleted Boolean @default(false)
|
||||
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
|
||||
charts Chart[]
|
||||
dashboards Dashboard[]
|
||||
|
||||
surveys Survey[]
|
||||
contacts Contact[]
|
||||
@@ -650,21 +649,20 @@ model Workspace {
|
||||
/// @property isAISmartToolsEnabled - Controls access to AI smart tools (e.g. translations) that never touch collected data
|
||||
/// @property isAIDataAnalysisEnabled - Controls access to AI data analysis features that touch experience data
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
memberships Membership[]
|
||||
workspaces Workspace[]
|
||||
billing OrganizationBilling?
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
memberships Membership[]
|
||||
workspaces Workspace[]
|
||||
billing OrganizationBilling?
|
||||
/// [OrganizationWhitelabel]
|
||||
whitelabel Json @default("{}")
|
||||
invites Invite[]
|
||||
isAISmartToolsEnabled Boolean @default(false)
|
||||
isAIDataAnalysisEnabled Boolean @default(false)
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
feedbackRecordDirectories FeedbackRecordDirectory[]
|
||||
whitelabel Json @default("{}")
|
||||
invites Invite[]
|
||||
isAISmartToolsEnabled Boolean @default(false)
|
||||
isAIDataAnalysisEnabled Boolean @default(false)
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
}
|
||||
|
||||
/// Stores billing and Stripe synchronization data for an organization.
|
||||
@@ -757,19 +755,18 @@ model Invite {
|
||||
/// @property lastUsedAt - Timestamp of last usage
|
||||
/// @property apiKeyWorkspaces - Workspaces this key has access to
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String?
|
||||
lastUsedAt DateTime?
|
||||
label String
|
||||
hashedKey String
|
||||
lookupHash String? @unique
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
apiKeyWorkspaces ApiKeyWorkspace[]
|
||||
apiKeyFeedbackRecordDirectories ApiKeyFeedbackRecordDirectory[]
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String?
|
||||
lastUsedAt DateTime?
|
||||
label String
|
||||
hashedKey String
|
||||
lookupHash String? @unique
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
apiKeyWorkspaces ApiKeyWorkspace[]
|
||||
/// [OrganizationAccess]
|
||||
organizationAccess Json @default("{}")
|
||||
organizationAccess Json @default("{}")
|
||||
|
||||
@@index([organizationId])
|
||||
}
|
||||
@@ -803,27 +800,6 @@ model ApiKeyWorkspace {
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
/// Links API keys to feedback record directories with specific permissions.
|
||||
/// Enables granular access control for API keys across feedback record directories (Hub API).
|
||||
///
|
||||
/// @property id - Unique identifier for the directory access entry
|
||||
/// @property apiKey - The associated API key
|
||||
/// @property feedbackRecordDirectory - The directory being accessed
|
||||
/// @property permission - Level of access granted
|
||||
model ApiKeyFeedbackRecordDirectory {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
apiKeyId String
|
||||
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
feedbackRecordDirectoryId String
|
||||
feedbackRecordDirectory FeedbackRecordDirectory @relation(fields: [feedbackRecordDirectoryId], references: [id], onDelete: Cascade)
|
||||
permission ApiKeyPermission
|
||||
|
||||
@@unique([apiKeyId, feedbackRecordDirectoryId])
|
||||
@@index([feedbackRecordDirectoryId])
|
||||
}
|
||||
|
||||
enum IdentityProvider {
|
||||
email
|
||||
github
|
||||
@@ -1101,26 +1077,23 @@ enum ChartType {
|
||||
/// @property createdBy - User who created the chart
|
||||
/// @property dashboards - Dashboards that use this chart
|
||||
model Chart {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
type ChartType
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
workspaceId String
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
type ChartType
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
workspaceId String
|
||||
/// [ChartQuery] - Cube.js query configuration
|
||||
query Json @default("{}")
|
||||
query Json @default("{}")
|
||||
/// [ChartConfig] - Visualization configuration (colors, labels, formatting)
|
||||
config Json @default("{}")
|
||||
creator User? @relation("chartCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
createdBy String?
|
||||
feedbackRecordDirectory FeedbackRecordDirectory @relation(fields: [feedbackRecordDirectoryId], references: [id])
|
||||
feedbackRecordDirectoryId String
|
||||
widgets DashboardWidget[]
|
||||
config Json @default("{}")
|
||||
creator User? @relation("chartCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
createdBy String?
|
||||
widgets DashboardWidget[]
|
||||
|
||||
@@unique([workspaceId, name])
|
||||
@@index([workspaceId, createdAt])
|
||||
@@index([feedbackRecordDirectoryId])
|
||||
}
|
||||
|
||||
/// Represents a dashboard containing multiple charts.
|
||||
@@ -1204,26 +1177,23 @@ enum HubFieldType {
|
||||
/// @property formbricksMappings - Element mappings for Formbricks connectors
|
||||
/// @property fieldMappings - Field mappings for other connector types
|
||||
model Connector {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
type ConnectorType
|
||||
status ConnectorStatus @default(active)
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
feedbackRecordDirectoryId String
|
||||
feedbackRecordDirectory FeedbackRecordDirectory @relation(fields: [feedbackRecordDirectoryId], references: [id], onDelete: Cascade)
|
||||
formbricksMappings ConnectorFormbricksMapping[]
|
||||
fieldMappings ConnectorFieldMapping[]
|
||||
lastSyncAt DateTime? @map(name: "last_sync_at")
|
||||
createdBy String? @map(name: "created_by")
|
||||
creator User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
type ConnectorType
|
||||
status ConnectorStatus @default(active)
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
formbricksMappings ConnectorFormbricksMapping[]
|
||||
fieldMappings ConnectorFieldMapping[]
|
||||
lastSyncAt DateTime? @map(name: "last_sync_at")
|
||||
createdBy String? @map(name: "created_by")
|
||||
creator User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([id, workspaceId])
|
||||
@@unique([workspaceId, name])
|
||||
@@index([type])
|
||||
@@index([feedbackRecordDirectoryId])
|
||||
}
|
||||
|
||||
/// Maps survey elements to Hub FeedbackRecords for Formbricks connectors.
|
||||
@@ -1272,44 +1242,3 @@ model ConnectorFieldMapping {
|
||||
|
||||
@@unique([workspaceId, connectorId, sourceFieldId, targetFieldId])
|
||||
}
|
||||
|
||||
/// Represents a feedback record directory (Hub tenant) owned by an organization.
|
||||
/// Directories group feedback data and are assigned to workspaces for access control.
|
||||
///
|
||||
/// @property id - Unique identifier for the directory
|
||||
/// @property name - Display name of the directory
|
||||
/// @property isArchived - Soft delete flag
|
||||
/// @property organization - The parent organization
|
||||
/// @property workspaces - Workspaces assigned to this directory
|
||||
model FeedbackRecordDirectory {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
isArchived Boolean @default(false)
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
workspaces FeedbackRecordDirectoryWorkspace[]
|
||||
connectors Connector[]
|
||||
charts Chart[]
|
||||
apiKeys ApiKeyFeedbackRecordDirectory[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
|
||||
/// Links feedback record directories to workspaces (workspaces).
|
||||
/// Manages which workspaces can access a given directory.
|
||||
///
|
||||
/// @property feedbackRecordDirectory - The directory being accessed
|
||||
/// @property workspace - The workspace receiving access
|
||||
model FeedbackRecordDirectoryWorkspace {
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
feedbackRecordDirectoryId String
|
||||
feedbackRecordDirectory FeedbackRecordDirectory @relation(fields: [feedbackRecordDirectoryId], references: [id], onDelete: Cascade)
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([feedbackRecordDirectoryId, workspaceId])
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
@@ -378,18 +378,6 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const defaultFrd = await prisma.feedbackRecordDirectory.upsert({
|
||||
where: {
|
||||
organizationId_name: { organizationId: organization.id, name: "Default Feedback Record Directory" },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
name: "Default Feedback Record Directory",
|
||||
organizationId: organization.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Users
|
||||
const passwordHash = await hashPassword(SEED_CREDENTIALS.ADMIN.password, 10);
|
||||
|
||||
@@ -461,21 +449,6 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// Link default FRD to workspace
|
||||
await prisma.feedbackRecordDirectoryWorkspace.upsert({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Contact attribute keys for the workspace
|
||||
const defaultAttributeKeys = [
|
||||
{ name: "Email", key: "email", isUnique: true, type: "default" as const },
|
||||
@@ -658,7 +631,7 @@ async function main(): Promise<void> {
|
||||
name: "Responses Over Time",
|
||||
type: "line",
|
||||
workspaceId: workspace.id,
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
|
||||
createdBy: SEED_IDS.USER_ADMIN,
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
@@ -686,7 +659,7 @@ async function main(): Promise<void> {
|
||||
name: "Satisfaction Distribution",
|
||||
type: "pie",
|
||||
workspaceId: workspace.id,
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
|
||||
createdBy: SEED_IDS.USER_ADMIN,
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
@@ -708,7 +681,7 @@ async function main(): Promise<void> {
|
||||
name: "NPS Score",
|
||||
type: "big_number",
|
||||
workspaceId: workspace.id,
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
|
||||
createdBy: SEED_IDS.USER_ADMIN,
|
||||
query: {
|
||||
measures: ["FeedbackRecords.npsScore"],
|
||||
@@ -734,7 +707,7 @@ async function main(): Promise<void> {
|
||||
name: "Survey Completion Rate",
|
||||
type: "bar",
|
||||
workspaceId: workspace.id,
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
|
||||
createdBy: SEED_IDS.USER_MANAGER,
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
@@ -764,7 +737,7 @@ async function main(): Promise<void> {
|
||||
name: "Responses by Channel",
|
||||
type: "area",
|
||||
workspaceId: workspace.id,
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
|
||||
createdBy: SEED_IDS.USER_ADMIN,
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
|
||||
@@ -55,7 +55,6 @@ export const ZConnector = z.object({
|
||||
type: ZConnectorType,
|
||||
status: ZConnectorStatus,
|
||||
workspaceId: z.cuid2(),
|
||||
feedbackRecordDirectoryId: z.cuid2(),
|
||||
lastSyncAt: z.date().nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
});
|
||||
@@ -96,7 +95,6 @@ export type TConnectorWithMappings = z.infer<typeof ZConnectorWithMappings>;
|
||||
export const ZConnectorCreateInput = z.object({
|
||||
name: z.string().min(1),
|
||||
type: ZConnectorType,
|
||||
feedbackRecordDirectoryId: z.cuid2(),
|
||||
createdBy: z.cuid2().optional(),
|
||||
});
|
||||
export type TConnectorCreateInput = z.infer<typeof ZConnectorCreateInput>;
|
||||
|
||||
Reference in New Issue
Block a user