diff --git a/packages/lib/server/repository/__tests__/insightsBooking.integration-test.ts b/packages/lib/server/repository/__tests__/insightsBooking.integration-test.ts new file mode 100644 index 0000000000..3a015ec9a5 --- /dev/null +++ b/packages/lib/server/repository/__tests__/insightsBooking.integration-test.ts @@ -0,0 +1,611 @@ +import type { Team, User, Membership } from "@prisma/client"; +import { describe, expect, it } from "vitest"; + +import prisma from "@calcom/prisma"; +import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; + +import { InsightsBookingRepository } from "../insightsBooking"; + +// 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 +} = {}) { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(7); + + // Create test user + const user = await prisma.user.create({ + data: { + email: `test-user-${timestamp}-${randomSuffix}@example.com`, + username: `testuser-${timestamp}-${randomSuffix}`, + name: "Test User", + }, + }); + + // Create test organization + const org = await prisma.team.create({ + data: { + name: `Test Org ${timestamp}-${randomSuffix}`, + slug: `test-org-${timestamp}-${randomSuffix}`, + isOrganization: true, + }, + }); + + // Create test team under the organization + const team = await prisma.team.create({ + data: { + name: `Test Team ${timestamp}-${randomSuffix}`, + slug: `test-team-${timestamp}-${randomSuffix}`, + isOrganization: false, + parentId: org.id, + }, + }); + + // Create test event type + const eventType = await prisma.eventType.create({ + data: { + title: "Test Event Type", + slug: "test-event-type", + length: 60, + userId: user.id, + teamId: team.id, + }, + }); + + // Create test booking + const booking = await prisma.booking.create({ + data: { + uid: `test-booking-${timestamp}-${randomSuffix}`, + title: "Test Booking", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: user.id, + eventTypeId: eventType.id, + status: BookingStatus.PENDING, + }, + }); + + 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 teamIndex = 0; teamIndex < moreTeamsAndUsers.length; teamIndex++) { + const teamUserRoles = moreTeamsAndUsers[teamIndex]; + + // Create the team + const additionalTeam = await prisma.team.create({ + data: { + name: `Test Team ${teamIndex + 2} ${timestamp}-${randomSuffix}`, + slug: `test-team-${teamIndex + 2}-${timestamp}-${randomSuffix}`, + isOrganization: false, + parentId: org.id, + }, + }); + additionalTeams.push(additionalTeam); + + // Create users for this team + for (let userIndex = 0; userIndex < teamUserRoles.length; userIndex++) { + const userRole = teamUserRoles[userIndex]; + + // Create the user + const additionalUser = await prisma.user.create({ + data: { + email: `test-user-team${teamIndex + 2}-${userIndex + 1}-${timestamp}-${randomSuffix}@example.com`, + username: `testuser-team${teamIndex + 2}-${userIndex + 1}-${timestamp}-${randomSuffix}`, + name: `Test User Team${teamIndex + 2}-${userIndex + 1}`, + }, + }); + additionalUsers.push(additionalUser); + + // Create the membership + const additionalMembership = await prisma.membership.create({ + data: { + userId: additionalUser.id, + teamId: additionalTeam.id, + role: userRole, + accepted: true, + }, + }); + additionalMemberships.push(additionalMembership); + } + } + + // Return test data and cleanup function + return { + user, + org, + orgMembership, + team, + eventType, + booking, + membership, + additionalTeams, + additionalUsers, + additionalMemberships, + cleanup: async () => { + // Clean up additional memberships + for (const membership of additionalMemberships) { + await prisma.membership.delete({ + where: { id: membership.id }, + }); + } + + // Clean up additional users + for (const user of additionalUsers) { + await prisma.user.delete({ + where: { id: user.id }, + }); + } + + // Clean up additional teams + for (const team of additionalTeams) { + await prisma.team.delete({ + where: { id: team.id }, + }); + } + + // Clean up main test data + await prisma.booking.deleteMany({ + where: { id: booking.id }, + }); + await prisma.eventType.deleteMany({ + where: { id: eventType.id }, + }); + await prisma.membership.deleteMany({ + where: { id: membership.id }, + }); + await prisma.membership.delete({ + where: { id: orgMembership.id }, + }); + await prisma.team.deleteMany({ + where: { id: team.id }, + }); + await prisma.team.deleteMany({ + where: { id: org.id }, + }); + await prisma.user.deleteMany({ + where: { id: user.id }, + }); + }, + }; +} + +describe("InsightsBookingRepository Integration Tests", () => { + describe("Authorization Conditions", () => { + it("should return NOTHING for invalid options", async () => { + const repository = new InsightsBookingRepository({ + prisma, + options: null as any, + }); + + const conditions = await repository.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-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`, + username: `regularuser-${Date.now()}-${Math.random().toString(36).substring(7)}`, + 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 repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "org", + userId: regularUser.id, + orgId: testData.org.id, + }, + }); + + const conditions = await repository.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 repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const conditions = await repository.getAuthorizationConditions(); + expect(conditions).toEqual({ + AND: [ + { + userId: testData.user.id, + teamId: null, + }, + ], + }); + + await testData.cleanup(); + }); + + it("should build team scope conditions", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "team", + userId: testData.user.id, + orgId: testData.org.id, + teamId: testData.team.id, + }, + }); + + const conditions = await repository.getAuthorizationConditions(); + expect(conditions).toEqual({ + AND: [ + { + OR: [ + { + teamId: testData.team.id, + isTeamBooking: true, + }, + { + userId: { + in: [testData.user.id], + }, + isTeamBooking: false, + }, + ], + }, + ], + }); + + // 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 user2 = testData.additionalUsers[0]; // First user from first team + const user3 = testData.additionalUsers[1]; // First user from second team + const team2 = testData.additionalTeams[0]; // First additional team + const team3 = testData.additionalTeams[1]; // Second additional team + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "org", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const conditions = await repository.getAuthorizationConditions(); + + expect(conditions).toEqual({ + AND: [ + { + OR: [ + { + teamId: { + in: [testData.org.id, testData.team.id, team2.id, team3.id], + }, + isTeamBooking: true, + }, + { + userId: { + in: [testData.user.id, user2.id, user3.id], + }, + isTeamBooking: false, + }, + ], + }, + ], + }); + + await testData.cleanup(); + }); + }); + + describe("Filter Conditions", () => { + it("should return null when no filters are provided", async () => { + const testData = await createTestData(); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + const conditions = await repository.getFilterConditions(); + expect(conditions).toBeNull(); + + await testData.cleanup(); + }); + + it("should build eventTypeId filter conditions", async () => { + const testData = await createTestData(); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + filters: { + eventTypeId: testData.eventType.id, + }, + }); + + const conditions = await repository.getFilterConditions(); + expect(conditions).toEqual({ + AND: [ + { + OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }], + }, + ], + }); + + await testData.cleanup(); + }); + + it("should build memberUserId filter conditions", async () => { + const testData = await createTestData(); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + filters: { + memberUserId: testData.user.id, + }, + }); + + const conditions = await repository.getFilterConditions(); + expect(conditions).toEqual({ + AND: [ + { + userId: testData.user.id, + }, + ], + }); + + await testData.cleanup(); + }); + + it("should combine multiple filter conditions", async () => { + const testData = await createTestData(); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + filters: { + eventTypeId: testData.eventType.id, + memberUserId: testData.user.id, + }, + }); + + const conditions = await repository.getFilterConditions(); + expect(conditions).toEqual({ + AND: [ + { + OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }], + }, + { + userId: testData.user.id, + }, + ], + }); + + await testData.cleanup(); + }); + }); + + describe("Caching", () => { + it("should cache authorization conditions", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + }); + + // First call should build conditions + const conditions1 = await repository.getAuthorizationConditions(); + expect(conditions1).toEqual({ + AND: [ + { + userId: testData.user.id, + teamId: null, + }, + ], + }); + + // Second call should use cached conditions + const conditions2 = await repository.getAuthorizationConditions(); + expect(conditions2).toEqual(conditions1); + + // Clean up + await testData.cleanup(); + }); + + it("should cache filter conditions", async () => { + const testData = await createTestData(); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + filters: { + eventTypeId: testData.eventType.id, + }, + }); + + // First call should build conditions + const conditions1 = await repository.getFilterConditions(); + expect(conditions1).toEqual({ + AND: [ + { + OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }], + }, + ], + }); + + // Second call should use cached conditions + const conditions2 = await repository.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, + }); + + // Create a user-scoped event type (not team-scoped) + const userEventType = await prisma.eventType.create({ + data: { + title: "User Event Type", + slug: "user-event-type", + length: 60, + userId: testData.user.id, + teamId: null, // User-scoped, not team-scoped + }, + }); + + // Create a booking for the user-scoped event type + const userBooking = await prisma.booking.create({ + data: { + uid: `user-booking-${Date.now()}-${Math.random().toString(36).substring(7)}`, + title: "User Booking", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: testData.user.id, + eventTypeId: userEventType.id, + status: BookingStatus.PENDING, + }, + }); + + const repository = new InsightsBookingRepository({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + filters: { + eventTypeId: userEventType.id, + }, + }); + + const results = await repository.findMany({ + select: { + id: true, + title: true, + }, + }); + + // Should return the user booking since it matches both conditions + expect(results).toHaveLength(1); + expect(results[0]?.id).toBe(userBooking.id); + + // Clean up + await prisma.booking.delete({ + where: { id: userBooking.id }, + }); + await prisma.eventType.delete({ + where: { id: userEventType.id }, + }); + await testData.cleanup(); + }); + }); +}); diff --git a/packages/lib/server/repository/insightsBooking.ts b/packages/lib/server/repository/insightsBooking.ts new file mode 100644 index 0000000000..6923d2098e --- /dev/null +++ b/packages/lib/server/repository/insightsBooking.ts @@ -0,0 +1,256 @@ +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import type { readonlyPrisma } from "@calcom/prisma"; + +export const insightsBookingRepositoryOptionsSchema = 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 InsightsBookingRepositoryOptions = z.infer; + +export type InsightsBookingRepositoryFilterOptions = { + eventTypeId?: number; + memberUserId?: number; +}; + +const NOTHING = { + id: -1, +} as const; + +export class InsightsBookingRepository { + private prisma: typeof readonlyPrisma; + private options: InsightsBookingRepositoryOptions | null; + private filters?: InsightsBookingRepositoryFilterOptions; + private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput; + private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null; + + constructor({ + prisma, + options, + filters, + }: { + prisma: typeof readonlyPrisma; + options: InsightsBookingRepositoryOptions; + filters?: InsightsBookingRepositoryFilterOptions; + }) { + this.prisma = prisma; + + const validation = insightsBookingRepositoryOptionsSchema.safeParse(options); + this.options = validation.success ? validation.data : null; + + this.filters = filters; + } + + async findMany(findManyArgs: Prisma.BookingTimeStatusDenormalizedFindManyArgs) { + const authConditions = await this.getAuthorizationConditions(); + const filterConditions = await this.getFilterConditions(); + + return this.prisma.bookingTimeStatusDenormalized.findMany({ + ...findManyArgs, + where: { + ...findManyArgs.where, + AND: [authConditions, filterConditions].filter( + (c): c is Prisma.BookingTimeStatusDenormalizedWhereInput => c !== null + ), + }, + }); + } + + 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 { + const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = []; + + if (!this.filters) { + return null; + } + + if (this.filters.eventTypeId) { + conditions.push({ + OR: [{ eventTypeId: this.filters.eventTypeId }, { eventParentId: this.filters.eventTypeId }], + }); + } + + if (this.filters.memberUserId) { + conditions.push({ + userId: this.filters.memberUserId, + }); + } + + return conditions.length > 0 ? { AND: conditions } : 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.BookingTimeStatusDenormalizedWhereInput[] = []; + + if (this.options.scope === "user") { + conditions.push({ + userId: this.options.userId, + teamId: null, + }); + } 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 this.prisma.team.findMany({ + where: { + 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) + : []; + + return { + OR: [ + { + teamId: { + in: teamIds, + }, + isTeamBooking: true, + }, + ...(userIdsFromOrg.length > 0 + ? [ + { + userId: { + in: Array.from(new Set(userIdsFromOrg)), + }, + isTeamBooking: false, + }, + ] + : []), + ], + }; + } + + private async buildTeamAuthorizationCondition( + options: Extract + ): Promise { + const childTeamOfOrg = await this.prisma.team.findFirst({ + where: { + id: options.teamId, + parentId: options.orgId, + }, + }); + if (!childTeamOfOrg) { + return NOTHING; + } + + const usersFromTeam = await this.prisma.membership.findMany({ + where: { + teamId: options.teamId, + accepted: true, + }, + select: { + userId: true, + }, + }); + const userIdsFromTeam = usersFromTeam.map((u) => u.userId); + + return { + OR: [ + { + teamId: options.teamId, + isTeamBooking: true, + }, + { + userId: { + in: userIdsFromTeam, + }, + isTeamBooking: false, + }, + ], + }; + } + + 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); + } +}