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:
Amit Sharma
2024-06-01 21:30:52 +00:00
committed by GitHub
co-authored by Somay Chauhan
parent 5d2d99006b
commit 60b61eb625
22 changed files with 566 additions and 72 deletions
@@ -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>;
@@ -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>;
@@ -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),
});