diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index 965673ad009001..abc27f17198a8d 100644 --- a/packages/features/bookings/repositories/BookingRepository.test.ts +++ b/packages/features/bookings/repositories/BookingRepository.test.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { BookingRepository } from "./BookingRepository"; @@ -6,6 +7,10 @@ describe("BookingRepository", () => { let repository: BookingRepository; let mockPrismaClient: { $queryRaw: ReturnType; + booking: { + findUnique: ReturnType; + findMany: ReturnType; + }; }; beforeEach(() => { @@ -13,6 +18,10 @@ describe("BookingRepository", () => { mockPrismaClient = { $queryRaw: vi.fn(), + booking: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, }; repository = new BookingRepository(mockPrismaClient as unknown as PrismaClient); @@ -58,4 +67,176 @@ describe("BookingRepository", () => { expect(mockPrismaClient.$queryRaw).toHaveBeenCalledTimes(1); }); }); + + describe("findByUidIncludeAttendeeEmails", () => { + it("should query booking by uid with attendee emails and host user email", async () => { + const mockBooking = { + id: 1, + uid: "test-uid", + attendees: [{ email: "guest@example.com" }], + user: { email: "host@example.com" }, + }; + mockPrismaClient.booking.findUnique.mockResolvedValue(mockBooking); + + const result = await repository.findByUidIncludeAttendeeEmails({ uid: "test-uid" }); + + expect(result).toEqual(mockBooking); + expect(mockPrismaClient.booking.findUnique).toHaveBeenCalledWith({ + where: { uid: "test-uid" }, + select: { + id: true, + uid: true, + attendees: { select: { email: true } }, + user: { select: { email: true } }, + }, + }); + }); + + it("should return null when booking does not exist", async () => { + mockPrismaClient.booking.findUnique.mockResolvedValue(null); + + const result = await repository.findByUidIncludeAttendeeEmails({ uid: "nonexistent" }); + + expect(result).toBeNull(); + }); + }); + + describe("findByUserIdsAndDateRange", () => { + const dateFrom = new Date("2026-04-01T00:00:00Z"); + const dateTo = new Date("2026-04-30T23:59:59Z"); + + it("should return empty array when both userIds and userEmails are empty", async () => { + const result = await repository.findByUserIdsAndDateRange({ + userIds: [], + userEmails: [], + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockPrismaClient.booking.findMany).not.toHaveBeenCalled(); + }); + + it("should query bookings by userId when userIds are provided", async () => { + const mockBookings = [ + { + uid: "booking-1", + startTime: new Date("2026-04-10T09:00:00Z"), + endTime: new Date("2026-04-10T10:00:00Z"), + title: "Meeting", + userId: 10, + status: BookingStatus.ACCEPTED, + }, + ]; + mockPrismaClient.booking.findMany.mockResolvedValue(mockBookings); + + const result = await repository.findByUserIdsAndDateRange({ + userIds: [10], + userEmails: [], + dateFrom, + dateTo, + }); + + expect(result).toEqual(mockBookings); + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] }, + AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }], + }), + }) + ); + }); + + it("should query bookings by email when userEmails are provided", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([]); + + await repository.findByUserIdsAndDateRange({ + userIds: [], + userEmails: ["guest@example.com"], + dateFrom, + dateTo, + }); + + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + { + attendees: { + some: { email: { in: ["guest@example.com"], mode: "insensitive" } }, + }, + }, + ]), + }), + }) + ); + }); + + it("should combine userId and email conditions in OR clause", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([]); + + await repository.findByUserIdsAndDateRange({ + userIds: [10, 20], + userEmails: ["guest@example.com"], + dateFrom, + dateTo, + }); + + const callArgs = mockPrismaClient.booking.findMany.mock.calls[0][0]; + expect(callArgs.where.OR).toHaveLength(2); + expect(callArgs.where.OR).toEqual( + expect.arrayContaining([ + { userId: { in: [10, 20] } }, + { + attendees: { + some: { email: { in: ["guest@example.com"], mode: "insensitive" } }, + }, + }, + ]) + ); + }); + + it("should select the correct fields", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([]); + + await repository.findByUserIdsAndDateRange({ + userIds: [10], + userEmails: [], + dateFrom, + dateTo, + }); + + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + userId: true, + status: true, + }, + }) + ); + }); + + it("should include excludeUid in query when provided", async () => { + await repository.findByUserIdsAndDateRange({ + userIds: [1], + userEmails: [], + dateFrom, + dateTo, + excludeUid: "booking-to-exclude", + }); + + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + uid: { not: "booking-to-exclude" }, + }), + }) + ); + }); + }); }); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 96b20fdf373215..fca1e9d6289626 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2137,4 +2137,54 @@ export class BookingRepository implements IBookingRepository { }, }); } + + async findByUidIncludeAttendeeEmails({ uid }: { uid: string }) { + return this.prismaClient.booking.findUnique({ + where: { uid }, + select: { + id: true, + uid: true, + attendees: { select: { email: true } }, + user: { select: { email: true } }, + }, + }); + } + + async findByUserIdsAndDateRange({ + userIds, + userEmails, + dateFrom, + dateTo, + excludeUid, + }: { + userIds: number[]; + userEmails: string[]; + dateFrom: Date; + dateTo: Date; + excludeUid?: string; + }) { + if (!userIds.length && !userEmails.length) return []; + + return this.prismaClient.booking.findMany({ + where: { + status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] }, + AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }], + OR: [ + ...(userIds.length > 0 ? [{ userId: { in: userIds } }] : []), + ...(userEmails.length > 0 + ? [{ attendees: { some: { email: { in: userEmails, mode: "insensitive" as const } } } }] + : []), + ], + ...(excludeUid ? { uid: { not: excludeUid } } : {}), + }, + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + userId: true, + status: true, + }, + }); + } } diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts index 44e7001c96834d..10505ea52eefff 100644 --- a/packages/features/users/repositories/UserRepository.test.ts +++ b/packages/features/users/repositories/UserRepository.test.ts @@ -1,5 +1,6 @@ import prismock from "@calcom/testing/lib/__mocks__/prisma"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import type { PrismaClient } from "@calcom/prisma"; import { CreationSource } from "@calcom/prisma/enums"; import { beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("@calcom/app-store/delegationCredential", () => ({ @@ -111,4 +112,104 @@ describe("UserRepository", () => { ); }); }); + + describe("findByEmails", () => { + let mockPrismaClient: { + user: { + findMany: ReturnType; + }; + }; + let repo: UserRepository; + + beforeEach(() => { + mockPrismaClient = { + user: { + findMany: vi.fn(), + }, + }; + repo = new UserRepository(mockPrismaClient as unknown as PrismaClient); + }); + + test("should return empty array when emails list is empty", async () => { + const result = await repo.findByEmails({ emails: [] }); + + expect(result).toEqual([]); + expect(mockPrismaClient.user.findMany).not.toHaveBeenCalled(); + }); + + test("should look up users by primary email", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]) + .mockResolvedValueOnce([]); + + const result = await repo.findByEmails({ emails: ["user@example.com"] }); + + expect(result).toEqual([{ id: 1, email: "user@example.com" }]); + expect(mockPrismaClient.user.findMany).toHaveBeenCalledTimes(2); + }); + + test("should look up users by secondary (verified) email", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 2, email: "primary@example.com" }]); + + const result = await repo.findByEmails({ emails: ["secondary@example.com"] }); + + expect(result).toEqual([{ id: 2, email: "primary@example.com" }]); + expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { + secondaryEmails: { + some: { + email: { in: ["secondary@example.com"], mode: "insensitive" }, + emailVerified: { not: null }, + }, + }, + }, + }) + ); + }); + + test("should deduplicate users found via both primary and secondary email", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]) + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]); + + const result = await repo.findByEmails({ emails: ["user@example.com", "alias@example.com"] }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + test("should normalize emails to lowercase and deduplicate input", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]) + .mockResolvedValueOnce([]); + + await repo.findByEmails({ emails: ["User@Example.COM", "user@example.com"] }); + + expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { email: { in: ["user@example.com"], mode: "insensitive" } }, + }) + ); + }); + + test("should return multiple distinct users", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([ + { id: 1, email: "user1@example.com" }, + { id: 2, email: "user2@example.com" }, + ]) + .mockResolvedValueOnce([]); + + const result = await repo.findByEmails({ + emails: ["user1@example.com", "user2@example.com"], + }); + + expect(result).toHaveLength(2); + }); + }); }); diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 24878a56846295..1b695a2a937ed5 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1506,4 +1506,34 @@ export class UserRepository { return { email: user.email, username: user.username }; } + + async findByEmails({ emails }: { emails: string[] }) { + if (!emails.length) return []; + + const normalized = Array.from(new Set(emails.map((e) => e.toLowerCase()))); + + const [byPrimary, bySecondary] = await Promise.all([ + this.prismaClient.user.findMany({ + where: { email: { in: normalized, mode: "insensitive" } }, + select: { id: true, email: true }, + }), + this.prismaClient.user.findMany({ + where: { + secondaryEmails: { + some: { + email: { in: normalized, mode: "insensitive" }, + emailVerified: { not: null }, + }, + }, + }, + select: { id: true, email: true }, + }), + ]); + + const seen = new Map(); + for (const u of [...byPrimary, ...bySecondary]) { + if (!seen.has(u.id)) seen.set(u.id, u); + } + return Array.from(seen.values()); + } }