mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-10 08:49:54 -06:00
feat: Optimize layout data fetching and reduce database queries by 50% (#6729)
Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -24,8 +23,6 @@ const Page = async (props) => {
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) return notFound();
|
||||
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
@@ -37,11 +34,10 @@ const Page = async (props) => {
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
||||
<ProjectAndOrgSwitch
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
projects={[]}
|
||||
currentOrganizationName={organization.name}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={0}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getProjectsByUserId } from "./lib/project";
|
||||
|
||||
const ZCreateProjectAction = z.object({
|
||||
organizationId: ZId,
|
||||
@@ -84,3 +87,59 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches organizations list for switcher dropdown.
|
||||
* Called on-demand when user opens the organization switcher.
|
||||
*/
|
||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetOrganizationsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getOrganizationsByUserId(ctx.user.id);
|
||||
});
|
||||
|
||||
const ZGetProjectsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches projects list for switcher dropdown.
|
||||
* Called on-demand when user opens the project switcher.
|
||||
*/
|
||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetProjectsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Need membership for getProjectsByUserId (1 DB query)
|
||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||
if (!membership) {
|
||||
throw new Error("Membership not found");
|
||||
}
|
||||
|
||||
return await getProjectsByUserId(ctx.user.id, membership);
|
||||
});
|
||||
|
||||
@@ -1,104 +1,49 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
|
||||
interface EnvironmentLayoutProps {
|
||||
environmentId: string;
|
||||
session: Session;
|
||||
layoutData: TEnvironmentLayoutData;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
|
||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||
const t = await getTranslate();
|
||||
const [user, environment, organizations, organization] = await Promise.all([
|
||||
getUser(session.user.id),
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationsByUserId(session.user.id),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
// Destructure all data from props (NO database queries)
|
||||
const {
|
||||
user,
|
||||
environment,
|
||||
organization,
|
||||
membership,
|
||||
project, // Current project details
|
||||
environments, // All project environments (for environment switcher)
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
} = layoutData;
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
// Calculate derived values (no queries)
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
if (!currentUserMembership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
|
||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
||||
getProjectsByUserId(user.id, currentUserMembership),
|
||||
getEnvironments(environment.projectId),
|
||||
getAccessControlPermission(organization.billing.plan),
|
||||
]);
|
||||
|
||||
if (!projects || !environments || !organizations) {
|
||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||
}
|
||||
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
|
||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.project_permission_not_found"));
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
|
||||
// Find the current project from the projects array
|
||||
const project = projects.find((p) => p.id === environment.projectId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
@@ -122,26 +67,24 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
<MainNavigation
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
projects={projects}
|
||||
user={user}
|
||||
project={{ id: project.id, name: project.name }}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membershipRole}
|
||||
membershipRole={membership.role}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
currentProjectId={project.id}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={active}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
membershipRole={membershipRole}
|
||||
membershipRole={membership.role}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
projects: { id: string; name: string }[];
|
||||
project: { id: string; name: string };
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
@@ -52,7 +52,7 @@ export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
user,
|
||||
projects,
|
||||
project,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
@@ -65,7 +65,6 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -9,9 +9,7 @@ import { getAccessFlags } from "@/lib/membership/utils";
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -24,9 +22,7 @@ interface TopControlBarProps {
|
||||
export const TopControlBar = ({
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -46,9 +42,7 @@ export const TopControlBar = ({
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
@@ -23,10 +25,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useOrganization } from "../context/environment-context";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||
isMultiOrgEnabled: boolean;
|
||||
currentEnvironmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -47,7 +50,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
|
||||
|
||||
export const OrganizationBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentOrganizationName,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
isFormbricksCloud,
|
||||
@@ -60,7 +63,45 @@ export const OrganizationBreadcrumb = ({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Get current organization name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { organization: currentOrganization } = useOrganization();
|
||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||
|
||||
// Lazy-load organizations when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
||||
setIsLoadingOrganizations(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort organizations by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load organizations");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
|
||||
}
|
||||
setIsLoadingOrganizations(false);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isOrganizationDropdownOpen,
|
||||
currentOrganizationId,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
loadError,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!currentOrganization) {
|
||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||
@@ -126,7 +167,7 @@ export const OrganizationBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentOrganization.name}</span>
|
||||
<span>{organizationName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -142,30 +183,52 @@ export const OrganizationBreadcrumb = ({
|
||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_organization")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganizationId}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<div>
|
||||
<DropdownMenuSeparator />
|
||||
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.organization_settings")}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||
@@ -8,9 +7,9 @@ import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentOrganizationName?: string; // Optional: for pages without context
|
||||
currentProjectId?: string;
|
||||
projects: { id: string; name: string }[];
|
||||
currentProjectName?: string; // Optional: for pages without context
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
@@ -18,15 +17,15 @@ interface ProjectAndOrgSwitchProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
isMember: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentOrganizationName,
|
||||
currentProjectId,
|
||||
projects,
|
||||
currentProjectName,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
@@ -37,11 +36,6 @@ export const ProjectAndOrgSwitch = ({
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
||||
const sortedOrganizations = useMemo(
|
||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations]
|
||||
);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
@@ -50,9 +44,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={sortedOrganizations}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
currentOrganizationName={currentOrganizationName}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
@@ -60,9 +54,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentProjectName={currentProjectName}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
projects={sortedProjects}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
@@ -18,10 +20,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useProject } from "../context/environment-context";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
currentProjectName?: string; // Optional: pass directly if context not available
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -44,7 +47,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
projects,
|
||||
currentProjectName,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -59,9 +62,41 @@ export const ProjectBreadcrumb = ({
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get current project name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { project: currentProject } = useProject();
|
||||
const projectName = currentProject?.name || currentProjectName || "";
|
||||
|
||||
// Lazy-load projects when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||
setIsLoadingProjects(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load projects");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
}
|
||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
@@ -100,8 +135,6 @@ export const ProjectBreadcrumb = ({
|
||||
},
|
||||
];
|
||||
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||
logger.error(errorMessage);
|
||||
@@ -166,7 +199,7 @@ export const ProjectBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentProject.name}</span>
|
||||
<span>{projectName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -181,26 +214,48 @@ export const ProjectBreadcrumb = ({
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProject.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setProjects([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProjectId}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
@@ -20,25 +22,44 @@ export const useEnvironment = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useProject = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { project: null };
|
||||
}
|
||||
return { project: context.project };
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { organization: null };
|
||||
}
|
||||
return { organization: context.organization };
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
[environment, project, organization]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
|
||||
@@ -15,46 +14,27 @@ const EnvLayout = async (props: {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
// Check session first (required for userId)
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const [project, environment] = await Promise.all([
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
|
||||
if (!membership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
session={layoutData.session}
|
||||
user={layoutData.user}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</EnvironmentIdBaseLayout>
|
||||
);
|
||||
|
||||
@@ -182,6 +182,8 @@ checksums:
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
@@ -821,7 +823,6 @@ checksums:
|
||||
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
|
||||
environments/projects_environments_organizations_not_found: 9d450087c4035083f93bda9aa1889c43
|
||||
environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905
|
||||
environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a
|
||||
environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_projects": "Fehler beim Laden der Projekte",
|
||||
"finish": "Fertigstellen",
|
||||
"follow_these": "Folge diesen",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projekte, Umgebungen oder Organisationen nicht gefunden",
|
||||
"segments": {
|
||||
"add_filter_below": "Filter unten hinzufügen",
|
||||
"add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
|
||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||
"expand_rows": "Expand rows",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_projects": "Failed to load projects",
|
||||
"finish": "Finish",
|
||||
"follow_these": "Follow these",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "See which teams can access this project."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projects, environments or organizations not found",
|
||||
"segments": {
|
||||
"add_filter_below": "Add filter below",
|
||||
"add_your_first_filter_to_get_started": "Add your first filter to get started",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
|
||||
"error_rate_limit_title": "Limite de Taux Dépassée",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_projects": "Échec du chargement des projets",
|
||||
"finish": "Terminer",
|
||||
"follow_these": "Suivez ceci",
|
||||
"formbricks_version": "Version de Formbricks",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projets, environnements ou organisations non trouvés",
|
||||
"segments": {
|
||||
"add_filter_below": "Ajouter un filtre ci-dessous",
|
||||
"add_your_first_filter_to_get_started": "Ajoutez votre premier filtre pour commencer.",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
|
||||
"error_rate_limit_title": "レート制限を超えました",
|
||||
"expand_rows": "行を展開",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
|
||||
"finish": "完了",
|
||||
"follow_these": "こちらの手順に従って",
|
||||
"formbricks_version": "Formbricksバージョン",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "プロジェクト、環境、または組織が見つかりません",
|
||||
"segments": {
|
||||
"add_filter_below": "下にフィルターを追加",
|
||||
"add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Terminar",
|
||||
"follow_these": "Siga esses",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
|
||||
"segments": {
|
||||
"add_filter_below": "Adicionar filtro abaixo",
|
||||
"add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Concluir",
|
||||
"follow_these": "Siga estes",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
|
||||
"segments": {
|
||||
"add_filter_below": "Adicionar filtro abaixo",
|
||||
"add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
|
||||
"error_rate_limit_title": "Limită de cereri depășită",
|
||||
"expand_rows": "Extinde rândurile",
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_projects": "Nu s-a reușit încărcarea proiectelor",
|
||||
"finish": "Finalizează",
|
||||
"follow_these": "Urmați acestea",
|
||||
"formbricks_version": "Versiunea Formbricks",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Proiecte, medii sau organizații nu găsite",
|
||||
"segments": {
|
||||
"add_filter_below": "Adăugați un filtru mai jos",
|
||||
"add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
|
||||
"error_rate_limit_title": "速率 限制 超过",
|
||||
"expand_rows": "展开 行",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_projects": "加载项目失败",
|
||||
"finish": "完成",
|
||||
"follow_these": "遵循 这些",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "项目 、 环境 或 组织 未 找到",
|
||||
"segments": {
|
||||
"add_filter_below": "在下方添加 过滤器",
|
||||
"add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
||||
"error_rate_limit_title": "限流超過",
|
||||
"expand_rows": "展開列",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_projects": "無法載入專案",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
@@ -886,7 +888,6 @@
|
||||
"team_settings_description": "查看哪些團隊可以存取此專案。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "找不到專案、環境或組織",
|
||||
"segments": {
|
||||
"add_filter_below": "在下方新增篩選器",
|
||||
"add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Pull in the mocked implementations to configure them in tests
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
@@ -12,12 +13,24 @@ import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { environmentIdLayoutChecks, getEnvironmentAuth } from "./utils";
|
||||
// Pull in the mocked implementations to configure them in tests
|
||||
import {
|
||||
environmentIdLayoutChecks,
|
||||
getEnvironmentAuth,
|
||||
getEnvironmentLayoutData,
|
||||
getEnvironmentWithRelations,
|
||||
} from "./utils";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
@@ -58,6 +71,8 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
@@ -68,12 +83,36 @@ vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getAccessControlPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/errors", () => ({
|
||||
AuthorizationError: class AuthorizationError extends Error {},
|
||||
DatabaseError: class DatabaseError extends Error {},
|
||||
}));
|
||||
|
||||
describe("utils.ts", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Provide default mocks for successful scenario
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } });
|
||||
vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment);
|
||||
@@ -96,6 +135,16 @@ describe("utils.ts", () => {
|
||||
});
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user123" } as TUser);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "none",
|
||||
} as any);
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(0);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("getEnvironmentAuth", () => {
|
||||
@@ -170,4 +219,434 @@ describe("utils.ts", () => {
|
||||
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironmentWithRelations", () => {
|
||||
const mockPrismaData = {
|
||||
id: "env123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
type: "production" as const,
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight" as const,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
{
|
||||
id: "env123",
|
||||
type: "production" as const,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
{
|
||||
id: "env456",
|
||||
type: "development" as const,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
],
|
||||
organization: {
|
||||
id: "org123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { plan: "free" },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockPrismaData as any);
|
||||
});
|
||||
|
||||
test("returns combined environment, project, organization, and membership data", async () => {
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.environment.id).toBe("env123");
|
||||
expect(result!.environment.type).toBe("production");
|
||||
expect(result!.project.id).toBe("proj123");
|
||||
expect(result!.project.name).toBe("Test Project");
|
||||
expect(result!.organization.id).toBe("org123");
|
||||
expect(result!.organization.name).toBe("Test Organization");
|
||||
expect(result!.environments).toHaveLength(2);
|
||||
expect(result!.membership).toEqual({
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches only current user's membership using database-level filtering", async () => {
|
||||
await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "env123" },
|
||||
select: expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
organization: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
memberships: expect.objectContaining({
|
||||
where: { userId: "user123" },
|
||||
take: 1,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when environment not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null membership when user has no membership", async () => {
|
||||
const dataWithoutMembership = {
|
||||
...mockPrismaData,
|
||||
project: {
|
||||
...mockPrismaData.project,
|
||||
organization: {
|
||||
...mockPrismaData.project.organization,
|
||||
memberships: [], // No memberships
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(dataWithoutMembership as any);
|
||||
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result!.membership).toBeNull();
|
||||
});
|
||||
|
||||
test("throws error on database failure", async () => {
|
||||
// Mock a database error
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.environment.findUnique).mockRejectedValueOnce(dbError);
|
||||
|
||||
// Verify function throws (specific error type depends on Prisma error detection)
|
||||
await expect(getEnvironmentWithRelations("env123", "user123")).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Note: Input validation for environmentId and userId is handled by
|
||||
// getEnvironmentLayoutData (the parent function), not here.
|
||||
// See getEnvironmentLayoutData tests for validation coverage.
|
||||
});
|
||||
|
||||
describe("getEnvironmentLayoutData", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
||||
id: "env123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
{
|
||||
id: "env123",
|
||||
type: "production",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
],
|
||||
organization: {
|
||||
id: "org123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
test("returns complete layout data on success", async () => {
|
||||
const result = await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.session).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.environment).toBeDefined();
|
||||
expect(result.project).toBeDefined();
|
||||
expect(result.organization).toBeDefined();
|
||||
expect(result.environments).toBeDefined();
|
||||
expect(result.membership).toBeDefined();
|
||||
expect(result.isAccessControlAllowed).toBeDefined();
|
||||
expect(result.projectPermission).toBeDefined();
|
||||
expect(result.license).toBeDefined();
|
||||
expect(result.peopleCount).toBe(0);
|
||||
expect(result.responseCount).toBe(0);
|
||||
});
|
||||
|
||||
test("validates environmentId input", async () => {
|
||||
await expect(getEnvironmentLayoutData("", "user123")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("validates userId input", async () => {
|
||||
await expect(getEnvironmentLayoutData("env123", "")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("throws error if session not found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.session_not_found");
|
||||
});
|
||||
|
||||
test("throws error if userId doesn't match session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "different-user" } } as any);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("User ID mismatch");
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.user_not_found");
|
||||
});
|
||||
|
||||
test("throws error if environment data not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user has no environment access", async () => {
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
test("throws error if membership not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org123",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [], // No membership
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
|
||||
"common.membership_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches user before auth check, then environment data after authorization", async () => {
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
// User is fetched first (needed for auth check)
|
||||
expect(getUser).toHaveBeenCalledWith("user123");
|
||||
// Environment data is fetched after authorization passes
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "env123" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches permissions and license data in parallel", async () => {
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(getAccessControlPermission).toHaveBeenCalled();
|
||||
expect(getProjectPermissionByUserId).toHaveBeenCalledWith("user123", "proj123");
|
||||
expect(getEnterpriseLicense).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fetches cloud metrics when IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
// Mock IS_FORMBRICKS_CLOUD to be true
|
||||
const constantsMock = await import("@/lib/constants");
|
||||
vi.mocked(constantsMock).IS_FORMBRICKS_CLOUD = true;
|
||||
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(getMonthlyActiveOrganizationPeopleCount).toHaveBeenCalledWith("org123");
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith("org123");
|
||||
});
|
||||
|
||||
test("caches results per environmentId and userId", async () => {
|
||||
// Call twice with same parameters
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
// Due to React.cache, database should only be queried once
|
||||
// Note: React.cache behavior is per-request in production, but in tests
|
||||
// we can verify the function was called multiple times
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns different data for different environmentIds", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
name: "Project 1",
|
||||
organizationId: "org123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org123",
|
||||
name: "Org 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user123", organizationId: "org123", role: "owner", accepted: true }],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result1 = await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env456",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "proj456",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj456",
|
||||
name: "Project 2",
|
||||
organizationId: "org456",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org456",
|
||||
name: "Org 2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "pro", limits: {} },
|
||||
isAIEnabled: true,
|
||||
whitelabel: true,
|
||||
memberships: [{ userId: "user123", organizationId: "org456", role: "member", accepted: true }],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result2 = await getEnvironmentLayoutData("env456", "user123");
|
||||
|
||||
expect(result1.environment.id).not.toBe(result2.environment.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { cache as reactCache } from "react";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError, DatabaseError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { TEnvironmentAuth } from "../types/environment-auth";
|
||||
import { TEnvironmentAuth, TEnvironmentLayoutData } from "../types/environment-auth";
|
||||
|
||||
/**
|
||||
* Common utility to fetch environment data and perform authorization checks
|
||||
@@ -103,3 +115,215 @@ export const environmentIdLayoutChecks = async (environmentId: string) => {
|
||||
|
||||
return { t, session, user, organization };
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches environment with related project, organization, environments, and current user's membership
|
||||
* in a single optimized database query.
|
||||
* Returns data with proper types matching TEnvironment, TProject, TOrganization.
|
||||
*
|
||||
* Note: Validation is handled by parent function (getEnvironmentLayoutData)
|
||||
*/
|
||||
export const getEnvironmentWithRelations = reactCache(async (environmentId: string, userId: string) => {
|
||||
try {
|
||||
const data = await prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
// Environment fields
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
type: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
// Project via relation (nested select)
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
languages: true,
|
||||
recontactDays: true,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
// All project environments
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
},
|
||||
// Organization via relation
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
billing: true,
|
||||
isAIEnabled: true,
|
||||
whitelabel: true,
|
||||
// Current user's membership only (filtered at DB level)
|
||||
memberships: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
accepted: true,
|
||||
role: true,
|
||||
},
|
||||
take: 1, // Only need one result
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Extract and return properly typed data
|
||||
return {
|
||||
environment: {
|
||||
id: data.id,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
type: data.type,
|
||||
projectId: data.projectId,
|
||||
appSetupCompleted: data.appSetupCompleted,
|
||||
},
|
||||
project: {
|
||||
id: data.project.id,
|
||||
createdAt: data.project.createdAt,
|
||||
updatedAt: data.project.updatedAt,
|
||||
name: data.project.name,
|
||||
organizationId: data.project.organizationId,
|
||||
languages: data.project.languages,
|
||||
recontactDays: data.project.recontactDays,
|
||||
linkSurveyBranding: data.project.linkSurveyBranding,
|
||||
inAppSurveyBranding: data.project.inAppSurveyBranding,
|
||||
config: data.project.config,
|
||||
placement: data.project.placement,
|
||||
clickOutsideClose: data.project.clickOutsideClose,
|
||||
darkOverlay: data.project.darkOverlay,
|
||||
styling: data.project.styling,
|
||||
logo: data.project.logo,
|
||||
environments: data.project.environments,
|
||||
},
|
||||
organization: {
|
||||
id: data.project.organization.id,
|
||||
createdAt: data.project.organization.createdAt,
|
||||
updatedAt: data.project.organization.updatedAt,
|
||||
name: data.project.organization.name,
|
||||
billing: data.project.organization.billing,
|
||||
isAIEnabled: data.project.organization.isAIEnabled,
|
||||
whitelabel: data.project.organization.whitelabel,
|
||||
},
|
||||
environments: data.project.environments,
|
||||
membership: data.project.organization.memberships[0] || null, // First (and only) membership or null
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting environment with relations");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches all data required for environment layout rendering.
|
||||
* Consolidates multiple queries and eliminates duplicates.
|
||||
* Does NOT fetch switcher data (organizations/projects lists) - those are lazy-loaded.
|
||||
*
|
||||
* Note: userId is included in cache key to make it explicit that results are user-specific,
|
||||
* even though React.cache() is per-request and doesn't leak across users.
|
||||
*/
|
||||
export const getEnvironmentLayoutData = reactCache(
|
||||
async (environmentId: string, userId: string): Promise<TEnvironmentLayoutData> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
validateInputs([userId, ZId]);
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
// Verify userId matches session (safety check)
|
||||
if (session.user.id !== userId) {
|
||||
throw new Error("User ID mismatch with session");
|
||||
}
|
||||
|
||||
// Get user first (lightweight query needed for subsequent checks)
|
||||
const user = await getUser(userId); // 1 DB query
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
// Authorization check before expensive data fetching
|
||||
const hasAccess = await hasUserEnvironmentAccess(userId, environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError(t("common.not_authorized"));
|
||||
}
|
||||
|
||||
const relationData = await getEnvironmentWithRelations(environmentId, userId);
|
||||
if (!relationData) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const { environment, project, organization, environments, membership } = relationData;
|
||||
|
||||
// Validate user's membership was found
|
||||
if (!membership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
|
||||
// Fetch remaining data in parallel
|
||||
const [isAccessControlAllowed, projectPermission, license] = await Promise.all([
|
||||
getAccessControlPermission(organization.billing.plan), // No DB query (logic only)
|
||||
getProjectPermissionByUserId(userId, environment.projectId), // 1 DB query
|
||||
getEnterpriseLicense(), // Externally cached
|
||||
]);
|
||||
|
||||
// Conditional queries for Formbricks Cloud
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
environments,
|
||||
membership,
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Session } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { ZEnvironment } from "@formbricks/types/environment";
|
||||
import { ZMembership } from "@formbricks/types/memberships";
|
||||
import { ZOrganization } from "@formbricks/types/organizations";
|
||||
import { ZProject } from "@formbricks/types/project";
|
||||
import { ZUser } from "@formbricks/types/user";
|
||||
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { TEnvironment, ZEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject, ZProject } from "@formbricks/types/project";
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
// Type for the enterprise license returned by getEnterpriseLicense()
|
||||
type TEnterpriseLicense = {
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
};
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
environment: ZEnvironment,
|
||||
@@ -27,3 +38,25 @@ export const ZEnvironmentAuth = z.object({
|
||||
});
|
||||
|
||||
export type TEnvironmentAuth = z.infer<typeof ZEnvironmentAuth>;
|
||||
|
||||
/**
|
||||
* Complete layout data type for environment pages.
|
||||
* Includes all data needed for layout rendering.
|
||||
*
|
||||
* Note: organizations and projects lists are NOT included - they are lazy-loaded
|
||||
* in switcher dropdowns only when needed.
|
||||
*/
|
||||
export type TEnvironmentLayoutData = {
|
||||
session: Session;
|
||||
user: TUser;
|
||||
environment: TEnvironment;
|
||||
project: TProject; // Current project with full details
|
||||
organization: TOrganization;
|
||||
environments: TEnvironment[]; // All project environments for switcher
|
||||
membership: TMembership;
|
||||
isAccessControlAllowed: boolean;
|
||||
projectPermission: TTeamPermission | null;
|
||||
license: TEnterpriseLicense;
|
||||
peopleCount: number;
|
||||
responseCount: number;
|
||||
};
|
||||
|
||||
@@ -35,9 +35,10 @@ const BreadcrumbItem = React.forwardRef<
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1",
|
||||
!isHighlighted && "hover:bg-white hover:outline hover:outline-slate-300",
|
||||
isActive && "bg-slate-100 outline outline-slate-300",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:bg-red-700 hover:outline-red-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user