diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 4dd9c404c68d30..c3174fbe460712 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 53831fe9cae7bc..59f47d3a59299e 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 findChildrenByParentId(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 4369ab0be70dd9..5fdcb1405914af 100644 --- a/packages/features/host/repositories/HostRepository.ts +++ b/packages/features/host/repositories/HostRepository.ts @@ -142,6 +142,188 @@ export class HostRepository { return { items, nextCursor, hasMore }; } + async findHostsForAvailabilityPaginated({ + 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, -1) : hosts; + const nextCursor = hasMore ? items[items.length - 1].userId : undefined; + + return { items, nextCursor, hasMore }; + } + + async findHostsForAssignmentPaginated({ + eventTypeId, + cursor, + limit = 20, + search, + memberUserIds, + }: { + eventTypeId: number; + cursor?: number; + limit?: number; + search?: string; + memberUserIds?: number[]; + }) { + const userIdFilter = memberUserIds?.length + ? 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, -1) : 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, -1) : 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/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index dfcc7dac983261..d0c9e434d05035 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -340,6 +340,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 }, @@ -594,6 +636,71 @@ 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?.length && cursor) { + userFilter.id = { in: memberUserIds, gt: cursor }; + } else if (memberUserIds?.length) { + userFilter.id = { 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 = items.length > 0 ? items[items.length - 1].user.id : undefined; + + return { memberships: items, nextCursor, hasMore }; + } + /** * 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 057169030fff6c..556c1e9703edac 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -427,6 +427,52 @@ 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; + + const total = await this.prismaClient.user.count({ where }); + + 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 cb8d664f0a32b5..74682e69e926ae 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 00000000000000..f7c810fd1ccb95 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.handler.ts @@ -0,0 +1,111 @@ +import { findTeamMembersMatchingAttributeLogic } from "@calcom/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic"; +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; +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 ExportedWeightMember = { + userId: number; + name: string | null; + email: string; + avatarUrl: string | null; + weight: number | null; +}; + +export type ExportHostsForWeightsResponse = { + members: ExportedWeightMember[]; +}; + +async function getSegmentMemberIds( + ctx: ExportHostsForWeightsInput["ctx"], + input: ExportHostsForWeightsInput["input"] +): Promise | null> { + if (!input.assignRRMembersUsingSegment || !input.attributesQueryValue || !input.teamId) { + return null; + } + + const orgId = ctx.user.organizationId; + if (!orgId) return null; + + const { teamMembersMatchingAttributeLogic } = await findTeamMembersMatchingAttributeLogic( + { + teamId: input.teamId, + attributesQueryValue: input.attributesQueryValue, + orgId, + }, + { enablePerf: false } + ); + + if (!teamMembersMatchingAttributeLogic) return null; + + return new Set(teamMembersMatchingAttributeLogic.map((m) => m.userId)); +} + +export const exportHostsForWeightsHandler = async ({ + ctx, + input, +}: ExportHostsForWeightsInput): Promise => { + const segmentMemberIds = await getSegmentMemberIds(ctx, input); + + if (input.assignAllTeamMembers && input.teamId) { + // Fetch all accepted team members + const memberships = await ctx.prisma.membership.findMany({ + where: { + teamId: input.teamId, + accepted: true, + }, + orderBy: { user: { id: "asc" } }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); + + 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 }; + } + + // Fetch all non-fixed hosts for this event type + const hostRepository = new HostRepository(ctx.prisma); + const hosts = await hostRepository.findAllRoundRobinHosts({ eventTypeId: input.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 }; +}; 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 00000000000000..5726029410f707 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/exportHostsForWeights.schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { zodAttributesQueryValue } from "@calcom/lib/raqb/zod"; + +export const ZExportHostsForWeightsInputSchema = z.object({ + eventTypeId: z.number(), + teamId: z.number().optional(), + 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 00000000000000..bd745a4ce7f16c --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getChildrenForAssignment.handler.ts @@ -0,0 +1,64 @@ +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; +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 AssignmentChild = { + childEventTypeId: number; + slug: string; + hidden: boolean; + owner: { + id: number; + name: string | null; + email: string; + username: string | null; + avatarUrl: string | null; + }; +}; + +export type GetChildrenForAssignmentResponse = { + children: AssignmentChild[]; + nextCursor: number | undefined; + hasMore: boolean; +}; + +export const getChildrenForAssignmentHandler = async ({ + ctx, + input, +}: GetChildrenForAssignmentInput): Promise => { + const { eventTypeId, cursor, limit, search } = input; + + const hostRepository = new HostRepository(ctx.prisma); + const { items, nextCursor, hasMore } = await hostRepository.findChildrenForAssignmentPaginated({ + eventTypeId, + cursor: cursor ?? undefined, + 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 }; +}; 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 00000000000000..282a049221ad9c --- /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 00000000000000..670bcbc8db66fd --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAssignment.handler.ts @@ -0,0 +1,63 @@ +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; +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 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 GetHostsForAssignmentResponse = { + hosts: AssignmentHost[]; + nextCursor: number | undefined; + hasMore: boolean; + hasFixedHosts?: boolean; +}; + +export const getHostsForAssignmentHandler = async ({ + ctx, + input, +}: GetHostsForAssignmentInput): Promise => { + const { eventTypeId, cursor, limit, search, memberUserIds } = input; + + const hostRepository = new HostRepository(ctx.prisma); + const { items, nextCursor, hasMore, hasFixedHosts } = + await hostRepository.findHostsForAssignmentPaginated({ + eventTypeId, + cursor: cursor ?? undefined, + 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 }) }; +}; 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 00000000000000..e83fd8db2eb4a8 --- /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 00000000000000..d1ebc8101a772f --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsForAvailability.handler.ts @@ -0,0 +1,58 @@ +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; +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 AvailabilityHost = { + userId: number; + isFixed: boolean; + priority: number; + weight: number; + scheduleId: number | null; + groupId: string | null; + name: string | null; + avatarUrl: string | null; +}; + +export type GetHostsForAvailabilityResponse = { + hosts: AvailabilityHost[]; + nextCursor: number | undefined; + hasMore: boolean; +}; + +export const getHostsForAvailabilityHandler = async ({ + ctx, + input, +}: GetHostsForAvailabilityInput): Promise => { + const { eventTypeId, cursor, limit, search } = input; + + const hostRepository = new HostRepository(ctx.prisma); + const { items, nextCursor, hasMore } = await hostRepository.findHostsForAvailabilityPaginated({ + eventTypeId, + cursor: cursor ?? undefined, + 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 }; +}; 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 00000000000000..2d42662bead823 --- /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 64578cd095665f..138c8ca40e582f 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 b85e7d3da10354..0e0868aa132f1c 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/searchTeamMembers.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.handler.ts new file mode 100644 index 00000000000000..14086cc823a919 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/searchTeamMembers.handler.ts @@ -0,0 +1,63 @@ +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; +import type { TrpcSessionUser } from "../../../types"; +import type { TSearchTeamMembersInputSchema } from "./searchTeamMembers.schema"; + +type SearchTeamMembersInput = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TSearchTeamMembersInputSchema; +}; + +export type TeamMemberSearchResult = { + userId: number; + name: string | null; + email: string; + avatarUrl: string | null; + username: string | null; + defaultScheduleId: number | null; + role: MembershipRole; +}; + +export type SearchTeamMembersResponse = { + members: TeamMemberSearchResult[]; + nextCursor: number | undefined; + hasMore: boolean; +}; + +export const searchTeamMembersHandler = async ({ + ctx, + input, +}: SearchTeamMembersInput): Promise => { + const { teamId, cursor, limit, search, memberUserIds } = input; + const membershipRepo = new MembershipRepository(ctx.prisma); + + const isMember = await membershipRepo.hasMembership({ teamId, userId: ctx.user.id }); + if (!isMember) { + throw new TRPCError({ code: "FORBIDDEN", message: "You are not a member of this team" }); + } + + const { memberships, nextCursor, hasMore } = await membershipRepo.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 }; +}; 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 00000000000000..4f578c547731dd --- /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 e29fc9672b00f8..57e4bc20974432 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; // ============================================================================