diff --git a/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts b/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts index fdfd58336fa..fabd45af99b 100644 --- a/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts +++ b/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts @@ -1,4 +1,5 @@ import type { Team, User, Membership } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { describe, expect, it } from "vitest"; import prisma from "@calcom/prisma"; @@ -6,6 +7,8 @@ import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; import { InsightsBookingService } from "../../service/insightsBooking"; +const NOTHING_CONDITION = Prisma.sql`1=0`; + // Helper function to create unique test data async function createTestData({ teamRole = MembershipRole.MEMBER, @@ -204,7 +207,7 @@ describe("InsightsBookingService Integration Tests", () => { }); const conditions = await service.getAuthorizationConditions(); - expect(conditions).toEqual({ id: -1 }); + expect(conditions).toEqual(NOTHING_CONDITION); }); it("should return NOTHING for non-owner/admin user", async () => { @@ -239,7 +242,7 @@ describe("InsightsBookingService Integration Tests", () => { }); const conditions = await service.getAuthorizationConditions(); - expect(conditions).toEqual({ id: -1 }); + expect(conditions).toEqual(NOTHING_CONDITION); // Clean up await prisma.membership.delete({ @@ -267,14 +270,7 @@ describe("InsightsBookingService Integration Tests", () => { }); const conditions = await service.getAuthorizationConditions(); - expect(conditions).toEqual({ - AND: [ - { - userId: testData.user.id, - teamId: null, - }, - ], - }); + expect(conditions).toEqual(Prisma.sql`("userId" = ${testData.user.id}) AND ("teamId" IS NULL)`); await testData.cleanup(); }); @@ -296,24 +292,11 @@ describe("InsightsBookingService Integration Tests", () => { }); const conditions = await service.getAuthorizationConditions(); - expect(conditions).toEqual({ - AND: [ - { - OR: [ - { - teamId: testData.team.id, - isTeamBooking: true, - }, - { - userId: { - in: [testData.user.id], - }, - isTeamBooking: false, - }, - ], - }, - ], - }); + expect(conditions).toEqual( + Prisma.sql`(("teamId" = ${testData.team.id}) AND ("isTeamBooking" = true)) OR (("userId" = ANY(${[ + testData.user.id, + ]})) AND ("isTeamBooking" = false))` + ); // Clean up await testData.cleanup(); @@ -346,26 +329,18 @@ describe("InsightsBookingService Integration Tests", () => { const conditions = await service.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, - }, - ], - }, - ], - }); + expect(conditions).toEqual( + Prisma.sql`(("teamId" = ANY(${[ + testData.org.id, + testData.team.id, + team2.id, + team3.id, + ]})) AND ("isTeamBooking" = true)) OR (("userId" = ANY(${[ + testData.user.id, + user2.id, + user3.id, + ]})) AND ("isTeamBooking" = false))` + ); await testData.cleanup(); }); @@ -406,13 +381,9 @@ describe("InsightsBookingService Integration Tests", () => { }); const conditions = await service.getFilterConditions(); - expect(conditions).toEqual({ - AND: [ - { - OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }], - }, - ], - }); + expect(conditions).toEqual( + Prisma.sql`("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})` + ); await testData.cleanup(); }); @@ -433,13 +404,7 @@ describe("InsightsBookingService Integration Tests", () => { }); const conditions = await service.getFilterConditions(); - expect(conditions).toEqual({ - AND: [ - { - userId: testData.user.id, - }, - ], - }); + expect(conditions).toEqual(Prisma.sql`"userId" = ${testData.user.id}`); await testData.cleanup(); }); @@ -461,90 +426,15 @@ describe("InsightsBookingService Integration Tests", () => { }); const conditions = await service.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 service = new InsightsBookingService({ - 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: [ - { - userId: testData.user.id, - teamId: null, - }, - ], - }); - - // 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 InsightsBookingService({ - 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 service.getFilterConditions(); - expect(conditions1).toEqual({ - AND: [ - { - OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }], - }, - ], - }); - - // Second call should use cached conditions - const conditions2 = await service.getFilterConditions(); - expect(conditions2).toEqual(conditions1); + expect(conditions).toEqual( + Prisma.sql`(("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})) AND ("userId" = ${testData.user.id})` + ); await testData.cleanup(); }); }); - describe("findMany", () => { + describe("getBaseConditions", () => { it("should combine authorization and filter conditions", async () => { const testData = await createTestData({ teamRole: MembershipRole.OWNER, @@ -587,16 +477,18 @@ describe("InsightsBookingService Integration Tests", () => { }, }); - const results = await service.findMany({ - select: { - id: true, - title: true, - }, - }); + const baseConditions = await service.getBaseConditions(); + const results = await prisma.$queryRaw<{ id: number }[]>` + SELECT id FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + `; // Should return the user booking since it matches both conditions - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe(userBooking.id); + expect(results).toEqual([ + { + id: userBooking.id, + }, + ]); // Clean up await prisma.booking.delete({ diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 6d9bb04f40e..f0858f4142b 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -1,4 +1,4 @@ -import type { Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { z } from "zod"; import type { readonlyPrisma } from "@calcom/prisma"; @@ -26,6 +26,13 @@ export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", }), ]); +export type InsightsBookingServicePublicOptions = { + scope: "user" | "org" | "team"; + userId: number; + orgId: number; + teamId?: number; +}; + export type InsightsBookingServiceOptions = z.infer; export type InsightsBookingServiceFilterOptions = { @@ -33,16 +40,14 @@ export type InsightsBookingServiceFilterOptions = { memberUserId?: number; }; -const NOTHING = { - id: -1, -} as const; +const NOTHING_CONDITION = Prisma.sql`1=0`; export class InsightsBookingService { private prisma: typeof readonlyPrisma; private options: InsightsBookingServiceOptions | null; private filters?: InsightsBookingServiceFilterOptions; - private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput; - private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null; + private cachedAuthConditions?: Prisma.Sql; + private cachedFilterConditions?: Prisma.Sql | null; constructor({ prisma, @@ -50,7 +55,7 @@ export class InsightsBookingService { filters, }: { prisma: typeof readonlyPrisma; - options: InsightsBookingServiceOptions; + options: InsightsBookingServicePublicOptions; filters?: InsightsBookingServiceFilterOptions; }) { this.prisma = prisma; @@ -60,89 +65,86 @@ export class InsightsBookingService { this.filters = filters; } - async findMany(findManyArgs: Prisma.BookingTimeStatusDenormalizedFindManyArgs) { + async getBaseConditions(): Promise { 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 - ), - }, - }); + if (authConditions && filterConditions) { + return Prisma.sql`(${authConditions}) AND (${filterConditions})`; + } else if (authConditions) { + return authConditions; + } else if (filterConditions) { + return filterConditions; + } else { + return NOTHING_CONDITION; + } } - async getAuthorizationConditions(): Promise { + async getAuthorizationConditions(): Promise { if (this.cachedAuthConditions === undefined) { this.cachedAuthConditions = await this.buildAuthorizationConditions(); } return this.cachedAuthConditions; } - async getFilterConditions(): Promise { + async getFilterConditions(): Promise { if (this.cachedFilterConditions === undefined) { this.cachedFilterConditions = await this.buildFilterConditions(); } return this.cachedFilterConditions; } - async buildFilterConditions(): Promise { - const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = []; + async buildFilterConditions(): Promise { + const conditions: Prisma.Sql[] = []; if (!this.filters) { return null; } if (this.filters.eventTypeId) { - conditions.push({ - OR: [{ eventTypeId: this.filters.eventTypeId }, { eventParentId: this.filters.eventTypeId }], - }); + conditions.push( + Prisma.sql`("eventTypeId" = ${this.filters.eventTypeId}) OR ("eventParentId" = ${this.filters.eventTypeId})` + ); } if (this.filters.memberUserId) { - conditions.push({ - userId: this.filters.memberUserId, - }); + conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`); } - return conditions.length > 0 ? { AND: conditions } : null; + if (conditions.length === 0) { + return null; + } + + // Join all conditions with AND + return conditions.reduce((acc, condition, index) => { + if (index === 0) return condition; + return Prisma.sql`(${acc}) AND (${condition})`; + }); } - async buildAuthorizationConditions(): Promise { + async buildAuthorizationConditions(): Promise { if (!this.options) { - return NOTHING; + return NOTHING_CONDITION; } const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId); if (!isOwnerOrAdmin) { - return NOTHING; + return NOTHING_CONDITION; } - const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = []; - if (this.options.scope === "user") { - conditions.push({ - userId: this.options.userId, - teamId: null, - }); + return Prisma.sql`("userId" = ${this.options.userId}) AND ("teamId" IS NULL)`; } else if (this.options.scope === "org") { - conditions.push(await this.buildOrgAuthorizationCondition(this.options)); + return await this.buildOrgAuthorizationCondition(this.options); } else if (this.options.scope === "team") { - conditions.push(await this.buildTeamAuthorizationCondition(this.options)); + return await this.buildTeamAuthorizationCondition(this.options); } else { - return NOTHING; + return NOTHING_CONDITION; } - - return { - AND: conditions, - }; } private async buildOrgAuthorizationCondition( options: Extract - ): Promise { + ): Promise { // Get all teams from the organization const teamRepo = new TeamRepository(this.prisma); const teamsFromOrg = await teamRepo.findAllByParentId({ @@ -159,31 +161,22 @@ export class InsightsBookingService { ) : []; - return { - OR: [ - { - teamId: { - in: teamIds, - }, - isTeamBooking: true, - }, - ...(userIdsFromOrg.length > 0 - ? [ - { - userId: { - in: Array.from(new Set(userIdsFromOrg)), - }, - isTeamBooking: false, - }, - ] - : []), - ], - }; + const conditions: Prisma.Sql[] = [Prisma.sql`("teamId" = ANY(${teamIds})) AND ("isTeamBooking" = true)`]; + + if (userIdsFromOrg.length > 0) { + const uniqueUserIds = Array.from(new Set(userIdsFromOrg)); + conditions.push(Prisma.sql`("userId" = ANY(${uniqueUserIds})) AND ("isTeamBooking" = false)`); + } + + return conditions.reduce((acc, condition, index) => { + if (index === 0) return condition; + return Prisma.sql`(${acc}) OR (${condition})`; + }); } private async buildTeamAuthorizationCondition( options: Extract - ): Promise { + ): Promise { const teamRepo = new TeamRepository(this.prisma); const childTeamOfOrg = await teamRepo.findByIdAndParentId({ id: options.teamId, @@ -191,7 +184,7 @@ export class InsightsBookingService { select: { id: true }, }); if (!childTeamOfOrg) { - return NOTHING; + return NOTHING_CONDITION; } const usersFromTeam = await MembershipRepository.findAllByTeamIds({ @@ -200,20 +193,18 @@ export class InsightsBookingService { }); const userIdsFromTeam = usersFromTeam.map((u) => u.userId); - return { - OR: [ - { - teamId: options.teamId, - isTeamBooking: true, - }, - { - userId: { - in: userIdsFromTeam, - }, - isTeamBooking: false, - }, - ], - }; + const conditions: Prisma.Sql[] = [ + Prisma.sql`("teamId" = ${options.teamId}) AND ("isTeamBooking" = true)`, + ]; + + if (userIdsFromTeam.length > 0) { + conditions.push(Prisma.sql`("userId" = ANY(${userIdsFromTeam})) AND ("isTeamBooking" = false)`); + } + + return conditions.reduce((acc, condition, index) => { + if (index === 0) return condition; + return Prisma.sql`(${acc}) OR (${condition})`; + }); } private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { @@ -223,7 +214,7 @@ export class InsightsBookingService { membership && membership.accepted && membership.role && - ([MembershipRole.OWNER, MembershipRole.ADMIN] as const).includes(membership.role) + (membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN) ); } }