Compare commits

...

1 Commits

Author SHA1 Message Date
Matti Nannt 8c7112e559 feat: remove FeedbackRecordDirectory entity, use workspace.id as Hub tenant_id
Drops FRD as a separate org-level entity in favour of using workspace.id
directly as the Hub tenant_id. This eliminates the dual-auth model, removes
the implicit cascade where workspace read granted access to XM data, and
simplifies the connector/chart/API-key permission surfaces.

Key changes:
- Schema: drop FeedbackRecordDirectory, FeedbackRecordDirectoryWorkspace and
  ApiKeyFeedbackRecordDirectory models; remove FKs from Chart/Connector/ApiKey
- Connector pipeline, CSV import and import now pass connector.workspaceId as
  tenant_id instead of feedbackRecordDirectoryId
- Chart actions: injectTenantFilter now receives workspaceId
- API key create/list: FRD permission section removed entirely
- Workspace create: no longer auto-creates/links a default FRD
- Feedback records page: single workspace-scoped Hub query replaces multi-FRD loop
- Delete entire modules/ee/feedback-record-directory module
- All tests updated; pnpm test (4570), build and lint pass clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 06:43:33 +02:00
76 changed files with 264 additions and 3865 deletions
@@ -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 (
@@ -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 +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 = {
@@ -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>
@@ -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}
/>
@@ -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}
@@ -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}
/>
@@ -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}
/>
@@ -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 -20
View File
@@ -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;
+1 -1
View File
@@ -22,7 +22,7 @@ export const importCsvData = async (
const { records, skipped } = transformCsvRowsToFeedbackRecords(
csvRows,
connector.fieldMappings,
connector.feedbackRecordDirectoryId
connector.workspaceId
);
let successes = 0;
+1 -6
View File
@@ -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();
+1 -1
View File
@@ -41,7 +41,7 @@ const processConnector = async (
response,
survey,
connector.formbricksMappings,
connector.feedbackRecordDirectoryId
connector.workspaceId
);
if (feedbackRecords.length === 0) {
+5 -9
View File
@@ -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
);
});
});
-3
View File
@@ -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;
})
);
@@ -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>
)}
</>
);
};
@@ -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>
);
};
@@ -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}
/>
)}
</>
);
};
@@ -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", {
@@ -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}
/>
);
};
@@ -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
View File
@@ -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])
}
+5 -32
View File
@@ -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"],
-2
View File
@@ -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>;