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:
Matti Nannt
2025-10-28 07:55:44 +01:00
committed by GitHub
parent 7a3d05eb9a
commit f587446079
24 changed files with 1075 additions and 224 deletions

View File

@@ -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}

View File

@@ -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);
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}

View File

@@ -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")}

View File

@@ -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}

View File

@@ -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 />

View File

@@ -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 (

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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": "まず最初のフィルターを追加してください",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "添加 您 的 第一个 过滤器 以 开始",

View File

@@ -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": "新增您的第一個篩選器以開始使用",

View File

@@ -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);
});
});
});

View File

@@ -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,
};
}
);

View File

@@ -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;
};

View File

@@ -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}