diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx index 48562dc15c..b7f0c92018 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx @@ -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) => {
- {/* 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 */} { + 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); + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx index d2807c3269..e1928b5960 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx @@ -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 (
{IS_FORMBRICKS_CLOUD && ( @@ -122,26 +67,24 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
{children}
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index c54f43d783..1fc5cec100 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -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; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx index 94f06d7a07..85c1f083db 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx @@ -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} diff --git a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx index bd7e1a9a83..dd7fd851bb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx @@ -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(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>
- {currentOrganization.name} + {organizationName} {isPending && } {isOrganizationDropdownOpen ? ( @@ -142,30 +183,52 @@ export const OrganizationBreadcrumb = ({ {t("common.choose_organization")}
- - {organizations.map((org) => ( - handleOrganizationChange(org.id)} - className="cursor-pointer"> - {org.name} - - ))} - - {isMultiOrgEnabled && ( - setOpenCreateOrganizationModal(true)} - className="cursor-pointer"> - {t("common.create_new_organization")} - - + {isLoadingOrganizations && ( +
+ +
+ )} + {!isLoadingOrganizations && loadError && ( +
+

{loadError}

+ +
+ )} + {!isLoadingOrganizations && !loadError && ( + <> + + {organizations.map((org) => ( + handleOrganizationChange(org.id)} + className="cursor-pointer"> + {org.name} + + ))} + + {isMultiOrgEnabled && ( + setOpenCreateOrganizationModal(true)} + className="cursor-pointer"> + {t("common.create_new_organization")} + + + )} + )} )} {currentEnvironmentId && (
- + {showOrganizationDropdown && }
{t("common.organization_settings")} diff --git a/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx b/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx index 097b885ddb..7bad5faedd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx @@ -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 = ({ 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(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>
- {currentProject.name} + {projectName} {isPending && } {isProjectDropdownOpen ? ( @@ -181,26 +214,48 @@ export const ProjectBreadcrumb = ({ {t("common.choose_project")}
- - {projects.map((proj) => ( - handleProjectChange(proj.id)} - className="cursor-pointer"> -
- {proj.name} -
-
- ))} -
- {isOwnerOrManager && ( - - {t("common.add_new_project")} - - + {isLoadingProjects && ( +
+ +
+ )} + {!isLoadingProjects && loadError && ( +
+

{loadError}

+ +
+ )} + {!isLoadingProjects && !loadError && ( + <> + + {projects.map((proj) => ( + handleProjectChange(proj.id)} + className="cursor-pointer"> +
+ {proj.name} +
+
+ ))} +
+ {isOwnerOrManager && ( + + {t("common.add_new_project")} + + + )} + )} diff --git a/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx index 0bc15edbbe..202029a7ab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx @@ -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 ( diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index feb25e8fa9..ff2f4a07c5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -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 ( + session={layoutData.session} + user={layoutData.user} + organization={layoutData.organization}> - - - {children} - + + {children} ); diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 5d6f28a66a..901219a1ec 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -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 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index dd1d448d1b..9d6d0b956c 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -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", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 752dd10123..0fcb3fcb46 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -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", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 3ae32c75ba..9343f36721 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -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.", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 703c11fd7c..b15f60fd81 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -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": "まず最初のフィルターを追加してください", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 970dd1857b..f6703d810e 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -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", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 5e463e7164..9719631b4f 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -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", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 6bfab07b84..72251bc632 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -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", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 01b2567086..6a1dc07cdc 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -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": "添加 您 的 第一个 过滤器 以 开始", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index b8f0cde416..add3115550 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -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": "新增您的第一個篩選器以開始使用", diff --git a/apps/web/modules/environments/lib/utils.test.ts b/apps/web/modules/environments/lib/utils.test.ts index 6d18badbfd..36f0b10d1b 100644 --- a/apps/web/modules/environments/lib/utils.test.ts +++ b/apps/web/modules/environments/lib/utils.test.ts @@ -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); + }); + }); }); diff --git a/apps/web/modules/environments/lib/utils.ts b/apps/web/modules/environments/lib/utils.ts index ebcc0829ea..3d4b54c504 100644 --- a/apps/web/modules/environments/lib/utils.ts +++ b/apps/web/modules/environments/lib/utils.ts @@ -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 => { + 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, + }; + } +); diff --git a/apps/web/modules/environments/types/environment-auth.ts b/apps/web/modules/environments/types/environment-auth.ts index 7c087de969..fd96a658f7 100644 --- a/apps/web/modules/environments/types/environment-auth.ts +++ b/apps/web/modules/environments/types/environment-auth.ts @@ -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; + +/** + * 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; +}; diff --git a/apps/web/modules/ui/components/breadcrumb/index.tsx b/apps/web/modules/ui/components/breadcrumb/index.tsx index aa913566c9..d19e2912ad 100644 --- a/apps/web/modules/ui/components/breadcrumb/index.tsx +++ b/apps/web/modules/ui/components/breadcrumb/index.tsx @@ -35,9 +35,10 @@ const BreadcrumbItem = React.forwardRef<