diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 702758fbbe..a211a80cfe 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2399,6 +2399,7 @@ "delete_org_eventtypes": "Delete individual event types", "lock_org_users_eventtypes": "Lock individual event type creation", "lock_org_users_eventtypes_description": "Prevent members from creating their own event types.", + "add_to_event_type": "Add to event type", "create_account_password": "Create account password", "error_creating_account_password": "Failed to create account password", "cannot_create_account_password_cal_provider": "Cannot create account password for cal accounts", diff --git a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx index d4e2005626..8e9cf680fd 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx @@ -161,8 +161,8 @@ const OtherTeamProfileView = () => { function leaveTeam() { if (team?.id && session.data) removeMemberMutation.mutate({ - teamId: team.id, - memberId: session.data.user.id, + teamIds: [team.id], + memberIds: [session.data.user.id], }); } diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx index 95cbce921a..182645e235 100644 --- a/packages/features/ee/teams/components/AddNewTeamMembers.tsx +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -288,8 +288,8 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n className="h-[36px] w-[36px]" onClick={() => { removeMemberMutation.mutate({ - teamId: teamId, - memberId: member.id, + teamIds: [teamId], + memberIds: [member.id], isOrg: !!props.isOrg, }); }} diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index cdb1081e2f..5570548f07 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -95,8 +95,8 @@ export default function MemberListItem(props: Props) { const removeMember = () => removeMemberMutation.mutate({ - teamId: props.team?.id, - memberId: props.member.id, + teamIds: [props.team?.id], + memberIds: [props.member.id], isOrg: checkIsOrg(props.team), }); diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 0a33b73b26..64a9e684cf 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -147,8 +147,8 @@ const ProfileView = () => { function leaveTeam() { if (team?.id && session.data) removeMemberMutation.mutate({ - teamId: team.id, - memberId: session.data.user.id, + teamIds: [team.id], + memberIds: [session.data.user.id], }); } diff --git a/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx b/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx new file mode 100644 index 0000000000..566bd84564 --- /dev/null +++ b/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx @@ -0,0 +1,262 @@ +import type { Table } from "@tanstack/react-table"; +import { Check } from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; +import { useState } from "react"; + +import classNames from "@calcom/lib/classNames"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { SchedulingType } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + showToast, + PopoverContent, + PopoverTrigger, +} from "@calcom/ui"; + +import type { User } from "../UserListTable"; + +interface Props { + table: Table; + orgTeams: RouterOutputs["viewer"]["organizations"]["getTeams"] | undefined; +} + +export function EventTypesList({ table, orgTeams }: Props) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const teamIds = orgTeams?.map((team) => team.id); + const { data } = trpc.viewer.eventTypes.getByViewer.useQuery({ + filters: { teamIds, schedulingTypes: [SchedulingType.ROUND_ROBIN] }, + }); + const addMutation = trpc.viewer.organizations.addMembersToEventTypes.useMutation({ + onError: (error) => { + showToast(error.message, "error"); + }, + onSuccess: () => { + showToast( + `${selectedUsers.length} users added to ${Array.from(selectedEvents).length} events`, + "success" + ); + + utils.viewer.organizations.listMembers.invalidate(); + utils.viewer.eventTypes.invalidate(); + + // Clear the selected values + setSelectedEvents(new Set()); + setSelectedTeams(new Set()); + table.toggleAllRowsSelected(false); + }, + }); + const removeHostsMutation = trpc.viewer.organizations.removeHostsFromEventTypes.useMutation({ + onError: (error) => { + showToast(error.message, "error"); + }, + onSuccess: () => { + showToast( + `${selectedUsers.length} users were removed from ${Array.from(removeHostFromEvents).length} events`, + "success" + ); + + utils.viewer.organizations.listMembers.invalidate(); + utils.viewer.eventTypes.invalidate(); + + // Clear the selected values + setRemoveHostFromEvents(new Set()); + table.toggleAllRowsSelected(false); + }, + }); + const [selectedEvents, setSelectedEvents] = useState>(new Set()); + const [selectedTeams, setSelectedTeams] = useState>(new Set()); + const [removeHostFromEvents, setRemoveHostFromEvents] = useState>(new Set()); + const teams = data?.eventTypeGroups; + const selectedUsers = table.getSelectedRowModel().flatRows.map((row) => row.original); + + // Add value array to the set + const addValue = (set: Set, setSet: Dispatch>>, value: number[]) => { + const updatedSet = new Set(set); + value.forEach((v) => updatedSet.add(v)); + setSet(updatedSet); + }; + + // Remove value array from the set + const removeValue = (set: Set, setSet: Dispatch>>, value: number[]) => { + const updatedSet = new Set(set); + value.forEach((v) => updatedSet.delete(v)); + setSet(updatedSet); + }; + + return ( + <> + + + + + + + + + No results found. + + {teams && + teams.map((team) => { + const events = team.eventTypes; + const teamId = team.teamId; + + if (events.length === 0 || !teamId) return null; + + const ids = events.map((event) => event.id); + const areAllUsersHostForTeam = selectedUsers.every((user) => + events.every((event) => event.hosts.some((host) => host.userId === user.id)) + ); + const isSelected = ids.every( + (id) => + selectedEvents.has(id) || (areAllUsersHostForTeam && !removeHostFromEvents.has(id)) + ); + return ( + <> + { + if (!isSelected) { + // Add current team and its event + addValue(selectedTeams, setSelectedTeams, [teamId]); + addValue(selectedEvents, setSelectedEvents, ids); + setRemoveHostFromEvents(new Set()); + } else { + const eventIdsWhereAllUsersAreHosts = events + .filter((event) => + selectedUsers.every((user) => + event.hosts.some((host) => host.userId === user.id) + ) + ) + .map((event) => event.id); + + addValue( + removeHostFromEvents, + setRemoveHostFromEvents, + eventIdsWhereAllUsersAreHosts + ); + // Remove selected team and its event + removeValue(selectedEvents, setSelectedEvents, ids); + removeValue(selectedTeams, setSelectedTeams, [teamId]); + } + }} + isSelected={isSelected} + text={team.profile.name || ""} + key={team.profile.name} + /> + {events.map((event) => { + const hosts = event.hosts; + const areAllUsersHostForEventType = selectedUsers.every((user) => + hosts.some((host) => host.userId === user.id) + ); + const isSelected = + (selectedEvents.has(event.id) || areAllUsersHostForEventType) && + !removeHostFromEvents.has(event.id); + return ( + { + if (!isSelected) { + if (areAllUsersHostForEventType) { + removeValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]); + } else { + // Add current event and its team + addValue(selectedEvents, setSelectedEvents, [event.id]); + addValue(selectedTeams, setSelectedTeams, [teamId]); + } + } else { + if (areAllUsersHostForEventType) { + // remove selected users as hosts + addValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]); + } else { + // remove current event and its team + removeValue(selectedEvents, setSelectedEvents, [event.id]); + // if no event from current team is selected, remove the team + setSelectedEvents((selectedEvents) => { + if (!ids.some((id) => selectedEvents.has(id))) { + setSelectedTeams((selectedTeams) => { + const updatedTeams = new Set(selectedTeams); + updatedTeams.delete(teamId); + return updatedTeams; + }); + } + return selectedEvents; + }); + } + } + }} + key={event.id} + text={event.title} + isSelected={isSelected} + /> + ); + })} + + ); + })} + + + +
+ +
+
+
+ + ); +} + +interface ListItemProps { + text: string; + isSelected: boolean; + onSelect: () => void; + isTeam: boolean; +} + +const ListItem = ({ onSelect, text, isSelected, isTeam }: ListItemProps) => { + return ( + + {text} +
+ +
+
+ ); +}; diff --git a/packages/features/users/components/UserTable/BulkActions/TeamList.tsx b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx index 642a148bbe..dae4cee34b 100644 --- a/packages/features/users/components/UserTable/BulkActions/TeamList.tsx +++ b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx @@ -1,4 +1,5 @@ import type { Table } from "@tanstack/react-table"; +import type { Dispatch, SetStateAction } from "react"; import { useState } from "react"; import classNames from "@calcom/lib/classNames"; @@ -28,8 +29,9 @@ interface Props { export function TeamListBulkAction({ table }: Props) { const { data: teams } = trpc.viewer.organizations.getTeams.useQuery(); const [selectedValues, setSelectedValues] = useState>(new Set()); + const [removeFromTeams, setRemoveFromTeams] = useState>(new Set()); const utils = trpc.useUtils(); - const mutation = trpc.viewer.organizations.bulkAddToTeams.useMutation({ + const mutation = trpc.viewer.organizations.addMembersToTeams.useMutation({ onError: (error) => { showToast(error.message, "error"); }, @@ -48,20 +50,36 @@ export function TeamListBulkAction({ table }: Props) { }, }); + const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({ + onError: (error) => { + showToast(error.message, "error"); + }, + onSuccess: () => { + showToast(`${selectedUsers.length} Users removed from ${removeFromTeams.size} teams`, "success"); + + utils.viewer.organizations.listMembers.invalidate(); + + // Clear the selected values + setRemoveFromTeams(new Set()); + table.toggleAllRowsSelected(false); + }, + }); + const { t } = useLocale(); + const selectedUsers = table.getSelectedRowModel().flatRows.map((row) => row.original); // Add a value to the set - const addValue = (value: number) => { - const updatedSet = new Set(selectedValues); + const addValue = (set: Set, setSet: Dispatch>>, value: number) => { + const updatedSet = new Set(set); updatedSet.add(value); - setSelectedValues(updatedSet); + setSet(updatedSet); }; - // Remove a value from the set - const removeValue = (value: number) => { - const updatedSet = new Set(selectedValues); + // Remove value from the set + const removeValue = (set: Set, setSet: Dispatch>>, value: number) => { + const updatedSet = new Set(set); updatedSet.delete(value); - setSelectedValues(updatedSet); + setSet(updatedSet); }; return ( @@ -79,15 +97,28 @@ export function TeamListBulkAction({ table }: Props) { {teams && teams.map((option) => { - const isSelected = selectedValues.has(option.id); + const areAllUsersInTeam = selectedUsers.every((user) => + user.teams.some((team) => team.id === option.id) + ); + const isSelected = + (selectedValues.has(option.id) || areAllUsersInTeam) && !removeFromTeams.has(option.id); + return ( { if (!isSelected) { - addValue(option.id); + if (areAllUsersInTeam) { + removeValue(removeFromTeams, setRemoveFromTeams, option.id); + } else { + addValue(selectedValues, setSelectedValues, option.id); + } } else { - removeValue(option.id); + if (areAllUsersInTeam) { + addValue(removeFromTeams, setRemoveFromTeams, option.id); + } else { + removeValue(selectedValues, setSelectedValues, option.id); + } } }}> {option.name} @@ -110,11 +141,20 @@ export function TeamListBulkAction({ table }: Props) { className="ml-auto mr-1.5 rounded-md" size="sm" onClick={async () => { - const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original); - mutation.mutateAsync({ - userIds: selectedRows.map((row) => row.id), - teamIds: Array.from(selectedValues), - }); + if (selectedValues.size > 0) { + mutation.mutateAsync({ + userIds: selectedUsers.map((user) => user.id), + teamIds: Array.from(selectedValues), + }); + } + + if (removeFromTeams.size > 0) { + removeMemberMutation.mutateAsync({ + memberIds: selectedUsers.map((user) => user.id), + teamIds: Array.from(removeFromTeams), + isOrg: true, + }); + } }}> {t("apply")} diff --git a/packages/features/users/components/UserTable/DeleteMemberModal.tsx b/packages/features/users/components/UserTable/DeleteMemberModal.tsx index 63586841de..ee0e2b3d21 100644 --- a/packages/features/users/components/UserTable/DeleteMemberModal.tsx +++ b/packages/features/users/components/UserTable/DeleteMemberModal.tsx @@ -43,8 +43,8 @@ export function DeleteMemberModal({ state, dispatch }: { state: State; dispatch: if (!session?.user.org?.id || !state?.deleteMember?.user?.id) return; removeMemberMutation.mutate({ - teamId: session?.user.org.id, - memberId: state?.deleteMember?.user.id, + teamIds: [session?.user.org.id], + memberIds: [state?.deleteMember?.user.id], isOrg: true, }); }}> diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 2fdafcc6b4..8dd4a689c8 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -14,6 +14,7 @@ import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui"; import { useOrgBranding } from "../../../ee/organizations/context/provider"; import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers"; +import { EventTypesList } from "./BulkActions/EventTypesList"; import { TeamListBulkAction } from "./BulkActions/TeamList"; import { ChangeUserRoleModal } from "./ChangeUserRoleModal"; import { DeleteMemberModal } from "./DeleteMemberModal"; @@ -338,6 +339,10 @@ export function UserListTable() { setDynamicLinkVisible((old) => !old); }, }, + { + type: "render", + render: (table) => , + }, { type: "render", render: (table) => ( diff --git a/packages/lib/event-types/getEventTypesByViewer.ts b/packages/lib/event-types/getEventTypesByViewer.ts index 97c17cdd48..e7b078f134 100644 --- a/packages/lib/event-types/getEventTypesByViewer.ts +++ b/packages/lib/event-types/getEventTypesByViewer.ts @@ -31,6 +31,7 @@ type User = { type Filters = { teamIds?: number[]; upIds?: string[]; + schedulingTypes?: SchedulingType[]; }; export type EventTypesByViewer = Awaited>; @@ -202,12 +203,22 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo membershipRole: membership.role, })); - const filterTeamsEventTypesBasedOnInput = async (eventType: Awaited>) => { + const filterByTeamIds = async (eventType: Awaited>) => { if (!filters || !hasFilter(filters)) { return true; } return filters?.teamIds?.includes(eventType?.teamId || 0) ?? false; }; + const filterBySchedulingTypes = (evType: Awaited>) => { + if (!filters || !hasFilter(filters) || !filters.schedulingTypes) { + return true; + } + + if (!evType.schedulingType) return false; + + return filters.schedulingTypes.includes(evType.schedulingType); + }; + eventTypeGroups = ([] as EventTypeGroup[]).concat( eventTypeGroups, await Promise.all( @@ -271,7 +282,7 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo : MembershipRole.MEMBER), }, eventTypes: eventTypes - .filter(filterTeamsEventTypesBasedOnInput) + .filter(filterByTeamIds) .filter((evType) => { const res = evType.userId === null || evType.userId === user.id; return res; @@ -280,7 +291,8 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo membership.role === MembershipRole.MEMBER ? evType.schedulingType !== SchedulingType.MANAGED : true - ), + ) + .filter(filterBySchedulingTypes), }; }) ) diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts index 082b5df9dd..68bcadd1d2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -1,9 +1,12 @@ import { z } from "zod"; +import { SchedulingType } from "@calcom/prisma/enums"; + export const filterQuerySchemaStrict = z.object({ teamIds: z.number().array().optional(), // A user can only filter by only his userId upIds: z.string().array().max(1).optional(), + schedulingTypes: z.nativeEnum(SchedulingType).array().optional(), }); export const ZEventTypeInputSchema = z diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 749ca4961d..aa0aede5fe 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -6,7 +6,8 @@ import authedProcedure, { authedOrgAdminProcedure, } from "../../../procedures/authedProcedure"; import { importHandler, router } from "../../../trpc"; -import { ZAddBulkTeams } from "./addBulkTeams.schema"; +import { ZAddMembersToEventTypes } from "./addMembersToEventTypes.schema"; +import { ZAddMembersToTeams } from "./addMembersToTeams.schema"; import { ZAdminDeleteInput } from "./adminDelete.schema"; import { ZAdminGet } from "./adminGet.schema"; import { ZAdminUpdate } from "./adminUpdate.schema"; @@ -20,6 +21,7 @@ import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler"; import { ZGetUserInput } from "./getUser.schema"; import { ZListMembersSchema } from "./listMembers.schema"; import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler"; +import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema"; import { ZSetPasswordSchema } from "./setPassword.schema"; import { ZUpdateInputSchema } from "./update.schema"; import { ZUpdateUserInputSchema } from "./updateUser.schema"; @@ -88,8 +90,25 @@ export const viewerOrganizationsRouter = router({ const handler = await importHandler(namespaced("getTeams"), () => import("./getTeams.handler")); return handler(opts); }), - bulkAddToTeams: authedProcedure.input(ZAddBulkTeams).mutation(async (opts) => { - const handler = await importHandler(namespaced("addBulkTeams"), () => import("./addBulkTeams.handler")); + addMembersToTeams: authedProcedure.input(ZAddMembersToTeams).mutation(async (opts) => { + const handler = await importHandler( + namespaced("addMembersToTeams"), + () => import("./addMembersToTeams.handler") + ); + return handler(opts); + }), + addMembersToEventTypes: authedProcedure.input(ZAddMembersToEventTypes).mutation(async (opts) => { + const handler = await importHandler( + namespaced("addMembersToEventTypes"), + () => import("./addMembersToEventTypes.handler") + ); + return handler(opts); + }), + removeHostsFromEventTypes: authedProcedure.input(ZRemoveHostsFromEventTypes).mutation(async (opts) => { + const handler = await importHandler( + namespaced("removeHostsFromEventTypes"), + () => import("./removeHostsFromEventTypes.handler") + ); return handler(opts); }), bulkDeleteUsers: authedProcedure.input(ZBulkUsersDelete).mutation(async (opts) => { diff --git a/packages/trpc/server/routers/viewer/organizations/addBulkTeams.schema.ts b/packages/trpc/server/routers/viewer/organizations/addBulkTeams.schema.ts deleted file mode 100644 index b3ff7a94f7..0000000000 --- a/packages/trpc/server/routers/viewer/organizations/addBulkTeams.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod"; - -export const ZAddBulkTeams = z.object({ - userIds: z.array(z.number()), - teamIds: z.array(z.number()), -}); - -export type TAddBulkTeams = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts b/packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts new file mode 100644 index 0000000000..ca42cde399 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts @@ -0,0 +1,52 @@ +import type { Prisma } from "@prisma/client"; + +import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TAddMembersToEventTypes } from "./addMembersToEventTypes.schema"; +import { addMembersToTeams } from "./utils"; + +type AddBulkToEventTypeHandler = { + ctx: { + user: NonNullable; + }; + input: TAddMembersToEventTypes; +}; + +export async function addMembersToEventTypesHandler({ ctx, input }: AddBulkToEventTypeHandler) { + if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); + + // check if user is admin of organization + if (!(await isOrganisationAdmin(ctx.user?.id, ctx.user.organizationId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + + const { eventTypeIds, teamIds, userIds } = input; + + // invite users to whatever teams necessary + await addMembersToTeams({ + user: ctx.user, + input: { + teamIds, + userIds, + }, + }); + + const data: Prisma.HostCreateManyInput[] = eventTypeIds.flatMap((eventId) => + userIds.map((userId) => ({ + eventTypeId: eventId, + userId: userId, + priority: 2, // Default medium priority + })) + ); + + return await prisma.host.createMany({ + data, + // skip if user already a host in eventType + skipDuplicates: true, + }); +} + +export default addMembersToEventTypesHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.schema.ts b/packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.schema.ts new file mode 100644 index 0000000000..3e4b96c918 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZAddMembersToEventTypes = z.object({ + userIds: z.array(z.number()), + teamIds: z.array(z.number()), + eventTypeIds: z.array(z.number()), +}); + +export type TAddMembersToEventTypes = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/addMembersToTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/addMembersToTeams.handler.ts new file mode 100644 index 0000000000..99fbc243ad --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addMembersToTeams.handler.ts @@ -0,0 +1,19 @@ +import type { TrpcSessionUser } from "../../../trpc"; +import type { TAddMembersToTeams } from "./addMembersToTeams.schema"; +import { addMembersToTeams } from "./utils"; + +type AddBulkTeamsHandler = { + ctx: { + user: NonNullable; + }; + input: TAddMembersToTeams; +}; + +export async function addTeamsHandler({ ctx, input }: AddBulkTeamsHandler) { + return addMembersToTeams({ + user: ctx.user, + input, + }); +} + +export default addTeamsHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/addMembersToTeams.schema.ts b/packages/trpc/server/routers/viewer/organizations/addMembersToTeams.schema.ts new file mode 100644 index 0000000000..3c45e2ad3c --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addMembersToTeams.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZAddMembersToTeams = z.object({ + userIds: z.array(z.number()), + teamIds: z.array(z.number()), +}); + +export type TAddMembersToTeams = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/removeHostsFromEventTypes.handler.ts b/packages/trpc/server/routers/viewer/organizations/removeHostsFromEventTypes.handler.ts new file mode 100644 index 0000000000..ac1d552d2c --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/removeHostsFromEventTypes.handler.ts @@ -0,0 +1,36 @@ +import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema"; + +type RemoveHostsFromEventTypes = { + ctx: { + user: NonNullable; + }; + input: TRemoveHostsFromEventTypes; +}; + +export async function removeHostsFromEventTypesHandler({ ctx, input }: RemoveHostsFromEventTypes) { + if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (!(await isOrganisationAdmin(ctx.user?.id, ctx.user.organizationId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + + const { userIds, eventTypeIds } = input; + + return await prisma.host.deleteMany({ + where: { + eventTypeId: { + in: eventTypeIds, + }, + userId: { + in: userIds, + }, + }, + }); +} + +export default removeHostsFromEventTypesHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/removeHostsFromEventTypes.schema.ts b/packages/trpc/server/routers/viewer/organizations/removeHostsFromEventTypes.schema.ts new file mode 100644 index 0000000000..c8452242cb --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/removeHostsFromEventTypes.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZRemoveHostsFromEventTypes = z.object({ + userIds: z.array(z.number()), + eventTypeIds: z.array(z.number()), +}); + +export type TRemoveHostsFromEventTypes = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/addBulkTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/utils.ts similarity index 78% rename from packages/trpc/server/routers/viewer/organizations/addBulkTeams.handler.ts rename to packages/trpc/server/routers/viewer/organizations/utils.ts index ee5b11858e..3329ec5c9e 100644 --- a/packages/trpc/server/routers/viewer/organizations/addBulkTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/utils.ts @@ -6,28 +6,23 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; -import type { TAddBulkTeams } from "./addBulkTeams.schema"; +import type { TAddMembersToTeams } from "./addMembersToTeams.schema"; -type AddBulkTeamsHandler = { - ctx: { - user: NonNullable; - }; - input: TAddBulkTeams; -}; +interface AddBulkToTeamProps { + user: NonNullable; + input: TAddMembersToTeams; +} -export async function addBulkTeamsHandler({ ctx, input }: AddBulkTeamsHandler) { - const currentUser = ctx.user; - - if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); +export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => { + if (!user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); // check if user is admin of organization - if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId))) + if (!(await isOrganisationAdmin(user?.id, user.organizationId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - // Loop over all users and check if they are already in the organization const usersInOrganization = await prisma.membership.findMany({ where: { - teamId: currentUser.organizationId, + teamId: user.organizationId, user: { id: { in: input.userIds, @@ -85,6 +80,4 @@ export async function addBulkTeamsHandler({ ctx, input }: AddBulkTeamsHandler) { success: true, invitedTotalUsers: input.userIds.length, }; -} - -export default addBulkTeamsHandler; +}; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts index 6270969065..a445eb7ea9 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -27,31 +27,66 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = identifier: `removeMember.${ctx.sourceIp}`, }); - const isAdmin = await isTeamAdmin(ctx.user.id, input.teamId); + const { memberIds, teamIds, isOrg } = input; + const isAdmin = teamIds.every(async (teamId) => await isTeamAdmin(ctx.user.id, teamId)); + const isOrgAdmin = ctx.user.profile?.organizationId ? await isTeamAdmin(ctx.user.id, ctx.user.profile?.organizationId) : false; - if (!(isAdmin || isOrgAdmin) && ctx.user.id !== input.memberId) - throw new TRPCError({ code: "UNAUTHORIZED" }); - // Only a team owner can remove another team owner. - if ((await isTeamOwner(input.memberId, input.teamId)) && !(await isTeamOwner(ctx.user.id, input.teamId))) + + if (!(isAdmin || isOrgAdmin) && memberIds.every((memberId) => ctx.user.id !== memberId)) throw new TRPCError({ code: "UNAUTHORIZED" }); - if (ctx.user.id === input.memberId && isAdmin && !isOrgAdmin) + // Only a team owner can remove another team owner. + const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all( + memberIds.map(async (memberId) => { + const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all( + teamIds.map(async (teamId) => { + return (await isTeamOwner(memberId, teamId)) && !(await isTeamOwner(ctx.user.id, teamId)); + }) + ).then((results) => results.some((result) => result)); + + return isAnyTeamOwnerAndCurrentUserNotOwner; + }) + ).then((results) => results.some((result) => result)); + + if (isAnyMemberOwnerAndCurrentUserNotOwner) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Only a team owner can remove another team owner.", + }); + } + + if (memberIds.some((memberId) => ctx.user.id === memberId) && isAdmin && !isOrgAdmin) throw new TRPCError({ code: "FORBIDDEN", message: "You can not remove yourself from a team you own.", }); - const { membership } = await removeMember({ - teamId: input.teamId, - memberId: input.memberId, - isOrg: input.isOrg, - }); + const deleteMembershipPromises = []; + + for (const memberId of memberIds) { + for (const teamId of teamIds) { + deleteMembershipPromises.push( + removeMember({ + teamId, + memberId, + isOrg, + }) + ); + } + } + + const memberships = await Promise.all(deleteMembershipPromises); // Sync Services - closeComDeleteTeamMembership(membership.user); - if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); + memberships.flatMap((m) => closeComDeleteTeamMembership(m.membership.user)); + + if (IS_TEAM_BILLING_ENABLED) { + for (const teamId of teamIds) { + await updateQuantitySubscriptionFromStripe(teamId); + } + } }; export default removeMemberHandler; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts index 94036a2db2..9a96b80109 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts @@ -1,8 +1,8 @@ import { z } from "zod"; export const ZRemoveMemberInputSchema = z.object({ - teamId: z.number(), - memberId: z.number(), + teamIds: z.array(z.number()), + memberIds: z.array(z.number()), isOrg: z.boolean().default(false), });