feat: ability to assign people to round robin from org members (#14199)
* feat: ability to assign people to round robin from org members * fix: include schedulingtype in filter * fix: type check * rename to EventTypesList * rename to teams, change icon to link, early return if no events or teamId * filter scheduling type array, change filter name * rename to bulkAddMembersToTeams and bulkAddMembersToEventTypes * filter refactor * remove bulk * filter remove null return * Revert "filter remove null return" This reverts commit 8ae6296dac24e5081b5896bb9dfe0c000c0f7faf. * show checked if user already host in event type * check boxes if user is part of the team * remove hosts on unchecking boxes * fix isSelected condition * remove selected members from teams --------- Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
This commit is contained in:
co-authored by
Somay Chauhan
parent
5d2d99006b
commit
60b61eb625
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<User>;
|
||||
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<Set<number>>(new Set());
|
||||
const [selectedTeams, setSelectedTeams] = useState<Set<number>>(new Set());
|
||||
const [removeHostFromEvents, setRemoveHostFromEvents] = useState<Set<number>>(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<number>, setSet: Dispatch<SetStateAction<Set<number>>>, 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<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number[]) => {
|
||||
const updatedSet = new Set(set);
|
||||
value.forEach((v) => updatedSet.delete(v));
|
||||
setSet(updatedSet);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button StartIcon="link">{t("add_to_event_type")}</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0 shadow-md" align="start" sideOffset={12}>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{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 (
|
||||
<>
|
||||
<ListItem
|
||||
isTeam
|
||||
onSelect={() => {
|
||||
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 (
|
||||
<ListItem
|
||||
isTeam={false}
|
||||
onSelect={() => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<div className="my-1.5 flex w-full">
|
||||
<Button
|
||||
className="ml-auto mr-1.5 rounded-md"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const userIds = selectedUsers.map((user) => user.id);
|
||||
if (selectedEvents.size > 0) {
|
||||
addMutation.mutateAsync({
|
||||
userIds: userIds,
|
||||
teamIds: Array.from(selectedTeams),
|
||||
eventTypeIds: Array.from(selectedEvents),
|
||||
});
|
||||
}
|
||||
|
||||
if (removeHostFromEvents.size > 0) {
|
||||
removeHostsMutation.mutateAsync({
|
||||
userIds,
|
||||
eventTypeIds: Array.from(removeHostFromEvents),
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItemProps {
|
||||
text: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
isTeam: boolean;
|
||||
}
|
||||
|
||||
const ListItem = ({ onSelect, text, isSelected, isTeam }: ListItemProps) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={text}
|
||||
onSelect={onSelect}
|
||||
className={classNames(isTeam && "text-subtle text-xs font-normal")}>
|
||||
{text}
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle ml-auto flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Check className={classNames("h-4 w-4")} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
@@ -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<Set<number>>(new Set());
|
||||
const [removeFromTeams, setRemoveFromTeams] = useState<Set<number>>(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<number>, setSet: Dispatch<SetStateAction<Set<number>>>, 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<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number) => {
|
||||
const updatedSet = new Set(set);
|
||||
updatedSet.delete(value);
|
||||
setSelectedValues(updatedSet);
|
||||
setSet(updatedSet);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,15 +97,28 @@ export function TeamListBulkAction({ table }: Props) {
|
||||
<CommandGroup>
|
||||
{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 (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
onSelect={() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<span>{option.name}</span>
|
||||
@@ -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")}
|
||||
</Button>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}>
|
||||
|
||||
@@ -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) => <EventTypesList table={table} orgTeams={teams} />,
|
||||
},
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => (
|
||||
|
||||
@@ -31,6 +31,7 @@ type User = {
|
||||
type Filters = {
|
||||
teamIds?: number[];
|
||||
upIds?: string[];
|
||||
schedulingTypes?: SchedulingType[];
|
||||
};
|
||||
|
||||
export type EventTypesByViewer = Awaited<ReturnType<typeof getEventTypesByViewer>>;
|
||||
@@ -202,12 +203,22 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
|
||||
membershipRole: membership.role,
|
||||
}));
|
||||
|
||||
const filterTeamsEventTypesBasedOnInput = async (eventType: Awaited<ReturnType<typeof mapEventType>>) => {
|
||||
const filterByTeamIds = async (eventType: Awaited<ReturnType<typeof mapEventType>>) => {
|
||||
if (!filters || !hasFilter(filters)) {
|
||||
return true;
|
||||
}
|
||||
return filters?.teamIds?.includes(eventType?.teamId || 0) ?? false;
|
||||
};
|
||||
const filterBySchedulingTypes = (evType: Awaited<ReturnType<typeof mapEventType>>) => {
|
||||
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),
|
||||
};
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<typeof ZAddBulkTeams>;
|
||||
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
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;
|
||||
@@ -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<typeof ZAddMembersToEventTypes>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
import type { TAddMembersToTeams } from "./addMembersToTeams.schema";
|
||||
import { addMembersToTeams } from "./utils";
|
||||
|
||||
type AddBulkTeamsHandler = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TAddMembersToTeams;
|
||||
};
|
||||
|
||||
export async function addTeamsHandler({ ctx, input }: AddBulkTeamsHandler) {
|
||||
return addMembersToTeams({
|
||||
user: ctx.user,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
export default addTeamsHandler;
|
||||
@@ -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<typeof ZAddMembersToTeams>;
|
||||
+36
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
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;
|
||||
@@ -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<typeof ZRemoveHostsFromEventTypes>;
|
||||
+10
-17
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
input: TAddBulkTeams;
|
||||
};
|
||||
interface AddBulkToTeamProps {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user