diff --git a/apps/web/app/page.test.tsx b/apps/web/app/page.test.tsx index a1d896880a..a117a07ff3 100644 --- a/apps/web/app/page.test.tsx +++ b/apps/web/app/page.test.tsx @@ -8,7 +8,7 @@ import { TUser } from "@formbricks/types/user"; import Page from "./page"; vi.mock("@/lib/project/service", () => ({ - getProjectEnvironmentsByOrganizationIds: vi.fn(), + getUserProjectEnvironmentsByOrganizationIds: vi.fn(), })); vi.mock("@/lib/instance/service", () => ({ @@ -152,7 +152,7 @@ describe("Page", () => { const { getIsFreshInstance } = await import("@/lib/instance/service"); const { getUser } = await import("@/lib/user/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service"); - const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); + const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getAccessFlags } = await import("@/lib/membership/utils"); const { redirect } = await import("next/navigation"); @@ -220,7 +220,7 @@ describe("Page", () => { } as any); vi.mocked(getIsFreshInstance).mockResolvedValue(false); vi.mocked(getUser).mockResolvedValue(mockUser); - vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue( + vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue( mockUserProjects as unknown as TProject[] ); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); @@ -241,7 +241,7 @@ describe("Page", () => { const { getServerSession } = await import("next-auth"); const { getIsFreshInstance } = await import("@/lib/instance/service"); const { getUser } = await import("@/lib/user/service"); - const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); + const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getAccessFlags } = await import("@/lib/membership/utils"); @@ -310,7 +310,7 @@ describe("Page", () => { } as any); vi.mocked(getIsFreshInstance).mockResolvedValue(false); vi.mocked(getUser).mockResolvedValue(mockUser); - vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue( + vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue( mockUserProjects as unknown as TProject[] ); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); @@ -334,7 +334,7 @@ describe("Page", () => { const { getOrganizationsByUserId } = await import("@/lib/organization/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getAccessFlags } = await import("@/lib/membership/utils"); - const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); + const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { render } = await import("@testing-library/react"); const mockUser: TUser = { @@ -432,7 +432,7 @@ describe("Page", () => { vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); - vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects); + vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects); vi.mocked(getAccessFlags).mockReturnValue({ isManager: false, isOwner: false, diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7410ec6596..39950f14b7 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -3,7 +3,7 @@ import { getIsFreshInstance } from "@/lib/instance/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getAccessFlags } from "@/lib/membership/utils"; import { getOrganizationsByUserId } from "@/lib/organization/service"; -import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service"; +import { getUserProjectEnvironmentsByOrganizationIds } from "@/lib/project/service"; import { getUser } from "@/lib/user/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { ClientLogout } from "@/modules/ui/components/client-logout"; @@ -34,7 +34,10 @@ const Page = async () => { return redirect("/setup/organization/create"); } - const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id)); + const projectsByOrg = await getUserProjectEnvironmentsByOrganizationIds( + userOrganizations.map((org) => org.id), + user.id + ); // Flatten all environments from all projects across all organizations const allEnvironments = projectsByOrg.flatMap((project) => project.environments); diff --git a/apps/web/lib/project/service.test.ts b/apps/web/lib/project/service.test.ts index a18443d2e0..c070688300 100644 --- a/apps/web/lib/project/service.test.ts +++ b/apps/web/lib/project/service.test.ts @@ -7,8 +7,8 @@ import { ITEMS_PER_PAGE } from "../constants"; import { getProject, getProjectByEnvironmentId, - getProjectEnvironmentsByOrganizationIds, getProjects, + getUserProjectEnvironmentsByOrganizationIds, getUserProjects, } from "./service"; @@ -21,6 +21,7 @@ vi.mock("@formbricks/database", () => ({ }, membership: { findFirst: vi.fn(), + findMany: vi.fn(), }, }, })); @@ -488,6 +489,7 @@ describe("Project Service", () => { test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => { const organizationId1 = createId(); const organizationId2 = createId(); + const userId = createId(); const mockProjects = [ { environments: [], @@ -497,16 +499,34 @@ describe("Project Service", () => { }, ]; + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + { + userId, + organizationId: organizationId2, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + ]); + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any); - const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]); + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); expect(result).toEqual(mockProjects); expect(prisma.project.findMany).toHaveBeenCalledWith({ where: { - organizationId: { - in: [organizationId1, organizationId2], - }, + OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }], }, select: { environments: true }, }); @@ -515,17 +535,36 @@ describe("Project Service", () => { test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => { const organizationId1 = createId(); const organizationId2 = createId(); + const userId = createId(); + + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + { + userId, + organizationId: organizationId2, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + ]); vi.mocked(prisma.project.findMany).mockResolvedValue([]); - const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]); + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); expect(result).toEqual([]); expect(prisma.project.findMany).toHaveBeenCalledWith({ where: { - organizationId: { - in: [organizationId1, organizationId2], - }, + OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }], }, select: { environments: true }, }); @@ -534,18 +573,111 @@ describe("Project Service", () => { test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => { const organizationId1 = createId(); const organizationId2 = createId(); + const userId = createId(); const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { code: "P2002", clientVersion: "5.0.0", }); + + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + ]); + vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError); - await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow( - DatabaseError - ); + await expect( + getUserProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2], userId) + ).rejects.toThrow(DatabaseError); }); test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => { - await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError); + const userId = createId(); + await expect(getUserProjectEnvironmentsByOrganizationIds(["wrong-id"], userId)).rejects.toThrow( + ValidationError + ); + }); + + test("getProjectsByOrganizationIds should return empty array when user has no memberships", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const userId = createId(); + + // Mock no memberships found + vi.mocked(prisma.membership.findMany).mockResolvedValue([]); + + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); + + expect(result).toEqual([]); + expect(prisma.membership.findMany).toHaveBeenCalledWith({ + where: { + userId, + organizationId: { + in: [organizationId1, organizationId2], + }, + }, + }); + // Should not call project.findMany when no memberships + expect(prisma.project.findMany).not.toHaveBeenCalled(); + }); + + test("getProjectsByOrganizationIds should handle member role with team access", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const userId = createId(); + const mockProjects = [ + { + environments: [], + }, + ]; + + // Mock membership where user is a member + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "member" as any, + accepted: true, + deprecatedRole: null, + }, + ]); + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any); + + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { + organizationId: organizationId1, + projectTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }, + ], + }, + select: { environments: true }, + }); }); }); diff --git a/apps/web/lib/project/service.ts b/apps/web/lib/project/service.ts index 93521f4337..cb644ed160 100644 --- a/apps/web/lib/project/service.ts +++ b/apps/web/lib/project/service.ts @@ -171,20 +171,56 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st } }); -export const getProjectEnvironmentsByOrganizationIds = reactCache( - async (organizationIds: string[]): Promise[]> => { - validateInputs([organizationIds, ZId.array()]); +export const getUserProjectEnvironmentsByOrganizationIds = reactCache( + async (organizationIds: string[], userId: string): Promise[]> => { + validateInputs([organizationIds, ZId.array()], [userId, ZId]); try { if (organizationIds.length === 0) { return []; } - const projects = await prisma.project.findMany({ + const memberships = await prisma.membership.findMany({ where: { + userId, organizationId: { in: organizationIds, }, }, + }); + + if (memberships.length === 0) { + return []; + } + + const whereConditions: Prisma.ProjectWhereInput[] = memberships.map((membership) => { + let projectWhereClause: Prisma.ProjectWhereInput = { + organizationId: membership.organizationId, + }; + + if (membership.role === "member") { + projectWhereClause = { + ...projectWhereClause, + projectTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }; + } + + return projectWhereClause; + }); + + const projects = await prisma.project.findMany({ + where: { + OR: whereConditions, + }, select: { environments: true }, });