diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 4dd9c404c6..c3174fbe46 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -20,6 +20,7 @@ import type { RecurringEvent } from "@calcom/types/Calendar"; import type { UserProfile } from "@calcom/types/UserProfile"; import type { z } from "zod"; import type { EventType } from "./getEventTypeById"; + export type CustomInputParsed = typeof customInputSchema._output; export type AvailabilityOption = { @@ -51,6 +52,7 @@ export type Host = { groupId: string | null; location?: HostLocation | null; }; + export type TeamMember = { value: string; label: string; diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index a23fa121f7..a620abace1 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -1743,6 +1743,24 @@ export class EventTypeRepository implements IEventTypesRepository { }; } + async findChildrenByParentIdIncludeOwner(parentId: number) { + return this.prismaClient.eventType.findMany({ + where: { parentId }, + select: { + hidden: true, + slug: true, + owner: { + select: { + id: true, + name: true, + email: true, + eventTypes: { select: { slug: true } }, + }, + }, + }, + }); + } + async findByIdWithParentAndUserId(eventTypeId: number) { return this.prismaClient.eventType.findUnique({ where: { id: eventTypeId }, diff --git a/packages/features/host/repositories/HostRepository.ts b/packages/features/host/repositories/HostRepository.ts index 4369ab0be7..667cbb18b6 100644 --- a/packages/features/host/repositories/HostRepository.ts +++ b/packages/features/host/repositories/HostRepository.ts @@ -1,5 +1,5 @@ -import { AppCategories } from "@calcom/prisma/enums"; import type { PrismaClient } from "@calcom/prisma"; +import { AppCategories } from "@calcom/prisma/enums"; import { safeCredentialSelect } from "@calcom/prisma/selects/credential"; export class HostRepository { @@ -136,12 +136,194 @@ export class HostRepository { }); const hasMore = hosts.length > limit; - const items = hasMore ? hosts.slice(0, -1) : hosts; + const items = hasMore ? hosts.slice(0, limit) : hosts; const nextCursor = hasMore ? items[items.length - 1].userId : undefined; return { items, nextCursor, hasMore }; } + async findHostsPaginatedIncludeUser({ + eventTypeId, + cursor, + limit = 20, + search, + }: { + eventTypeId: number; + cursor?: number; + limit?: number; + search?: string; + }) { + const hosts = await this.prismaClient.host.findMany({ + where: { + eventTypeId, + ...(cursor && { userId: { gt: cursor } }), + ...(search && { + OR: [ + { user: { name: { contains: search, mode: "insensitive" as const } } }, + { user: { email: { contains: search, mode: "insensitive" as const } } }, + ], + }), + }, + take: limit + 1, + select: { + userId: true, + isFixed: true, + priority: true, + weight: true, + scheduleId: true, + groupId: true, + user: { + select: { + name: true, + avatarUrl: true, + timeZone: true, + }, + }, + }, + orderBy: [{ userId: "asc" }], + }); + + const hasMore = hosts.length > limit; + const items = hasMore ? hosts.slice(0, limit) : hosts; + const nextCursor = hasMore ? items[items.length - 1].userId : undefined; + + return { items, nextCursor, hasMore }; + } + + async findHostsPaginatedIncludeUserForAssignment({ + eventTypeId, + cursor, + limit = 20, + search, + memberUserIds, + }: { + eventTypeId: number; + cursor?: number; + limit?: number; + search?: string; + memberUserIds?: number[]; + }) { + const userIdFilter = memberUserIds !== undefined + ? cursor + ? { in: memberUserIds, gt: cursor } + : { in: memberUserIds } + : cursor + ? { gt: cursor } + : undefined; + + const hosts = await this.prismaClient.host.findMany({ + where: { + eventTypeId, + ...(userIdFilter && { userId: userIdFilter }), + ...(search && { + OR: [ + { user: { name: { contains: search, mode: "insensitive" as const } } }, + { user: { email: { contains: search, mode: "insensitive" as const } } }, + ], + }), + }, + take: limit + 1, + select: { + userId: true, + isFixed: true, + priority: true, + weight: true, + scheduleId: true, + groupId: true, + user: { + select: { + name: true, + email: true, + avatarUrl: true, + }, + }, + }, + orderBy: [{ userId: "asc" }], + }); + + const hasMore = hosts.length > limit; + const items = hasMore ? hosts.slice(0, limit) : hosts; + const nextCursor = hasMore ? items[items.length - 1].userId : undefined; + + // Only check on the first page to avoid an extra query on every scroll + const hasFixedHosts = !cursor + ? (await this.prismaClient.host.count({ + where: { eventTypeId, isFixed: true }, + take: 1, + })) > 0 + : undefined; + + return { items, nextCursor, hasMore, hasFixedHosts }; + } + + async findAllRoundRobinHosts({ eventTypeId }: { eventTypeId: number }) { + return this.prismaClient.host.findMany({ + where: { + eventTypeId, + isFixed: false, + }, + select: { + userId: true, + weight: true, + user: { + select: { + name: true, + email: true, + avatarUrl: true, + }, + }, + }, + orderBy: [{ userId: "asc" }], + }); + } + + async findChildrenForAssignmentPaginated({ + eventTypeId, + cursor, + limit = 20, + search, + }: { + eventTypeId: number; + cursor?: number; + limit?: number; + search?: string; + }) { + const children = await this.prismaClient.eventType.findMany({ + where: { + parentId: eventTypeId, + ...(cursor && { id: { gt: cursor } }), + ...(search && { + OR: [ + { owner: { name: { contains: search, mode: "insensitive" as const } } }, + { owner: { email: { contains: search, mode: "insensitive" as const } } }, + ], + }), + }, + take: limit + 1, + select: { + id: true, + slug: true, + hidden: true, + owner: { + select: { + id: true, + name: true, + email: true, + username: true, + avatarUrl: true, + }, + }, + }, + orderBy: [{ id: "asc" }], + }); + + const hasMore = children.length > limit; + const items = hasMore ? children.slice(0, limit) : children; + const nextCursor = hasMore ? items[items.length - 1].id : undefined; + + return { items, nextCursor, hasMore }; + } + async findHostsWithConferencingCredentials(eventTypeId: number) { return await this.prismaClient.host.findMany({ where: { eventTypeId }, diff --git a/packages/features/host/services/EventTypeHostService.test.ts b/packages/features/host/services/EventTypeHostService.test.ts new file mode 100644 index 0000000000..a8ee5578bc --- /dev/null +++ b/packages/features/host/services/EventTypeHostService.test.ts @@ -0,0 +1,528 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { MembershipRole } from "@calcom/prisma/enums"; + +// Mock the repositories and external dependencies +vi.mock("@calcom/features/host/repositories/HostRepository"); +vi.mock("@calcom/features/membership/repositories/MembershipRepository"); +vi.mock("@calcom/features/eventtypes/repositories/eventTypeRepository"); +vi.mock("@calcom/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic"); + +import { EventTypeHostService } from "./EventTypeHostService"; +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; +import { findTeamMembersMatchingAttributeLogic } from "@calcom/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic"; + +const mockPrisma = {} as never; + +function createService() { + return new EventTypeHostService(mockPrisma); +} + +function getHostRepoInstance(): InstanceType { + return (HostRepository as unknown as { mock: { instances: InstanceType[] } }).mock + .instances[0]; +} + +function getMembershipRepoInstance(): InstanceType { + return ( + MembershipRepository as unknown as { + mock: { instances: InstanceType[] }; + } + ).mock.instances[0]; +} + +function getEventTypeRepoInstance(): InstanceType { + return ( + EventTypeRepository as unknown as { + mock: { instances: InstanceType[] }; + } + ).mock.instances[0]; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("EventTypeHostService", () => { + describe("getHostsForAvailability", () => { + it("should map repository items to AvailabilityHost DTOs", async () => { + const service = createService(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(hostRepo.findHostsPaginatedIncludeUser).mockResolvedValue({ + items: [ + { + userId: 1, + isFixed: true, + priority: 2, + weight: 150, + scheduleId: 10, + groupId: "g1", + user: { name: "Alice", avatarUrl: "https://example.com/alice.png", timeZone: "UTC" }, + }, + ], + nextCursor: undefined, + hasMore: false, + }); + + const result = await service.getHostsForAvailability({ + eventTypeId: 100, + limit: 20, + }); + + expect(hostRepo.findHostsPaginatedIncludeUser).toHaveBeenCalledWith({ + eventTypeId: 100, + cursor: undefined, + limit: 20, + search: undefined, + }); + expect(result.hosts).toHaveLength(1); + expect(result.hosts[0]).toEqual({ + userId: 1, + isFixed: true, + priority: 2, + weight: 150, + scheduleId: 10, + groupId: "g1", + name: "Alice", + avatarUrl: "https://example.com/alice.png", + }); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); + + it("should default priority to 0 and weight to 100 when null", async () => { + const service = createService(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(hostRepo.findHostsPaginatedIncludeUser).mockResolvedValue({ + items: [ + { + userId: 2, + isFixed: false, + priority: null, + weight: null, + scheduleId: null, + groupId: null, + user: { name: null, avatarUrl: null, timeZone: "UTC" }, + }, + ], + nextCursor: undefined, + hasMore: false, + }); + + const result = await service.getHostsForAvailability({ eventTypeId: 1, limit: 10 }); + + expect(result.hosts[0].priority).toBe(0); + expect(result.hosts[0].weight).toBe(100); + }); + + it("should pass pagination params through", async () => { + const service = createService(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(hostRepo.findHostsPaginatedIncludeUser).mockResolvedValue({ + items: [], + nextCursor: undefined, + hasMore: false, + }); + + await service.getHostsForAvailability({ + eventTypeId: 5, + cursor: 42, + limit: 10, + search: "test", + }); + + expect(hostRepo.findHostsPaginatedIncludeUser).toHaveBeenCalledWith({ + eventTypeId: 5, + cursor: 42, + limit: 10, + search: "test", + }); + }); + }); + + describe("getHostsForAssignment", () => { + it("should map repository items to AssignmentHost DTOs with email", async () => { + const service = createService(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(hostRepo.findHostsPaginatedIncludeUserForAssignment).mockResolvedValue({ + items: [ + { + userId: 1, + isFixed: false, + priority: 3, + weight: 200, + scheduleId: null, + groupId: null, + user: { name: "Bob", email: "bob@test.com", avatarUrl: null }, + }, + ], + nextCursor: undefined, + hasMore: false, + hasFixedHosts: false, + }); + + const result = await service.getHostsForAssignment({ + eventTypeId: 100, + limit: 20, + }); + + expect(result.hosts[0].email).toBe("bob@test.com"); + expect(result.hosts[0].name).toBe("Bob"); + expect(result.hasFixedHosts).toBe(false); + }); + + it("should include hasFixedHosts only when defined", async () => { + const service = createService(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(hostRepo.findHostsPaginatedIncludeUserForAssignment).mockResolvedValue({ + items: [], + nextCursor: undefined, + hasMore: false, + hasFixedHosts: undefined, + }); + + const result = await service.getHostsForAssignment({ + eventTypeId: 100, + limit: 20, + }); + + expect(result).not.toHaveProperty("hasFixedHosts"); + }); + + it("should pass memberUserIds to repository", async () => { + const service = createService(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(hostRepo.findHostsPaginatedIncludeUserForAssignment).mockResolvedValue({ + items: [], + nextCursor: undefined, + hasMore: false, + hasFixedHosts: undefined, + }); + + await service.getHostsForAssignment({ + eventTypeId: 1, + limit: 10, + memberUserIds: [1, 2, 3], + }); + + expect(hostRepo.findHostsPaginatedIncludeUserForAssignment).toHaveBeenCalledWith( + expect.objectContaining({ memberUserIds: [1, 2, 3] }) + ); + }); + }); + + describe("getChildrenForAssignment", () => { + it("should map and filter children with null owners", async () => { + const service = createService(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(hostRepo.findChildrenForAssignmentPaginated).mockResolvedValue({ + items: [ + { + id: 10, + slug: "child-1", + hidden: false, + owner: { id: 1, name: "Alice", email: "alice@test.com", username: "alice", avatarUrl: null }, + }, + { + id: 11, + slug: "child-2", + hidden: true, + owner: null, + }, + ], + nextCursor: undefined, + hasMore: false, + }); + + const result = await service.getChildrenForAssignment({ + eventTypeId: 100, + limit: 20, + }); + + // Should filter out item with null owner + expect(result.children).toHaveLength(1); + expect(result.children[0]).toEqual({ + childEventTypeId: 10, + slug: "child-1", + hidden: false, + owner: { + id: 1, + name: "Alice", + email: "alice@test.com", + username: "alice", + avatarUrl: null, + }, + }); + }); + }); + + describe("searchTeamMembers", () => { + it("should throw FORBIDDEN if user is not a member of the team", async () => { + const service = createService(); + const membershipRepo = getMembershipRepoInstance(); + + vi.mocked(membershipRepo.hasMembership).mockResolvedValue(false); + + await expect( + service.searchTeamMembers({ + teamId: 1, + userId: 99, + limit: 10, + }) + ).rejects.toThrow(ErrorWithCode); + + await expect( + service.searchTeamMembers({ + teamId: 1, + userId: 99, + limit: 10, + }) + ).rejects.toMatchObject({ code: "forbidden_error" }); + }); + + it("should return mapped team members when user is authorized", async () => { + const service = createService(); + const membershipRepo = getMembershipRepoInstance(); + + vi.mocked(membershipRepo.hasMembership).mockResolvedValue(true); + vi.mocked(membershipRepo.searchMembers).mockResolvedValue({ + memberships: [ + { + user: { + id: 5, + name: "Charlie", + email: "charlie@test.com", + avatarUrl: null, + username: "charlie", + defaultScheduleId: 1, + }, + role: MembershipRole.MEMBER, + }, + ], + nextCursor: undefined, + hasMore: false, + }); + + const result = await service.searchTeamMembers({ + teamId: 1, + userId: 10, + limit: 20, + }); + + expect(result.members).toHaveLength(1); + expect(result.members[0]).toEqual({ + userId: 5, + name: "Charlie", + email: "charlie@test.com", + avatarUrl: null, + username: "charlie", + defaultScheduleId: 1, + role: MembershipRole.MEMBER, + }); + }); + + it("should pass search and memberUserIds to repository", async () => { + const service = createService(); + const membershipRepo = getMembershipRepoInstance(); + + vi.mocked(membershipRepo.hasMembership).mockResolvedValue(true); + vi.mocked(membershipRepo.searchMembers).mockResolvedValue({ + memberships: [], + nextCursor: undefined, + hasMore: false, + }); + + await service.searchTeamMembers({ + teamId: 1, + userId: 10, + limit: 20, + search: "test", + memberUserIds: [1, 2], + }); + + expect(membershipRepo.searchMembers).toHaveBeenCalledWith({ + teamId: 1, + search: "test", + cursor: undefined, + limit: 20, + memberUserIds: [1, 2], + }); + }); + }); + + describe("exportHostsForWeights", () => { + it("should derive teamId from event type instead of trusting input", async () => { + const service = createService(); + const eventTypeRepo = getEventTypeRepoInstance(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(eventTypeRepo.getTeamIdByEventTypeId).mockResolvedValue({ teamId: 42 }); + vi.mocked(hostRepo.findAllRoundRobinHosts).mockResolvedValue([]); + + await service.exportHostsForWeights({ + eventTypeId: 100, + assignAllTeamMembers: false, + organizationId: null, + }); + + expect(eventTypeRepo.getTeamIdByEventTypeId).toHaveBeenCalledWith({ id: 100 }); + }); + + it("should fetch team members when assignAllTeamMembers is true", async () => { + const service = createService(); + const eventTypeRepo = getEventTypeRepoInstance(); + const membershipRepo = getMembershipRepoInstance(); + + vi.mocked(eventTypeRepo.getTeamIdByEventTypeId).mockResolvedValue({ teamId: 42 }); + vi.mocked(membershipRepo.findAcceptedMembersWithUserProfile).mockResolvedValue([ + { + user: { id: 1, name: "Alice", email: "alice@test.com", avatarUrl: null }, + }, + { + user: { id: 2, name: "Bob", email: "bob@test.com", avatarUrl: null }, + }, + ]); + + const result = await service.exportHostsForWeights({ + eventTypeId: 100, + assignAllTeamMembers: true, + organizationId: null, + }); + + expect(membershipRepo.findAcceptedMembersWithUserProfile).toHaveBeenCalledWith({ teamId: 42 }); + expect(result.members).toHaveLength(2); + expect(result.members[0].weight).toBeNull(); + expect(result.members[1].weight).toBeNull(); + }); + + it("should fetch round robin hosts when assignAllTeamMembers is false", async () => { + const service = createService(); + const eventTypeRepo = getEventTypeRepoInstance(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(eventTypeRepo.getTeamIdByEventTypeId).mockResolvedValue({ teamId: 42 }); + vi.mocked(hostRepo.findAllRoundRobinHosts).mockResolvedValue([ + { + userId: 1, + weight: 150, + user: { name: "Alice", email: "alice@test.com", avatarUrl: null }, + }, + ]); + + const result = await service.exportHostsForWeights({ + eventTypeId: 100, + assignAllTeamMembers: false, + organizationId: null, + }); + + expect(hostRepo.findAllRoundRobinHosts).toHaveBeenCalledWith({ eventTypeId: 100 }); + expect(result.members).toHaveLength(1); + expect(result.members[0].weight).toBe(150); + }); + + it("should default host weight to 100 when null", async () => { + const service = createService(); + const eventTypeRepo = getEventTypeRepoInstance(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(eventTypeRepo.getTeamIdByEventTypeId).mockResolvedValue({ teamId: 42 }); + vi.mocked(hostRepo.findAllRoundRobinHosts).mockResolvedValue([ + { + userId: 1, + weight: null, + user: { name: "Alice", email: "alice@test.com", avatarUrl: null }, + }, + ]); + + const result = await service.exportHostsForWeights({ + eventTypeId: 100, + assignAllTeamMembers: false, + organizationId: null, + }); + + expect(result.members[0].weight).toBe(100); + }); + + it("should filter by segment members when segment filtering is enabled", async () => { + const service = createService(); + const eventTypeRepo = getEventTypeRepoInstance(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(eventTypeRepo.getTeamIdByEventTypeId).mockResolvedValue({ teamId: 42 }); + vi.mocked(hostRepo.findAllRoundRobinHosts).mockResolvedValue([ + { userId: 1, weight: 100, user: { name: "Alice", email: "alice@test.com", avatarUrl: null } }, + { userId: 2, weight: 200, user: { name: "Bob", email: "bob@test.com", avatarUrl: null } }, + { userId: 3, weight: 50, user: { name: "Charlie", email: "charlie@test.com", avatarUrl: null } }, + ]); + vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({ + teamMembersMatchingAttributeLogic: [{ odataUserId: 1, odataEmail: "alice@test.com", userId: 1 }, { odataUserId: 3, odataEmail: "charlie@test.com", userId: 3 }], + mainAttributeLogicBuildingWarnings: [], + troubleshooter: null, + } as never); + + const result = await service.exportHostsForWeights({ + eventTypeId: 100, + assignAllTeamMembers: false, + assignRRMembersUsingSegment: true, + attributesQueryValue: { type: "group" } as never, + organizationId: 999, + }); + + expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalledWith( + { teamId: 42, attributesQueryValue: { type: "group" }, orgId: 999 }, + { enablePerf: false } + ); + // Only Alice (1) and Charlie (3) should remain + expect(result.members).toHaveLength(2); + expect(result.members.map((m) => m.userId)).toEqual([1, 3]); + }); + + it("should skip segment filtering when organizationId is null", async () => { + const service = createService(); + const eventTypeRepo = getEventTypeRepoInstance(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(eventTypeRepo.getTeamIdByEventTypeId).mockResolvedValue({ teamId: 42 }); + vi.mocked(hostRepo.findAllRoundRobinHosts).mockResolvedValue([ + { userId: 1, weight: 100, user: { name: "Alice", email: "alice@test.com", avatarUrl: null } }, + ]); + + const result = await service.exportHostsForWeights({ + eventTypeId: 100, + assignAllTeamMembers: false, + assignRRMembersUsingSegment: true, + attributesQueryValue: { type: "group" } as never, + organizationId: null, + }); + + expect(findTeamMembersMatchingAttributeLogic).not.toHaveBeenCalled(); + expect(result.members).toHaveLength(1); + }); + + it("should return empty members when event type has no teamId and assignAllTeamMembers is false", async () => { + const service = createService(); + const eventTypeRepo = getEventTypeRepoInstance(); + const hostRepo = getHostRepoInstance(); + + vi.mocked(eventTypeRepo.getTeamIdByEventTypeId).mockResolvedValue({ teamId: null }); + vi.mocked(hostRepo.findAllRoundRobinHosts).mockResolvedValue([]); + + const result = await service.exportHostsForWeights({ + eventTypeId: 100, + assignAllTeamMembers: false, + organizationId: null, + }); + + expect(result.members).toHaveLength(0); + }); + }); +}); diff --git a/packages/features/host/services/EventTypeHostService.ts b/packages/features/host/services/EventTypeHostService.ts new file mode 100644 index 0000000000..2fa64692da --- /dev/null +++ b/packages/features/host/services/EventTypeHostService.ts @@ -0,0 +1,274 @@ +import { findTeamMembersMatchingAttributeLogic } from "@calcom/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic"; +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import type { PrismaClient } from "@calcom/prisma/client"; +import { ErrorWithCode } from "@calcom/lib/errors"; + +import type { + AssignmentChild, + AssignmentHost, + AvailabilityHost, + ExportedWeightMember, + ExportWeightsResponse, + IEventTypeHostService, + PaginatedAssignmentChildrenResponse, + PaginatedAssignmentHostsResponse, + PaginatedAvailabilityHostsResponse, + PaginatedTeamMembersResponse, + TeamMemberSearchResult, +} from "./IEventTypeHostService"; + +export class EventTypeHostService implements IEventTypeHostService { + private hostRepository: HostRepository; + private membershipRepository: MembershipRepository; + private eventTypeRepository: EventTypeRepository; + + constructor(private prismaClient: PrismaClient) { + this.hostRepository = new HostRepository(prismaClient); + this.membershipRepository = new MembershipRepository(prismaClient); + this.eventTypeRepository = new EventTypeRepository(prismaClient); + } + + async getHostsForAvailability({ + eventTypeId, + cursor, + limit, + search, + }: { + eventTypeId: number; + cursor?: number; + limit: number; + search?: string; + }): Promise { + const { items, nextCursor, hasMore } = await this.hostRepository.findHostsPaginatedIncludeUser({ + eventTypeId, + cursor, + limit, + search, + }); + + const hosts: AvailabilityHost[] = items.map((item) => ({ + userId: item.userId, + isFixed: item.isFixed, + priority: item.priority ?? 0, + weight: item.weight ?? 100, + scheduleId: item.scheduleId, + groupId: item.groupId, + name: item.user.name, + avatarUrl: item.user.avatarUrl, + })); + + return { hosts, nextCursor, hasMore }; + } + + async getHostsForAssignment({ + eventTypeId, + cursor, + limit, + search, + memberUserIds, + }: { + eventTypeId: number; + cursor?: number; + limit: number; + search?: string; + memberUserIds?: number[]; + }): Promise { + const { items, nextCursor, hasMore, hasFixedHosts } = + await this.hostRepository.findHostsPaginatedIncludeUserForAssignment({ + eventTypeId, + cursor, + limit, + search, + memberUserIds, + }); + + const hosts: AssignmentHost[] = items.map((item) => ({ + userId: item.userId, + isFixed: item.isFixed, + priority: item.priority ?? 0, + weight: item.weight ?? 100, + scheduleId: item.scheduleId, + groupId: item.groupId, + name: item.user.name, + email: item.user.email, + avatarUrl: item.user.avatarUrl, + })); + + return { hosts, nextCursor, hasMore, ...(hasFixedHosts !== undefined && { hasFixedHosts }) }; + } + + async getChildrenForAssignment({ + eventTypeId, + cursor, + limit, + search, + }: { + eventTypeId: number; + cursor?: number; + limit: number; + search?: string; + }): Promise { + const { items, nextCursor, hasMore } = + await this.hostRepository.findChildrenForAssignmentPaginated({ + eventTypeId, + cursor, + limit, + search, + }); + + const children: AssignmentChild[] = items + .filter((item) => item.owner !== null) + .map((item) => ({ + childEventTypeId: item.id, + slug: item.slug, + hidden: item.hidden, + owner: { + id: item.owner!.id, + name: item.owner!.name, + email: item.owner!.email, + username: item.owner!.username, + avatarUrl: item.owner!.avatarUrl, + }, + })); + + return { children, nextCursor, hasMore }; + } + + async searchTeamMembers({ + teamId, + userId, + cursor, + limit, + search, + memberUserIds, + }: { + teamId: number; + userId: number; + cursor?: number | null; + limit: number; + search?: string | null; + memberUserIds?: number[] | null; + }): Promise { + const isMember = await this.membershipRepository.hasMembership({ teamId, userId }); + if (!isMember) { + throw ErrorWithCode.Factory.Forbidden("You are not a member of this team"); + } + + const { memberships, nextCursor, hasMore } = await this.membershipRepository.searchMembers({ + teamId, + search, + cursor, + limit, + memberUserIds, + }); + + const members: TeamMemberSearchResult[] = memberships.map((membership) => ({ + userId: membership.user.id, + name: membership.user.name, + email: membership.user.email, + avatarUrl: membership.user.avatarUrl, + username: membership.user.username, + defaultScheduleId: membership.user.defaultScheduleId, + role: membership.role, + })); + + return { members, nextCursor, hasMore }; + } + + async exportHostsForWeights({ + eventTypeId, + assignAllTeamMembers, + assignRRMembersUsingSegment, + attributesQueryValue, + organizationId, + }: { + eventTypeId: number; + assignAllTeamMembers: boolean; + assignRRMembersUsingSegment?: boolean; + attributesQueryValue?: AttributesQueryValue | null; + organizationId: number | null; + }): Promise { + // Derive teamId from the event type to prevent cross-team enumeration + const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({ id: eventTypeId }); + const teamId = eventType?.teamId; + + const segmentMemberIds = teamId + ? await this.getSegmentMemberIds({ + teamId, + organizationId, + assignRRMembersUsingSegment, + attributesQueryValue, + }) + : null; + + if (assignAllTeamMembers && teamId) { + const memberships = await this.membershipRepository.findAcceptedMembersWithUserProfile({ + teamId, + }); + + let members: ExportedWeightMember[] = memberships.map((m) => ({ + userId: m.user.id, + name: m.user.name, + email: m.user.email, + avatarUrl: m.user.avatarUrl, + weight: null, + })); + + if (segmentMemberIds) { + members = members.filter((m) => segmentMemberIds.has(m.userId)); + } + + return { members }; + } + + const hosts = await this.hostRepository.findAllRoundRobinHosts({ eventTypeId }); + + let members: ExportedWeightMember[] = hosts.map((h) => ({ + userId: h.userId, + name: h.user.name, + email: h.user.email, + avatarUrl: h.user.avatarUrl, + weight: h.weight ?? 100, + })); + + if (segmentMemberIds) { + members = members.filter((m) => segmentMemberIds.has(m.userId)); + } + + return { members }; + } + + private async getSegmentMemberIds({ + teamId, + organizationId, + assignRRMembersUsingSegment, + attributesQueryValue, + }: { + teamId: number; + organizationId: number | null; + assignRRMembersUsingSegment?: boolean; + attributesQueryValue?: AttributesQueryValue | null; + }): Promise | null> { + if (!assignRRMembersUsingSegment || !attributesQueryValue) { + return null; + } + + if (!organizationId) return null; + + const { teamMembersMatchingAttributeLogic } = await findTeamMembersMatchingAttributeLogic( + { + teamId, + attributesQueryValue, + orgId: organizationId, + }, + { enablePerf: false } + ); + + if (!teamMembersMatchingAttributeLogic) return null; + + return new Set(teamMembersMatchingAttributeLogic.map((m) => m.userId)); + } +} diff --git a/packages/features/host/services/IEventTypeHostService.ts b/packages/features/host/services/IEventTypeHostService.ts new file mode 100644 index 0000000000..8d4300c9fb --- /dev/null +++ b/packages/features/host/services/IEventTypeHostService.ts @@ -0,0 +1,132 @@ +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import type { MembershipRole } from "@calcom/prisma/enums"; + +// --- Response DTOs --- + +export type AvailabilityHost = { + userId: number; + isFixed: boolean; + priority: number; + weight: number; + scheduleId: number | null; + groupId: string | null; + name: string | null; + avatarUrl: string | null; +}; + +export type AssignmentHost = { + userId: number; + isFixed: boolean; + priority: number; + weight: number; + scheduleId: number | null; + groupId: string | null; + name: string | null; + email: string; + avatarUrl: string | null; +}; + +export type AssignmentChild = { + childEventTypeId: number; + slug: string; + hidden: boolean; + owner: { + id: number; + name: string | null; + email: string; + username: string | null; + avatarUrl: string | null; + }; +}; + +export type TeamMemberSearchResult = { + userId: number; + name: string | null; + email: string; + avatarUrl: string | null; + username: string | null; + defaultScheduleId: number | null; + role: MembershipRole; +}; + +export type ExportedWeightMember = { + userId: number; + name: string | null; + email: string; + avatarUrl: string | null; + weight: number | null; +}; + +// --- Paginated response wrappers --- + +export type PaginatedAvailabilityHostsResponse = { + hosts: AvailabilityHost[]; + nextCursor: number | undefined; + hasMore: boolean; +}; + +export type PaginatedAssignmentHostsResponse = { + hosts: AssignmentHost[]; + nextCursor: number | undefined; + hasMore: boolean; + hasFixedHosts?: boolean; +}; + +export type PaginatedAssignmentChildrenResponse = { + children: AssignmentChild[]; + nextCursor: number | undefined; + hasMore: boolean; +}; + +export type PaginatedTeamMembersResponse = { + members: TeamMemberSearchResult[]; + nextCursor: number | undefined; + hasMore: boolean; +}; + +export type ExportWeightsResponse = { + members: ExportedWeightMember[]; +}; + +// --- Service Interface --- + +export interface IEventTypeHostService { + getHostsForAvailability(input: { + eventTypeId: number; + cursor?: number; + limit: number; + search?: string; + }): Promise; + + getHostsForAssignment(input: { + eventTypeId: number; + cursor?: number; + limit: number; + search?: string; + memberUserIds?: number[]; + }): Promise; + + getChildrenForAssignment(input: { + eventTypeId: number; + cursor?: number; + limit: number; + search?: string; + }): Promise; + + searchTeamMembers(input: { + teamId: number; + userId: number; + cursor?: number | null; + limit: number; + search?: string | null; + memberUserIds?: number[] | null; + }): Promise; + + exportHostsForWeights(input: { + eventTypeId: number; + assignAllTeamMembers: boolean; + assignRRMembersUsingSegment?: boolean; + attributesQueryValue?: AttributesQueryValue | null; + organizationId: number | null; + }): Promise; +} diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index e82b61df7e..e8eac55896 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -342,6 +342,48 @@ export class MembershipRepository { }); } + async findRoleByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) { + return await this.prismaClient.membership.findUnique({ + where: { + userId_teamId: { + userId, + teamId, + }, + }, + select: { + role: true, + }, + }); + } + + async findMembershipsWithUserByTeamId({ teamId }: { teamId: number }) { + return this.prismaClient.membership.findMany({ + where: { teamId }, + select: { + role: true, + accepted: true, + user: { + select: { + name: true, + avatarUrl: true, + username: true, + id: true, + email: true, + locale: true, + defaultScheduleId: true, + isPlatformManaged: true, + timeZone: true, + eventTypes: { + select: { + slug: true, + }, + }, + }, + }, + }, + }); + } + async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) { return this.prismaClient.membership.findMany({ where: { userId }, @@ -596,6 +638,91 @@ export class MembershipRepository { return !!pendingInvite; } + async searchMembers({ + teamId, + search, + cursor, + limit, + memberUserIds, + }: { + teamId: number; + search?: string | null; + cursor?: number | null; + limit: number; + memberUserIds?: number[] | null; + }) { + const where: Record = { + teamId, + accepted: true, + }; + + const userFilter: Record = {}; + + if (search) { + userFilter.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ]; + } + + if (memberUserIds !== undefined && memberUserIds !== null) { + userFilter.id = cursor + ? { in: memberUserIds, gt: cursor } + : { in: memberUserIds }; + } else if (cursor) { + userFilter.id = { gt: cursor }; + } + + if (Object.keys(userFilter).length > 0) { + where.user = userFilter; + } + + const memberships = await this.prismaClient.membership.findMany({ + where, + take: limit + 1, + orderBy: { user: { id: "asc" } }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + username: true, + defaultScheduleId: true, + }, + }, + role: true, + }, + }); + + const hasMore = memberships.length > limit; + const items = hasMore ? memberships.slice(0, limit) : memberships; + const nextCursor = hasMore ? items[items.length - 1].user.id : undefined; + + return { memberships: items, nextCursor, hasMore }; + } + + async findAcceptedMembersWithUserProfile({ teamId }: { teamId: number }) { + return this.prismaClient.membership.findMany({ + where: { + teamId, + accepted: true, + }, + orderBy: { user: { id: "asc" } }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); + } + /** * Checks if a user has any team membership (pending or accepted). * Used during onboarding to detect users who signed up via invite token, diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 057169030f..b69b99d550 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1,11 +1,11 @@ import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; import { getParsedTeam } from "@calcom/features/ee/teams/lib/getParsedTeam"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; +import { getTranslation } from "@calcom/i18n/server"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { getTranslation } from "@calcom/i18n/server"; import { withSelectedCalendars } from "@calcom/lib/server/withSelectedCalendars"; import type { PrismaClient } from "@calcom/prisma"; import { availabilityUserSelect } from "@calcom/prisma"; @@ -427,6 +427,65 @@ export class UserRepository { }); } + async findByIdsWithPagination({ + ids, + search, + cursor, + limit, + }: { + ids: number[]; + search?: string | null; + cursor?: number | null; + limit?: number | null; + }) { + const where: Record = { + id: cursor ? { in: ids, gt: cursor } : { in: ids }, + }; + + if (search) { + where.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ]; + } + + const users = await this.prismaClient.user.findMany({ + where, + select: { + id: true, + name: true, + email: true, + }, + orderBy: { id: "asc" }, + ...(limit ? { take: limit + 1 } : {}), + }); + + if (!limit) { + return { users, nextCursor: undefined, total: users.length }; + } + + const hasMore = users.length > limit; + const items = hasMore ? users.slice(0, limit) : users; + const nextCursor = hasMore ? items[items.length - 1].id : undefined; + + // Only count on the first page to avoid an extra query on every scroll + let total: number | undefined; + if (!cursor) { + const countWhere: Record = { + id: { in: ids }, + }; + if (search) { + countWhere.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ]; + } + total = await this.prismaClient.user.count({ where: countWhere }); + } + + return { users: items, nextCursor, total }; + } + async findByUuids({ uuids }: { uuids: string[] }) { if (uuids.length === 0) return []; return this.prismaClient.user.findMany({ diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index cb8d664f0a..74682e69e9 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -10,9 +10,14 @@ import { ZGetActiveOnOptionsSchema } from "./getActiveOnOptions.schema"; import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByViewer.schema"; import { ZGetHashedLinkInputSchema } from "./getHashedLink.schema"; import { ZGetHashedLinksInputSchema } from "./getHashedLinks.schema"; +import { ZGetChildrenForAssignmentInputSchema } from "./getChildrenForAssignment.schema"; +import { ZExportHostsForWeightsInputSchema } from "./exportHostsForWeights.schema"; +import { ZGetHostsForAssignmentInputSchema } from "./getHostsForAssignment.schema"; +import { ZGetHostsForAvailabilityInputSchema } from "./getHostsForAvailability.schema"; import { ZGetHostsWithLocationOptionsInputSchema } from "./getHostsWithLocationOptions.schema"; import { ZMassApplyHostLocationInputSchema } from "./massApplyHostLocation.schema"; import { get } from "./procedures/get"; +import { ZSearchTeamMembersInputSchema } from "./searchTeamMembers.schema"; import { createEventPbacProcedure } from "./util"; export const eventTypesRouter = router({ @@ -148,6 +153,62 @@ export const eventTypesRouter = router({ }); }), + getHostsForAvailability: createEventPbacProcedure("eventType.update", [ + MembershipRole.ADMIN, + MembershipRole.OWNER, + ]) + .input(ZGetHostsForAvailabilityInputSchema) + .query(async ({ ctx, input }) => { + const { getHostsForAvailabilityHandler } = await import("./getHostsForAvailability.handler"); + + return getHostsForAvailabilityHandler({ + ctx, + input, + }); + }), + + getHostsForAssignment: createEventPbacProcedure("eventType.update", [ + MembershipRole.ADMIN, + MembershipRole.OWNER, + ]) + .input(ZGetHostsForAssignmentInputSchema) + .query(async ({ ctx, input }) => { + const { getHostsForAssignmentHandler } = await import("./getHostsForAssignment.handler"); + + return getHostsForAssignmentHandler({ + ctx, + input, + }); + }), + + exportHostsForWeights: createEventPbacProcedure("eventType.update", [ + MembershipRole.ADMIN, + MembershipRole.OWNER, + ]) + .input(ZExportHostsForWeightsInputSchema) + .query(async ({ ctx, input }) => { + const { exportHostsForWeightsHandler } = await import("./exportHostsForWeights.handler"); + + return exportHostsForWeightsHandler({ + ctx, + input, + }); + }), + + getChildrenForAssignment: createEventPbacProcedure("eventType.update", [ + MembershipRole.ADMIN, + MembershipRole.OWNER, + ]) + .input(ZGetChildrenForAssignmentInputSchema) + .query(async ({ ctx, input }) => { + const { getChildrenForAssignmentHandler } = await import("./getChildrenForAssignment.handler"); + + return getChildrenForAssignmentHandler({ + ctx, + input, + }); + }), + getHostsWithLocationOptions: createEventPbacProcedure("eventType.update", [ MembershipRole.ADMIN, MembershipRole.OWNER, @@ -175,4 +236,15 @@ export const eventTypesRouter = router({ input, }); }), + + searchTeamMembers: authedProcedure + .input(ZSearchTeamMembersInputSchema) + .query(async ({ ctx, input }) => { + const { searchTeamMembersHandler } = await import("./searchTeamMembers.handler"); + + return searchTeamMembersHandler({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.handler.ts new file mode 100644 index 0000000000..9afb0797ec --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.handler.ts @@ -0,0 +1,31 @@ +import { EventTypeHostService } from "@calcom/features/host/services/EventTypeHostService"; +import type { ExportWeightsResponse } from "@calcom/features/host/services/IEventTypeHostService"; +import type { PrismaClient } from "@calcom/prisma/client"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TExportHostsForWeightsInputSchema } from "./exportHostsForWeights.schema"; + +type ExportHostsForWeightsInput = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TExportHostsForWeightsInputSchema; +}; + +export type { ExportWeightsResponse as ExportHostsForWeightsResponse }; +export type { ExportedWeightMember } from "@calcom/features/host/services/IEventTypeHostService"; + +export const exportHostsForWeightsHandler = async ({ + ctx, + input, +}: ExportHostsForWeightsInput): Promise => { + const service = new EventTypeHostService(ctx.prisma); + return service.exportHostsForWeights({ + eventTypeId: input.eventTypeId, + assignAllTeamMembers: input.assignAllTeamMembers, + assignRRMembersUsingSegment: input.assignRRMembersUsingSegment, + attributesQueryValue: input.attributesQueryValue, + organizationId: ctx.user.organizationId, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.schema.ts new file mode 100644 index 0000000000..9b5d2518d3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { zodAttributesQueryValue } from "@calcom/lib/raqb/zod"; + +export const ZExportHostsForWeightsInputSchema = z.object({ + eventTypeId: z.number(), + assignAllTeamMembers: z.boolean(), + assignRRMembersUsingSegment: z.boolean().optional(), + attributesQueryValue: zodAttributesQueryValue.nullable().optional(), +}); + +export type TExportHostsForWeightsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.handler.ts new file mode 100644 index 0000000000..f3dadb5043 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.handler.ts @@ -0,0 +1,30 @@ +import { EventTypeHostService } from "@calcom/features/host/services/EventTypeHostService"; +import type { PaginatedAssignmentChildrenResponse } from "@calcom/features/host/services/IEventTypeHostService"; +import type { PrismaClient } from "@calcom/prisma/client"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TGetChildrenForAssignmentInputSchema } from "./getChildrenForAssignment.schema"; + +type GetChildrenForAssignmentInput = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetChildrenForAssignmentInputSchema; +}; + +export type { PaginatedAssignmentChildrenResponse as GetChildrenForAssignmentResponse }; +export type { AssignmentChild } from "@calcom/features/host/services/IEventTypeHostService"; + +export const getChildrenForAssignmentHandler = async ({ + ctx, + input, +}: GetChildrenForAssignmentInput): Promise => { + const service = new EventTypeHostService(ctx.prisma); + return service.getChildrenForAssignment({ + eventTypeId: input.eventTypeId, + cursor: input.cursor ?? undefined, + limit: input.limit, + search: input.search, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.schema.ts new file mode 100644 index 0000000000..282a049221 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZGetChildrenForAssignmentInputSchema = z.object({ + eventTypeId: z.number(), + cursor: z.number().nullish(), + limit: z.number().min(1).max(100).default(20), + search: z.string().optional(), +}); + +export type TGetChildrenForAssignmentInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.handler.ts new file mode 100644 index 0000000000..0881134f57 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.handler.ts @@ -0,0 +1,31 @@ +import { EventTypeHostService } from "@calcom/features/host/services/EventTypeHostService"; +import type { PaginatedAssignmentHostsResponse } from "@calcom/features/host/services/IEventTypeHostService"; +import type { PrismaClient } from "@calcom/prisma/client"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TGetHostsForAssignmentInputSchema } from "./getHostsForAssignment.schema"; + +type GetHostsForAssignmentInput = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetHostsForAssignmentInputSchema; +}; + +export type { PaginatedAssignmentHostsResponse as GetHostsForAssignmentResponse }; +export type { AssignmentHost } from "@calcom/features/host/services/IEventTypeHostService"; + +export const getHostsForAssignmentHandler = async ({ + ctx, + input, +}: GetHostsForAssignmentInput): Promise => { + const service = new EventTypeHostService(ctx.prisma); + return service.getHostsForAssignment({ + eventTypeId: input.eventTypeId, + cursor: input.cursor ?? undefined, + limit: input.limit, + search: input.search, + memberUserIds: input.memberUserIds, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.schema.ts new file mode 100644 index 0000000000..e83fd8db2e --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const ZGetHostsForAssignmentInputSchema = z.object({ + eventTypeId: z.number(), + cursor: z.number().nullish(), + limit: z.number().min(1).max(100).default(20), + search: z.string().optional(), + memberUserIds: z.array(z.number()).max(1000).optional(), +}); + +export type TGetHostsForAssignmentInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.handler.ts new file mode 100644 index 0000000000..d818f4ac92 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.handler.ts @@ -0,0 +1,30 @@ +import { EventTypeHostService } from "@calcom/features/host/services/EventTypeHostService"; +import type { PaginatedAvailabilityHostsResponse } from "@calcom/features/host/services/IEventTypeHostService"; +import type { PrismaClient } from "@calcom/prisma/client"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TGetHostsForAvailabilityInputSchema } from "./getHostsForAvailability.schema"; + +type GetHostsForAvailabilityInput = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetHostsForAvailabilityInputSchema; +}; + +export type { PaginatedAvailabilityHostsResponse as GetHostsForAvailabilityResponse }; +export type { AvailabilityHost } from "@calcom/features/host/services/IEventTypeHostService"; + +export const getHostsForAvailabilityHandler = async ({ + ctx, + input, +}: GetHostsForAvailabilityInput): Promise => { + const service = new EventTypeHostService(ctx.prisma); + return service.getHostsForAvailability({ + eventTypeId: input.eventTypeId, + cursor: input.cursor ?? undefined, + limit: input.limit, + search: input.search, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.integration-test.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.integration-test.ts new file mode 100644 index 0000000000..0dc8b4248a --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.integration-test.ts @@ -0,0 +1,264 @@ +import prisma from "@calcom/prisma"; +import type { EventType, Team, User } from "@calcom/prisma/client"; +import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { getHostsForAvailabilityHandler } from "./getHostsForAvailability.handler"; +import { getHostsForAssignmentHandler } from "./getHostsForAssignment.handler"; + +let user1: User; +let user2: User; +let user3: User; +let team: Team; +let eventType: EventType; + +describe("getHostsForAvailability and getHostsForAssignment", () => { + beforeAll(async () => { + const timestamp = Date.now(); + + user1 = await prisma.user.create({ + data: { + username: `host-test-1-${timestamp}`, + email: `host-test-1-${timestamp}@example.com`, + name: "Host User 1", + }, + }); + user2 = await prisma.user.create({ + data: { + username: `host-test-2-${timestamp}`, + email: `host-test-2-${timestamp}@example.com`, + name: "Host User 2", + }, + }); + user3 = await prisma.user.create({ + data: { + username: `host-test-3-${timestamp}`, + email: `host-test-3-${timestamp}@example.com`, + name: "Host User 3", + }, + }); + + team = await prisma.team.create({ + data: { + name: `Host Test Team ${timestamp}`, + slug: `host-test-team-${timestamp}`, + members: { + createMany: { + data: [ + { userId: user1.id, role: MembershipRole.ADMIN, accepted: true }, + { userId: user2.id, role: MembershipRole.MEMBER, accepted: true }, + { userId: user3.id, role: MembershipRole.MEMBER, accepted: true }, + ], + }, + }, + }, + }); + + eventType = await prisma.eventType.create({ + data: { + title: "Host Test Event", + slug: `host-test-event-${timestamp}`, + length: 30, + teamId: team.id, + schedulingType: SchedulingType.ROUND_ROBIN, + hosts: { + createMany: { + data: [ + { userId: user1.id, isFixed: true, priority: 1, weight: 100 }, + { userId: user2.id, isFixed: false, priority: 2, weight: 200 }, + { userId: user3.id, isFixed: false, priority: 3, weight: 50 }, + ], + }, + }, + }, + }); + }); + + afterAll(async () => { + try { + if (eventType?.id) { + await prisma.host.deleteMany({ where: { eventTypeId: eventType.id } }); + await prisma.eventType.delete({ where: { id: eventType.id } }); + } + if (team?.id) { + await prisma.team.delete({ where: { id: team.id } }); + } + const userIds = [user1?.id, user2?.id, user3?.id].filter(Boolean); + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + } + } catch (error) { + console.warn("Test cleanup failed:", error); + } + }); + + describe("getHostsForAvailability", () => { + it("should return all hosts for an event type", async () => { + const result = await getHostsForAvailabilityHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 10 }, + }); + + expect(result.hosts).toHaveLength(3); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + + const userIds = result.hosts.map((h) => h.userId); + expect(userIds).toContain(user1.id); + expect(userIds).toContain(user2.id); + expect(userIds).toContain(user3.id); + }); + + it("should paginate hosts correctly", async () => { + const page1 = await getHostsForAvailabilityHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 2 }, + }); + + expect(page1.hosts).toHaveLength(2); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBeDefined(); + + const page2 = await getHostsForAvailabilityHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: page1.nextCursor ?? null, limit: 2 }, + }); + + expect(page2.hosts).toHaveLength(1); + expect(page2.hasMore).toBe(false); + expect(page2.nextCursor).toBeUndefined(); + + // No duplicate hosts across pages + const allUserIds = [...page1.hosts.map((h) => h.userId), ...page2.hosts.map((h) => h.userId)]; + expect(new Set(allUserIds).size).toBe(3); + }); + + it("should return host data with user fields", async () => { + const result = await getHostsForAvailabilityHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 10 }, + }); + + const host1 = result.hosts.find((h) => h.userId === user1.id); + expect(host1).toBeDefined(); + expect(host1?.isFixed).toBe(true); + expect(host1?.priority).toBe(1); + expect(host1?.weight).toBe(100); + expect(host1?.name).toBe("Host User 1"); + }); + + it("should filter hosts by search term", async () => { + const result = await getHostsForAvailabilityHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 10, search: "Host User 1" }, + }); + + expect(result.hosts).toHaveLength(1); + expect(result.hosts[0].userId).toBe(user1.id); + }); + }); + + describe("getHostsForAssignment", () => { + it("should return all hosts for an event type", async () => { + const result = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 10 }, + }); + + expect(result.hosts).toHaveLength(3); + expect(result.hasMore).toBe(false); + + const userIds = result.hosts.map((h) => h.userId); + expect(userIds).toContain(user1.id); + expect(userIds).toContain(user2.id); + expect(userIds).toContain(user3.id); + }); + + it("should paginate hosts correctly", async () => { + const page1 = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 1 }, + }); + + expect(page1.hosts).toHaveLength(1); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBeDefined(); + + // hasFixedHosts should be present on first page + expect(page1.hasFixedHosts).toBe(true); + + const page2 = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: page1.nextCursor ?? null, limit: 1 }, + }); + + expect(page2.hosts).toHaveLength(1); + expect(page2.hasMore).toBe(true); + + // hasFixedHosts should NOT be present on subsequent pages + expect(page2.hasFixedHosts).toBeUndefined(); + + const page3 = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: page2.nextCursor ?? null, limit: 1 }, + }); + + expect(page3.hosts).toHaveLength(1); + expect(page3.hasMore).toBe(false); + }); + + it("should include email in assignment host data", async () => { + const result = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 10 }, + }); + + const host1 = result.hosts.find((h) => h.userId === user1.id); + expect(host1).toBeDefined(); + expect(host1?.email).toContain("host-test-1"); + expect(host1?.name).toBe("Host User 1"); + }); + + it("should filter by memberUserIds when provided", async () => { + const result = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { + eventTypeId: eventType.id, + cursor: null, + limit: 10, + memberUserIds: [user1.id, user2.id], + }, + }); + + expect(result.hosts).toHaveLength(2); + const userIds = result.hosts.map((h) => h.userId); + expect(userIds).toContain(user1.id); + expect(userIds).toContain(user2.id); + expect(userIds).not.toContain(user3.id); + }); + + it("should return zero results for empty memberUserIds array", async () => { + const result = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { + eventTypeId: eventType.id, + cursor: null, + limit: 10, + memberUserIds: [], + }, + }); + + expect(result.hosts).toHaveLength(0); + }); + + it("should filter hosts by search term", async () => { + const result = await getHostsForAssignmentHandler({ + ctx: { user: { id: user1.id } as never, prisma }, + input: { eventTypeId: eventType.id, cursor: null, limit: 10, search: "Host User 2" }, + }); + + expect(result.hosts).toHaveLength(1); + expect(result.hosts[0].userId).toBe(user2.id); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.schema.ts new file mode 100644 index 0000000000..2d42662bea --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZGetHostsForAvailabilityInputSchema = z.object({ + eventTypeId: z.number(), + cursor: z.number().nullish(), + limit: z.number().min(1).max(100).default(20), + search: z.string().optional(), +}); + +export type TGetHostsForAvailabilityInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index 64578cd095..138c8ca40e 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -117,7 +117,7 @@ export const getHostsWithLocationOptionsHandler = async ({ hasMore, } = await hostRepository.findHostsWithLocationOptionsPaginated({ eventTypeId, - cursor, + cursor: cursor ?? undefined, limit, }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts index b85e7d3da1..0e0868aa13 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const ZGetHostsWithLocationOptionsInputSchema = z.object({ eventTypeId: z.number(), - cursor: z.number().optional(), + cursor: z.number().nullish(), limit: z.number().min(1).max(100).default(10), }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 0d05f9edbb..10f4ce1f61 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -1,5 +1,3 @@ -import type { NextApiResponse, GetServerSidePropsContext } from "next"; - import type { appDataSchemas } from "@calcom/app-store/apps.schemas.generated"; import { DailyLocationType } from "@calcom/app-store/constants"; import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils"; @@ -16,27 +14,26 @@ import { MembershipRepository } from "@calcom/features/membership/repositories/M import { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; import tasker from "@calcom/features/tasker"; import { submitUrlForUrlScanning } from "@calcom/features/tasker/tasks/scanWorkflowUrls"; +import { getTranslation } from "@calcom/i18n/server"; import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder"; import logger from "@calcom/lib/logger"; -import { getTranslation } from "@calcom/i18n/server"; import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import { - WorkflowTriggerEvents, - SchedulingType, EventTypeAutoTranslatedField, RRTimestampBasis, + SchedulingType, + WorkflowTriggerEvents, } from "@calcom/prisma/enums"; import { eventTypeLocations } from "@calcom/prisma/zod-utils"; - import { TRPCError } from "@trpc/server"; - +import type { GetServerSidePropsContext, NextApiResponse } from "next"; import type { TrpcSessionUser } from "../../../../types"; import { setDestinationCalendarHandler } from "../../../viewer/calendars/setDestinationCalendar.handler"; import { - ensureUniqueBookingFields, ensureEmailOrPhoneNumberIsPresent, + ensureUniqueBookingFields, handleCustomInputs, handlePeriodType, } from "../util"; @@ -480,10 +477,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { if (teamId && hosts) { // check if all hosts can be assigned (memberships that have accepted invite) const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId }); - // guard against missing IDs, this may mean a member has just been removed - // or this request was forged. - // we let this pass through on organization sub-teams - if (!hosts.every((host) => teamMemberIds.includes(host.userId)) && !eventType.team?.parentId) { + const teamMemberIdSet = new Set(teamMemberIds); + if (!hosts.every((host) => teamMemberIdSet.has(host.userId)) && !eventType.team?.parentId) { throw new TRPCError({ code: "FORBIDDEN", }); @@ -814,7 +809,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { // - If calVideoSettings provided in input, sync to children // - If Cal Video location removed, delete from children (pass null) // - Otherwise, leave children's settings untouched (pass undefined) - let calVideoSettingsForChildren: typeof calVideoSettings | null | undefined = undefined; + let calVideoSettingsForChildren: typeof calVideoSettings | null | undefined; if (calVideoSettings !== undefined) { calVideoSettingsForChildren = calVideoSettings; } else if (eventType.calVideoSettings && !isCalVideoLocationActive) { diff --git a/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.handler.ts new file mode 100644 index 0000000000..f2f6748300 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.handler.ts @@ -0,0 +1,32 @@ +import { EventTypeHostService } from "@calcom/features/host/services/EventTypeHostService"; +import type { PaginatedTeamMembersResponse } from "@calcom/features/host/services/IEventTypeHostService"; +import type { PrismaClient } from "@calcom/prisma/client"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TSearchTeamMembersInputSchema } from "./searchTeamMembers.schema"; + +type SearchTeamMembersInput = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TSearchTeamMembersInputSchema; +}; + +export type { PaginatedTeamMembersResponse as SearchTeamMembersResponse }; +export type { TeamMemberSearchResult } from "@calcom/features/host/services/IEventTypeHostService"; + +export const searchTeamMembersHandler = async ({ + ctx, + input, +}: SearchTeamMembersInput): Promise => { + const service = new EventTypeHostService(ctx.prisma); + return service.searchTeamMembers({ + teamId: input.teamId, + userId: ctx.user.id, + cursor: input.cursor, + limit: input.limit, + search: input.search, + memberUserIds: input.memberUserIds, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.schema.ts new file mode 100644 index 0000000000..4f578c5477 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const ZSearchTeamMembersInputSchema = z.object({ + teamId: z.number(), + cursor: z.number().nullish(), + limit: z.number().min(1).max(100).default(20), + search: z.string().optional(), + memberUserIds: z.array(z.number()).max(1000).optional(), +}); + +export type TSearchTeamMembersInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index e29fc9672b..57e4bc2097 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -21,6 +21,7 @@ import { rrSegmentQueryValueSchema, } from "@calcom/prisma/zod-utils"; import { z } from "zod"; + export type TUpdateInputSchema = EventTypeUpdateInput; // ============================================================================