diff --git a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts index 115fbdcf3c..9d06163d0b 100644 --- a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts +++ b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts @@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({ }: GetAttributesForTeamHandlerOptions) { const { teamId } = input; const { user } = ctx; - const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId }); + const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId }); if (!isMemberOfTeam) { throw new TRPCError({ diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index fdfc875b17..50cc852771 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -263,7 +263,7 @@ export class MembershipRepository { }); } - static async findFirstByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) { + static async findUniqueByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) { return await prisma.membership.findUnique({ where: { userId_teamId: { @@ -398,4 +398,24 @@ export class MembershipRepository { }, }); } + + static async findAllByTeamIds({ + teamIds, + select = { userId: true }, + }: { + teamIds: number[]; + select?: Prisma.MembershipSelect; + }) { + return prisma.membership.findMany({ + where: { + team: { + id: { + in: teamIds, + }, + }, + accepted: true, + }, + select, + }); + } } diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index cf4a9e8836..daad78ed7a 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -186,6 +186,39 @@ export class TeamRepository { return getParsedTeam(team); } + static async findAllByParentId({ + parentId, + select = teamSelect, + }: { + parentId: number; + select?: Prisma.TeamSelect; + }) { + return await prisma.team.findMany({ + where: { + parentId, + }, + select, + }); + } + + static async findByIdAndParentId({ + id, + parentId, + select = teamSelect, + }: { + id: number; + parentId: number; + select?: Prisma.TeamSelect; + }) { + return await prisma.team.findFirst({ + where: { + id, + parentId, + }, + select, + }); + } + static async deleteById({ id }: { id: number }) { try { await WorkflowService.deleteWorkflowRemindersOfRemovedTeam(id); diff --git a/packages/lib/server/repository/__tests__/insightsBooking.integration-test.ts b/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts similarity index 89% rename from packages/lib/server/repository/__tests__/insightsBooking.integration-test.ts rename to packages/lib/server/service/__tests__/insightsBooking.integration-test.ts index 3a015ec9a5..fdfd58336f 100644 --- a/packages/lib/server/repository/__tests__/insightsBooking.integration-test.ts +++ b/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import prisma from "@calcom/prisma"; import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; -import { InsightsBookingRepository } from "../insightsBooking"; +import { InsightsBookingService } from "../../service/insightsBooking"; // Helper function to create unique test data async function createTestData({ @@ -195,15 +195,15 @@ async function createTestData({ }; } -describe("InsightsBookingRepository Integration Tests", () => { +describe("InsightsBookingService Integration Tests", () => { describe("Authorization Conditions", () => { it("should return NOTHING for invalid options", async () => { - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: null as any, }); - const conditions = await repository.getAuthorizationConditions(); + const conditions = await service.getAuthorizationConditions(); expect(conditions).toEqual({ id: -1 }); }); @@ -229,7 +229,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "org", @@ -238,7 +238,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getAuthorizationConditions(); + const conditions = await service.getAuthorizationConditions(); expect(conditions).toEqual({ id: -1 }); // Clean up @@ -257,7 +257,7 @@ describe("InsightsBookingRepository Integration Tests", () => { orgRole: MembershipRole.OWNER, }); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -266,7 +266,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getAuthorizationConditions(); + const conditions = await service.getAuthorizationConditions(); expect(conditions).toEqual({ AND: [ { @@ -285,7 +285,7 @@ describe("InsightsBookingRepository Integration Tests", () => { orgRole: MembershipRole.OWNER, }); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "team", @@ -295,7 +295,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getAuthorizationConditions(); + const conditions = await service.getAuthorizationConditions(); expect(conditions).toEqual({ AND: [ { @@ -335,7 +335,7 @@ describe("InsightsBookingRepository Integration Tests", () => { const team2 = testData.additionalTeams[0]; // First additional team const team3 = testData.additionalTeams[1]; // Second additional team - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "org", @@ -344,7 +344,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getAuthorizationConditions(); + const conditions = await service.getAuthorizationConditions(); expect(conditions).toEqual({ AND: [ @@ -375,7 +375,7 @@ describe("InsightsBookingRepository Integration Tests", () => { it("should return null when no filters are provided", async () => { const testData = await createTestData(); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -384,7 +384,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getFilterConditions(); + const conditions = await service.getFilterConditions(); expect(conditions).toBeNull(); await testData.cleanup(); @@ -393,7 +393,7 @@ describe("InsightsBookingRepository Integration Tests", () => { it("should build eventTypeId filter conditions", async () => { const testData = await createTestData(); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -405,7 +405,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getFilterConditions(); + const conditions = await service.getFilterConditions(); expect(conditions).toEqual({ AND: [ { @@ -420,7 +420,7 @@ describe("InsightsBookingRepository Integration Tests", () => { it("should build memberUserId filter conditions", async () => { const testData = await createTestData(); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -432,7 +432,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getFilterConditions(); + const conditions = await service.getFilterConditions(); expect(conditions).toEqual({ AND: [ { @@ -447,7 +447,7 @@ describe("InsightsBookingRepository Integration Tests", () => { it("should combine multiple filter conditions", async () => { const testData = await createTestData(); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -460,7 +460,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const conditions = await repository.getFilterConditions(); + const conditions = await service.getFilterConditions(); expect(conditions).toEqual({ AND: [ { @@ -483,7 +483,7 @@ describe("InsightsBookingRepository Integration Tests", () => { orgRole: MembershipRole.OWNER, }); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -493,7 +493,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }); // First call should build conditions - const conditions1 = await repository.getAuthorizationConditions(); + const conditions1 = await service.getAuthorizationConditions(); expect(conditions1).toEqual({ AND: [ { @@ -504,7 +504,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }); // Second call should use cached conditions - const conditions2 = await repository.getAuthorizationConditions(); + const conditions2 = await service.getAuthorizationConditions(); expect(conditions2).toEqual(conditions1); // Clean up @@ -514,7 +514,7 @@ describe("InsightsBookingRepository Integration Tests", () => { it("should cache filter conditions", async () => { const testData = await createTestData(); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -527,7 +527,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }); // First call should build conditions - const conditions1 = await repository.getFilterConditions(); + const conditions1 = await service.getFilterConditions(); expect(conditions1).toEqual({ AND: [ { @@ -537,7 +537,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }); // Second call should use cached conditions - const conditions2 = await repository.getFilterConditions(); + const conditions2 = await service.getFilterConditions(); expect(conditions2).toEqual(conditions1); await testData.cleanup(); @@ -575,7 +575,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const repository = new InsightsBookingRepository({ + const service = new InsightsBookingService({ prisma, options: { scope: "user", @@ -587,7 +587,7 @@ describe("InsightsBookingRepository Integration Tests", () => { }, }); - const results = await repository.findMany({ + const results = await service.findMany({ select: { id: true, title: true, diff --git a/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts b/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts new file mode 100644 index 0000000000..400e90dd58 --- /dev/null +++ b/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts @@ -0,0 +1,705 @@ +import type { Team, User, Membership } from "@prisma/client"; +import { randomUUID } from "crypto"; +import { describe, expect, it } from "vitest"; + +import prisma from "@calcom/prisma"; +import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; + +import { InsightsRoutingService } from "../../service/insightsRouting"; + +// Helper function to create unique test data +async function createTestData({ + teamRole = MembershipRole.MEMBER, + orgRole = MembershipRole.MEMBER, + moreTeamsAndUsers = [], +}: { + teamRole?: MembershipRole; + orgRole?: MembershipRole; + moreTeamsAndUsers?: MembershipRole[][]; // Array of teams, each team has array of user roles +} = {}) { + // Create test user + const user = await prisma.user.create({ + data: { + email: `test-user-${randomUUID()}@example.com`, + username: `testuser-${randomUUID()}`, + name: "Test User", + }, + }); + + // Create test organization + const org = await prisma.team.create({ + data: { + name: "Test Org", + slug: `test-org-${randomUUID()}`, + isOrganization: true, + }, + }); + + // Create test team under the organization + const team = await prisma.team.create({ + data: { + name: "Test Team", + slug: `test-team-${randomUUID()}`, + isOrganization: false, + parentId: org.id, + }, + }); + + // Create test event type + const eventType = await prisma.eventType.create({ + data: { + title: "Test Event Type", + slug: `test-event-type-${randomUUID()}`, + length: 60, + userId: user.id, + teamId: team.id, + }, + }); + + // Create test booking + const booking = await prisma.booking.create({ + data: { + uid: `test-booking-${randomUUID()}`, + title: "Test Booking", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: user.id, + eventTypeId: eventType.id, + status: BookingStatus.PENDING, + }, + }); + + // Create test routing form + const form = await prisma.app_RoutingForms_Form.create({ + data: { + name: "Test Routing Form", + userId: user.id, + teamId: team.id, + fields: [ + { + id: `text-field-id-${randomUUID()}`, + type: "text", + label: "Name", + required: true, + }, + { + id: `email-field-id-${randomUUID()}`, + type: "email", + label: "Email", + required: true, + }, + ], + }, + }); + + // Create test form response + const formResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: `test-filler-${randomUUID()}`, + formId: form.id, + response: { + [`text-field-id-${randomUUID()}`]: { + label: "Name", + value: "Test Name", + }, + [`email-field-id-${randomUUID()}`]: { + label: "Email", + value: "test@example.com", + }, + }, + routedToBookingUid: booking.uid, + }, + }); + + const membership = await prisma.membership.create({ + data: { + userId: user.id, + teamId: team.id, + role: teamRole, + accepted: true, + }, + }); + + const orgMembership = await prisma.membership.create({ + data: { + userId: user.id, + teamId: org.id, + role: orgRole, + accepted: true, + }, + }); + + // Create additional teams and users based on moreTeamsAndUsers + const additionalTeams: Team[] = []; + const additionalUsers: User[] = []; + const additionalMemberships: Membership[] = []; + + for (let i = 0; i < moreTeamsAndUsers.length; i++) { + const teamRoles = moreTeamsAndUsers[i]; + const additionalTeam = await prisma.team.create({ + data: { + name: `Additional Team ${i + 1}`, + slug: `additional-team-${i + 1}-${randomUUID()}`, + isOrganization: false, + parentId: org.id, + }, + }); + additionalTeams.push(additionalTeam); + + for (let j = 0; j < teamRoles.length; j++) { + const role = teamRoles[j]; + const additionalUser = await prisma.user.create({ + data: { + email: `additional-user-${i}-${j}-${randomUUID()}@example.com`, + username: `additionaluser-${i}-${j}-${randomUUID()}`, + name: `Additional User ${i}-${j}`, + }, + }); + additionalUsers.push(additionalUser); + + const additionalMembership = await prisma.membership.create({ + data: { + userId: additionalUser.id, + teamId: additionalTeam.id, + role: role, + accepted: true, + }, + }); + additionalMemberships.push(additionalMembership); + } + } + + // Cleanup function + const cleanup = async () => { + // Delete in reverse order to avoid foreign key constraints + for (const membership of additionalMemberships) { + await prisma.membership.delete({ + where: { id: membership.id }, + }); + } + for (const user of additionalUsers) { + await prisma.user.delete({ + where: { id: user.id }, + }); + } + for (const team of additionalTeams) { + await prisma.team.delete({ + where: { id: team.id }, + }); + } + + await prisma.membership.delete({ + where: { id: orgMembership.id }, + }); + await prisma.membership.delete({ + where: { id: membership.id }, + }); + await prisma.app_RoutingForms_FormResponse.delete({ + where: { id: formResponse.id }, + }); + await prisma.booking.delete({ + where: { id: booking.id }, + }); + await prisma.eventType.delete({ + where: { id: eventType.id }, + }); + await prisma.app_RoutingForms_Form.delete({ + where: { id: form.id }, + }); + await prisma.team.delete({ + where: { id: team.id }, + }); + await prisma.team.delete({ + where: { id: org.id }, + }); + await prisma.user.delete({ + where: { id: user.id }, + }); + }; + + return { + user, + org, + team, + eventType, + booking, + form, + formResponse, + membership, + orgMembership, + additionalTeams, + additionalUsers, + additionalMemberships, + cleanup, + }; +} + +describe("InsightsRoutingService Integration Tests", () => { + describe("Authorization Conditions", () => { + it("should return NOTHING for invalid options", async () => { + const service = new InsightsRoutingService({ + prisma, + options: null as any, + }); + + const conditions = await service.getAuthorizationConditions(); + expect(conditions).toEqual({ id: -1 }); + }); + + it("should return NOTHING for non-owner/admin user", async () => { + const testData = await createTestData(); + + // Create a regular user who is not an owner/admin + const regularUser = await prisma.user.create({ + data: { + email: `regular-user-${randomUUID()}@example.com`, + username: `regularuser-${randomUUID()}`, + name: "Regular User", + }, + }); + + // Add the user as a regular member (not owner/admin) to the organization + const membership = await prisma.membership.create({ + data: { + userId: regularUser.id, + teamId: testData.org.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "org", + userId: regularUser.id, + orgId: testData.org.id, + }, + }); + + const conditions = await service.getAuthorizationConditions(); + expect(conditions).toEqual({ id: -1 }); + + // Clean up + await prisma.membership.delete({ + where: { id: membership.id }, + }); + await prisma.user.delete({ + where: { id: regularUser.id }, + }); + await testData.cleanup(); + }); + + it("should build user scope conditions", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const conditions = await service.getAuthorizationConditions(); + expect(conditions).toEqual({ + AND: [ + { + formUserId: testData.user.id, + }, + ], + }); + + await testData.cleanup(); + }); + + it("should build team scope conditions", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "team", + userId: testData.user.id, + orgId: testData.org.id, + teamId: testData.team.id, + }, + }); + + const conditions = await service.getAuthorizationConditions(); + expect(conditions).toEqual({ + AND: [ + { + formTeamId: testData.team.id, + }, + ], + }); + + // Clean up + await testData.cleanup(); + }); + + it("should build org scope conditions", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + moreTeamsAndUsers: [ + [MembershipRole.MEMBER], // Team 2: 1 user with MEMBER role + [MembershipRole.MEMBER], // Team 3: 1 user with MEMBER role + ], + }); + + // Get references to additional data + const team2 = testData.additionalTeams[0]; // First additional team + const team3 = testData.additionalTeams[1]; // Second additional team + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "org", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const conditions = await service.getAuthorizationConditions(); + + expect(conditions).toEqual({ + AND: [ + { + formTeamId: { + in: [testData.org.id, testData.team.id, team2.id, team3.id], + }, + }, + ], + }); + + await testData.cleanup(); + }); + + it("should return NOTHING for team scope when team is not child of org", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + // Create a team that is not a child of the org + const unrelatedTeam = await prisma.team.create({ + data: { + name: "Unrelated Team", + slug: `unrelated-team-${randomUUID()}`, + isOrganization: false, + parentId: null, // Not part of the org + }, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "team", + userId: testData.user.id, + orgId: testData.org.id, + teamId: unrelatedTeam.id, + }, + }); + + const conditions = await service.getAuthorizationConditions(); + expect(conditions).toEqual({ AND: [{ id: -1 }] }); + + // Clean up + await prisma.team.delete({ + where: { id: unrelatedTeam.id }, + }); + await testData.cleanup(); + }); + }); + + describe("Filter Conditions", () => { + it("should return null when no filters are provided", async () => { + const testData = await createTestData(); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const conditions = await service.getFilterConditions(); + expect(conditions).toBeNull(); + + await testData.cleanup(); + }); + }); + + describe("Caching", () => { + it("should cache authorization conditions", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + // First call should build conditions + const conditions1 = await service.getAuthorizationConditions(); + expect(conditions1).toEqual({ + AND: [ + { + formUserId: testData.user.id, + }, + ], + }); + + // Second call should use cached conditions + const conditions2 = await service.getAuthorizationConditions(); + expect(conditions2).toEqual(conditions1); + + // Clean up + await testData.cleanup(); + }); + + it("should cache filter conditions", async () => { + const testData = await createTestData(); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + // First call should build conditions + const conditions1 = await service.getFilterConditions(); + expect(conditions1).toBeNull(); + + // Second call should use cached conditions + const conditions2 = await service.getFilterConditions(); + expect(conditions2).toEqual(conditions1); + + await testData.cleanup(); + }); + }); + + describe("findMany", () => { + it("should combine authorization and filter conditions", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const results = await service.findMany({ + select: { + id: true, + formName: true, + }, + }); + + // Should return the user form response since it matches the authorization conditions + expect(results).toHaveLength(1); + expect(results[0]?.id).toBe(testData.formResponse.id); + + await testData.cleanup(); + }); + + it("should filter out unauthorized responses", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + // Create another user and form + const otherUser = await prisma.user.create({ + data: { + email: `other-user-${randomUUID()}@example.com`, + username: `otheruser-${randomUUID()}`, + name: "Other User", + }, + }); + + const otherForm = await prisma.app_RoutingForms_Form.create({ + data: { + name: "Other User Form", + userId: otherUser.id, + teamId: null, + fields: [ + { + id: "text-field-id", + type: "text", + label: "Name", + required: true, + }, + ], + }, + }); + + const otherFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: `other-form-filler-${randomUUID()}`, + formId: otherForm.id, + response: { + "text-field-id": { + label: "Name", + value: "Other Form Response", + }, + }, + }, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const results = await service.findMany({ + select: { + id: true, + formName: true, + }, + }); + + // Should only return the authorized user's form response + expect(results).toHaveLength(1); + expect(results[0]?.id).toBe(testData.formResponse.id); + expect(results[0]?.id).not.toBe(otherFormResponse.id); + + // Clean up + await prisma.app_RoutingForms_FormResponse.delete({ + where: { id: otherFormResponse.id }, + }); + await prisma.app_RoutingForms_Form.delete({ + where: { id: otherForm.id }, + }); + await prisma.user.delete({ + where: { id: otherUser.id }, + }); + await testData.cleanup(); + }); + + it("should fetch all forms in org scope when user is in same team", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + // Create another user in the same team + const teamMember = await prisma.user.create({ + data: { + email: `team-member-${randomUUID()}@example.com`, + username: `teammember-${randomUUID()}`, + name: "Team Member", + }, + }); + + // Add the team member to the same team + const teamMembership = await prisma.membership.create({ + data: { + userId: teamMember.id, + teamId: testData.team.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + }); + + // Create a form under the team member + const teamMemberForm = await prisma.app_RoutingForms_Form.create({ + data: { + name: "Team Member Form", + userId: teamMember.id, + teamId: testData.team.id, // Same team as the original user + fields: [ + { + id: `text-field-id-${randomUUID()}`, + type: "text", + label: "Name", + required: true, + }, + ], + }, + }); + + // Create a form response for the team member's form + const teamMemberFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: `team-member-form-filler-${randomUUID()}`, + formId: teamMemberForm.id, + response: { + [`text-field-id-${randomUUID()}`]: { + label: "Name", + value: "Team Member Form Response", + }, + }, + }, + }); + + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "org", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const results = await service.findMany({ + select: { + id: true, + formName: true, + }, + }); + + // Should return both form responses (original user's and team member's) + expect(results).toHaveLength(2); + + const responseIds = results.map((r) => r.id).sort(); + const expectedIds = [testData.formResponse.id, teamMemberFormResponse.id].sort(); + expect(responseIds).toEqual(expectedIds); + + // Clean up + await prisma.app_RoutingForms_FormResponse.delete({ + where: { id: teamMemberFormResponse.id }, + }); + await prisma.app_RoutingForms_Form.delete({ + where: { id: teamMemberForm.id }, + }); + await prisma.membership.delete({ + where: { id: teamMembership.id }, + }); + await prisma.user.delete({ + where: { id: teamMember.id }, + }); + await testData.cleanup(); + }); + }); +}); diff --git a/packages/lib/server/repository/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts similarity index 71% rename from packages/lib/server/repository/insightsBooking.ts rename to packages/lib/server/service/insightsBooking.ts index 6923d2098e..e035613501 100644 --- a/packages/lib/server/repository/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -2,8 +2,12 @@ import type { Prisma } from "@prisma/client"; import { z } from "zod"; import type { readonlyPrisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; -export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scope", [ +import { MembershipRepository } from "../repository/membership"; +import { TeamRepository } from "../repository/team"; + +export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [ z.object({ scope: z.literal("user"), userId: z.number(), @@ -22,9 +26,9 @@ export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scop }), ]); -export type InsightsBookingRepositoryOptions = z.infer; +export type InsightsBookingServiceOptions = z.infer; -export type InsightsBookingRepositoryFilterOptions = { +export type InsightsBookingServiceFilterOptions = { eventTypeId?: number; memberUserId?: number; }; @@ -33,10 +37,10 @@ const NOTHING = { id: -1, } as const; -export class InsightsBookingRepository { +export class InsightsBookingService { private prisma: typeof readonlyPrisma; - private options: InsightsBookingRepositoryOptions | null; - private filters?: InsightsBookingRepositoryFilterOptions; + private options: InsightsBookingServiceOptions | null; + private filters?: InsightsBookingServiceFilterOptions; private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput; private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null; @@ -46,12 +50,11 @@ export class InsightsBookingRepository { filters, }: { prisma: typeof readonlyPrisma; - options: InsightsBookingRepositoryOptions; - filters?: InsightsBookingRepositoryFilterOptions; + options: InsightsBookingServiceOptions; + filters?: InsightsBookingServiceFilterOptions; }) { this.prisma = prisma; - - const validation = insightsBookingRepositoryOptionsSchema.safeParse(options); + const validation = insightsBookingServiceOptionsSchema.safeParse(options); this.options = validation.success ? validation.data : null; this.filters = filters; @@ -138,37 +141,21 @@ export class InsightsBookingRepository { } private async buildOrgAuthorizationCondition( - options: Extract + options: Extract ): Promise { // Get all teams from the organization - const teamsFromOrg = await this.prisma.team.findMany({ - where: { - parentId: options.orgId, - }, - select: { - id: true, - }, + const teamsFromOrg = await TeamRepository.findAllByParentId({ + parentId: options.orgId, + select: { id: true }, }); const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)]; // Get all users from the organization const userIdsFromOrg = teamsFromOrg.length > 0 - ? ( - await this.prisma.membership.findMany({ - where: { - team: { - id: { - in: teamIds, - }, - }, - accepted: true, - }, - select: { - userId: true, - }, - }) - ).map((m) => m.userId) + ? (await MembershipRepository.findAllByTeamIds({ teamIds, select: { userId: true } })).map( + (m) => m.userId + ) : []; return { @@ -194,26 +181,20 @@ export class InsightsBookingRepository { } private async buildTeamAuthorizationCondition( - options: Extract + options: Extract ): Promise { - const childTeamOfOrg = await this.prisma.team.findFirst({ - where: { - id: options.teamId, - parentId: options.orgId, - }, + const childTeamOfOrg = await TeamRepository.findByIdAndParentId({ + id: options.teamId, + parentId: options.orgId, + select: { id: true }, }); if (!childTeamOfOrg) { return NOTHING; } - const usersFromTeam = await this.prisma.membership.findMany({ - where: { - teamId: options.teamId, - accepted: true, - }, - select: { - userId: true, - }, + const usersFromTeam = await MembershipRepository.findAllByTeamIds({ + teamIds: [options.teamId], + select: { userId: true }, }); const userIdsFromTeam = usersFromTeam.map((u) => u.userId); @@ -235,22 +216,12 @@ export class InsightsBookingRepository { private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { // Check if the user is an owner or admin of the organization - const membershipOrg = await this.prisma.membership.findUnique({ - where: { - userId_teamId: { - userId, - teamId: orgId, - }, - accepted: true, - role: { - in: ["OWNER", "ADMIN"], - }, - }, - select: { - id: true, - }, - }); - - return Boolean(membershipOrg); + const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); + return Boolean( + membership && + membership.accepted && + membership.role && + ([MembershipRole.OWNER, MembershipRole.ADMIN] as const).includes(membership.role) + ); } } diff --git a/packages/lib/server/service/insightsRouting.ts b/packages/lib/server/service/insightsRouting.ts new file mode 100644 index 0000000000..4b35ac2a06 --- /dev/null +++ b/packages/lib/server/service/insightsRouting.ts @@ -0,0 +1,168 @@ +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import type { readonlyPrisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { MembershipRepository } from "../repository/membership"; +import { TeamRepository } from "../repository/team"; + +export const insightsRoutingServiceOptionsSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal("user"), + userId: z.number(), + orgId: z.number(), + }), + z.object({ + scope: z.literal("org"), + userId: z.number(), + orgId: z.number(), + }), + z.object({ + scope: z.literal("team"), + userId: z.number(), + orgId: z.number(), + teamId: z.number(), + }), +]); + +export type InsightsRoutingServiceOptions = z.infer; + +export type InsightsRoutingServiceFilterOptions = { + // Empty for now +}; + +const NOTHING = { + id: -1, +} as const; + +export class InsightsRoutingService { + private prisma: typeof readonlyPrisma; + private options: InsightsRoutingServiceOptions | null; + private filters?: InsightsRoutingServiceFilterOptions; + private cachedAuthConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput; + private cachedFilterConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput | null; + + constructor({ + prisma, + options, + filters, + }: { + prisma: typeof readonlyPrisma; + options: InsightsRoutingServiceOptions; + filters?: InsightsRoutingServiceFilterOptions; + }) { + this.prisma = prisma; + + const validation = insightsRoutingServiceOptionsSchema.safeParse(options); + this.options = validation.success ? validation.data : null; + + this.filters = filters; + } + + async findMany(findManyArgs: Prisma.RoutingFormResponseDenormalizedFindManyArgs) { + const authConditions = await this.getAuthorizationConditions(); + const filterConditions = await this.getFilterConditions(); + + return this.prisma.routingFormResponseDenormalized.findMany({ + ...findManyArgs, + where: { + ...findManyArgs.where, + AND: [authConditions, filterConditions].filter( + (c): c is Prisma.RoutingFormResponseDenormalizedWhereInput => c !== null && c !== undefined + ), + }, + }); + } + + async getAuthorizationConditions(): Promise { + if (this.cachedAuthConditions === undefined) { + this.cachedAuthConditions = await this.buildAuthorizationConditions(); + } + return this.cachedAuthConditions; + } + + async getFilterConditions(): Promise { + if (this.cachedFilterConditions === undefined) { + this.cachedFilterConditions = await this.buildFilterConditions(); + } + return this.cachedFilterConditions; + } + + async buildFilterConditions(): Promise { + // Empty for now + return null; + } + + async buildAuthorizationConditions(): Promise { + if (!this.options) { + return NOTHING; + } + const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId); + if (!isOwnerOrAdmin) { + return NOTHING; + } + + const conditions: Prisma.RoutingFormResponseDenormalizedWhereInput[] = []; + + if (this.options.scope === "user") { + conditions.push({ + formUserId: this.options.userId, + }); + } else if (this.options.scope === "org") { + conditions.push(await this.buildOrgAuthorizationCondition(this.options)); + } else if (this.options.scope === "team") { + conditions.push(await this.buildTeamAuthorizationCondition(this.options)); + } else { + return NOTHING; + } + + return { + AND: conditions, + }; + } + + private async buildOrgAuthorizationCondition( + options: Extract + ): Promise { + // Get all teams from the organization + const teamsFromOrg = await TeamRepository.findAllByParentId({ + parentId: options.orgId, + select: { id: true }, + }); + + return { + formTeamId: { + in: [options.orgId, ...teamsFromOrg.map((t) => t.id)], + }, + }; + } + + private async buildTeamAuthorizationCondition( + options: Extract + ): Promise { + const childTeamOfOrg = await TeamRepository.findByIdAndParentId({ + id: options.teamId, + parentId: options.orgId, + select: { id: true }, + }); + if (!childTeamOfOrg) { + return NOTHING; + } + + return { + formTeamId: options.teamId, + }; + } + + private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { + // Check if the user is an owner or admin of the organization + const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); + return Boolean( + membership && + membership.accepted && + membership.role && + (membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN) + ); + } +} diff --git a/packages/lib/service/attribute/server/assignValueToUser.ts b/packages/lib/service/attribute/server/assignValueToUser.ts index bece2c0833..aed25d4fe0 100644 --- a/packages/lib/service/attribute/server/assignValueToUser.ts +++ b/packages/lib/service/attribute/server/assignValueToUser.ts @@ -418,7 +418,7 @@ export const assignValueToUserInOrgBulk = async ({ attributeLabelToValueMap: AttributeLabelToValueMap; updater: BulkAttributeAssigner; }) => { - const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId }); + const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 }; if (!membership) { console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`); diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts index 505660af37..aa78a89850 100644 --- a/packages/trpc/server/routers/viewer/teams/get.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -14,7 +14,7 @@ type GetDataOptions = { }; export const get = async ({ ctx, input }: GetDataOptions) => { - const teamMembership = await MembershipRepository.findFirstByUserIdAndTeamId({ + const teamMembership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: ctx.user.id, teamId: input.teamId, });