RocketChat/Rocket.Chat

View on GitHub
apps/meteor/server/services/team/service.ts

Summary

Maintainability
F
1 wk
Test Coverage
import { Room, Authorization, Message, ServiceClassInternal } from '@rocket.chat/core-services';
import type {
    IListRoomsFilter,
    ITeamAutocompleteResult,
    ITeamCreateParams,
    ITeamInfo,
    ITeamMemberInfo,
    ITeamMemberParams,
    ITeamService,
    ITeamUpdateData,
} from '@rocket.chat/core-services';
import { TEAM_TYPE } from '@rocket.chat/core-typings';
import type {
    IRoom,
    IUser,
    ISubscription,
    IPaginationOptions,
    IQueryOptions,
    IRecordsWithTotal,
    ITeam,
    ITeamMember,
    ITeamStats,
    AtLeast,
} from '@rocket.chat/core-typings';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { Team, Rooms, Subscriptions, Users, TeamMember } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { Document, FindOptions, Filter } from 'mongodb';

import { saveRoomName } from '../../../app/channel-settings/server';
import { saveRoomType } from '../../../app/channel-settings/server/functions/saveRoomType';
import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom';
import { checkUsernameAvailability } from '../../../app/lib/server/functions/checkUsernameAvailability';
import { getSubscribedRoomsForUserWithDetails } from '../../../app/lib/server/functions/getRoomsWithSingleOwner';
import { removeUserFromRoom } from '../../../app/lib/server/functions/removeUserFromRoom';
import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener';
import { settings } from '../../../app/settings/server';

export class TeamService extends ServiceClassInternal implements ITeamService {
    protected name = 'team';

    async create(
        uid: string,
        { team, room = { name: team.name, extraData: {} }, members, owner, sidepanel }: ITeamCreateParams,
    ): Promise<ITeam> {
        if (!(await checkUsernameAvailability(team.name))) {
            throw new Error('team-name-already-exists');
        }

        const existingRoom = await Rooms.findOneByName(team.name, { projection: { _id: 1 } });
        if (existingRoom && existingRoom._id !== room.id) {
            throw new Error('room-name-already-exists');
        }

        const createdBy = await Users.findOneById<Pick<IUser, 'username' | '_id'>>(uid, {
            projection: { username: 1 },
        });
        if (!createdBy) {
            throw new Error('invalid-user');
        }

        // TODO add validations to `data` and `members`

        const membersResult =
            !members || !Array.isArray(members) || members.length === 0
                ? []
                : await Users.findActiveByIdsOrUsernames(members, {
                        projection: { username: 1 },
                  }).toArray();
        const memberUsernames = membersResult.map(({ username }) => username);
        const memberIds = membersResult.map(({ _id }) => _id);

        const teamData = {
            ...team,
            createdAt: new Date(),
            createdBy,
            _updatedAt: new Date(), // TODO how to avoid having to do this?
            roomId: '', // this will be populated at the end
        };

        try {
            const roomId =
                room.id ||
                (
                    await Room.create(owner || uid, {
                        ...room,
                        type: team.type === TEAM_TYPE.PRIVATE ? 'p' : 'c',
                        name: team.name,
                        members: memberUsernames as string[],
                        extraData: {
                            ...room.extraData,
                        },
                        sidepanel,
                    })
                )._id;

            const result = await Team.insertOne(teamData);
            const teamId = result.insertedId;
            // the same uid can be passed at 3 positions: owner, member list or via caller
            // if the owner is present, remove it from the members list
            // if the owner is not present, remove the caller from the members list
            const excludeFromMembers = owner ? [owner] : [uid];

            // filter empty strings and falsy values from members list
            const membersList: Array<InsertionModel<ITeamMember>> =
                memberIds
                    ?.filter(Boolean)
                    .filter((memberId) => !excludeFromMembers.includes(memberId))
                    .map((memberId) => ({
                        teamId,
                        userId: memberId,
                        createdAt: new Date(),
                        createdBy,
                    })) || [];

            membersList.push({
                teamId,
                userId: owner || uid,
                roles: ['owner'],
                createdAt: new Date(),
                createdBy,
            });

            await TeamMember.insertMany(membersList);

            await Rooms.setTeamMainById(roomId, teamId);
            await Team.updateMainRoomForTeam(teamId, roomId);
            teamData.roomId = roomId;

            if (room.id) {
                await Message.saveSystemMessage('user-converted-to-team', roomId, team.name, createdBy);
            }

            return {
                _id: teamId,
                ...teamData,
            };
        } catch (e) {
            throw new Error('error-team-creation');
        }
    }

    async update(uid: string, teamId: string, updateData: ITeamUpdateData): Promise<void> {
        const team = await Team.findOneById<Pick<ITeam, '_id' | 'roomId'>>(teamId, {
            projection: { roomId: 1 },
        });
        if (!team) {
            return;
        }

        const user = await Users.findOneById(uid);
        if (!user) {
            return;
        }

        const { name, type, updateRoom = true } = updateData;

        if (updateRoom && name) {
            await saveRoomName(team.roomId, name, user);
        }

        if (updateRoom && typeof type !== 'undefined') {
            await saveRoomType(team.roomId, type === TEAM_TYPE.PRIVATE ? 'p' : 'c', user);
        }

        await Team.updateNameAndType(teamId, updateData);
    }

    async findBySubscribedUserIds(userId: string, callerId?: string): Promise<ITeam[]> {
        const unfilteredTeams = await TeamMember.findByUserId<Pick<ITeamMember, 'roles' | 'teamId'>>(userId, {
            projection: { teamId: 1, roles: 1 },
        }).toArray();
        const unfilteredTeamIds = unfilteredTeams.map(({ teamId }) => teamId);

        let teamIds = unfilteredTeamIds;

        if (callerId) {
            const publicTeams = await Team.findByIdsAndType<Pick<ITeam, '_id'>>(unfilteredTeamIds, TEAM_TYPE.PUBLIC, {
                projection: { _id: 1 },
            }).toArray();
            const publicTeamIds = publicTeams.map(({ _id }) => _id);
            const privateTeamIds = unfilteredTeamIds.filter((teamId) => !publicTeamIds.includes(teamId));

            const privateTeams = await TeamMember.findByUserIdAndTeamIds(callerId, privateTeamIds, {
                projection: { teamId: 1 },
            }).toArray();
            const visibleTeamIds = privateTeams.map(({ teamId }) => teamId).concat(publicTeamIds);
            teamIds = unfilteredTeamIds.filter((teamId) => visibleTeamIds.includes(teamId));
        }

        const ownedTeams = unfilteredTeams.filter(({ roles = [] }) => roles.includes('owner')).map(({ teamId }) => teamId);

        const results = await Team.findByIds(teamIds).toArray();
        return results.map((team) => ({
            ...team,
            isOwner: ownedTeams.includes(team._id),
        }));
    }

    search(userId: string, term: string | RegExp): Promise<ITeam[]>;

    search(userId: string, term: string | RegExp, options: FindOptions<ITeam>): Promise<ITeam[]>;

    search<P extends Document>(userId: string, term: string | RegExp, options: FindOptions<P extends ITeam ? ITeam : P>): Promise<P[]>;

    async search<P extends Document>(
        userId: string,
        term: string | RegExp,
        options?: undefined | FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
    ): Promise<ITeam[] | P[]> {
        if (typeof term === 'string') {
            term = new RegExp(`^${escapeRegExp(term)}`, 'i');
        }

        const userTeams = await TeamMember.findByUserId<Pick<ITeamMember, 'teamId'>>(userId, {
            projection: { teamId: 1 },
        }).toArray();
        const teamIds = userTeams.map(({ teamId }) => teamId);

        return Team.findByNameAndTeamIds(term, teamIds, options || {}).toArray();
    }

    async list(
        uid: string,
        { offset, count }: IPaginationOptions = { offset: 0, count: 50 },
        { sort, query }: IQueryOptions<ITeam> = { sort: {} },
    ): Promise<IRecordsWithTotal<ITeamInfo>> {
        const userTeams = await TeamMember.findByUserId<Pick<ITeamMember, 'teamId'>>(uid, {
            projection: { teamId: 1 },
        }).toArray();

        const teamIds = userTeams.map(({ teamId }) => teamId);
        if (teamIds.length === 0) {
            return {
                total: 0,
                records: [],
            };
        }

        const { cursor, totalCount } = Team.findByIdsPaginated(
            teamIds,
            {
                ...(sort && { sort }),
                limit: count,
                skip: offset,
            },
            query,
        );

        const [records, total] = await Promise.all([cursor.toArray(), totalCount]);

        const results: ITeamInfo[] = [];
        for await (const record of records) {
            const rooms = Rooms.findByTeamId(record._id);
            const users = TeamMember.findByTeamId(record._id);
            results.push({
                ...record,
                rooms: await rooms.count(),
                numberOfUsers: await users.count(),
            });
        }

        return {
            total,
            records: results,
        };
    }

    async listAll({ offset, count }: IPaginationOptions = { offset: 0, count: 50 }): Promise<IRecordsWithTotal<ITeamInfo>> {
        const { cursor, totalCount } = Team.findPaginated(
            {},
            {
                limit: count,
                skip: offset,
            },
        );

        const [records, total] = await Promise.all([cursor.toArray(), totalCount]);

        const results: ITeamInfo[] = [];
        for await (const record of records) {
            const rooms = Rooms.findByTeamId(record._id);
            const users = TeamMember.findByTeamId(record._id);
            results.push({
                ...record,
                rooms: await rooms.count(),
                numberOfUsers: await users.count(),
            });
        }

        return {
            total,
            records: results,
        };
    }

    listByNames(names: Array<string>): Promise<ITeam[]>;

    listByNames(names: Array<string>, options: FindOptions<ITeam>): Promise<ITeam[]>;

    listByNames<P extends Document>(names: Array<string>, options: FindOptions<P extends ITeam ? ITeam : P>): Promise<P[]>;

    async listByNames<P extends Document>(
        names: Array<string>,
        options?: undefined | FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
    ): Promise<P[] | ITeam[]> {
        if (options === undefined) {
            return Team.findByNames(names).toArray();
        }
        return Team.findByNames(names, options).toArray();
    }

    async listByIds(ids: Array<string>, options?: FindOptions<ITeam>): Promise<ITeam[]> {
        return Team.findByIds(ids, options).toArray();
    }

    async addRooms(uid: string, rooms: Array<string>, teamId: string): Promise<Array<IRoom>> {
        if (!teamId) {
            throw new Error('missing-teamId');
        }
        if (!rooms) {
            throw new Error('missing-rooms');
        }
        if (!uid) {
            throw new Error('missing-userId');
        }

        const team = await Team.findOneById<Pick<ITeam, '_id' | 'roomId'>>(teamId, { projection: { _id: 1, roomId: 1 } });
        if (!team) {
            throw new Error('invalid-team');
        }

        // at this point, we already checked for the permission
        // so we just need to check if the user can see the room
        const user = await Users.findOneById(uid);
        if (!user) {
            throw new Error('invalid-user');
        }

        const rids = rooms.filter((rid) => rid && typeof rid === 'string');
        const validRooms = await Rooms.findManyByRoomIds(rids).toArray();
        if (validRooms.length < rids.length) {
            throw new Error('invalid-room');
        }

        // validate access for every room first
        for await (const room of validRooms) {
            const canSeeRoom = await Authorization.canAccessRoom(room, user);
            if (!canSeeRoom) {
                throw new Error('invalid-room');
            }
        }

        for await (const room of validRooms) {
            if (room.teamId) {
                throw new Error('room-already-on-team');
            }

            if (!(await Subscriptions.isUserInRole(uid, 'owner', room._id))) {
                throw new Error('error-no-owner-channel');
            }

            if (room.t === 'c') {
                await Message.saveSystemMessage('user-added-room-to-team', team.roomId, room.name || '', user);
            }

            room.teamId = teamId;
        }

        await Rooms.setTeamByIds(rids, teamId);
        return validRooms;
    }

    async removeRoom(uid: string, rid: string, teamId: string, canRemoveAnyRoom = false): Promise<IRoom> {
        if (!teamId) {
            throw new Error('missing-teamId');
        }
        if (!rid) {
            throw new Error('missing-roomId');
        }
        if (!uid) {
            throw new Error('missing-userId');
        }

        const room = await Rooms.findOneById(rid);
        if (!room) {
            throw new Error('invalid-room');
        }

        const user = await Users.findOneById(uid);
        if (!user) {
            throw new Error('invalid-user');
        }
        if (!canRemoveAnyRoom) {
            const canSeeRoom = await Authorization.canAccessRoom(room, user);
            if (!canSeeRoom) {
                throw new Error('invalid-room');
            }
        }

        const team = await Team.findOneById<Pick<ITeam, '_id' | 'roomId'>>(teamId, { projection: { _id: 1, roomId: 1 } });
        if (!team) {
            throw new Error('invalid-team');
        }

        if (room.teamId !== teamId) {
            throw new Error('room-not-on-that-team');
        }

        delete room.teamId;
        delete room.teamDefault;
        await Rooms.unsetTeamById(room._id);

        if (room.t === 'c') {
            await Message.saveSystemMessage('user-removed-room-from-team', team.roomId, room.name || '', user);
        }

        return {
            ...room,
        };
    }

    async unsetTeamIdOfRooms(uid: string, teamId: string): Promise<void> {
        if (!teamId) {
            throw new Error('missing-teamId');
        }

        const team = await Team.findOneById<Pick<ITeam, 'roomId'>>(teamId, { projection: { roomId: 1 } });
        if (!team) {
            throw new Error('invalid-team');
        }

        const room = await Rooms.findOneById<Pick<IRoom, 'name'>>(team.roomId, { projection: { name: 1 } });
        if (!room) {
            throw new Error('invalid-room');
        }

        const user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name'>>(uid, { projection: { username: 1, name: 1 } });
        if (!user) {
            throw new Error('invalid-user');
        }

        await Message.saveSystemMessage('user-converted-to-channel', team.roomId, room.name || '', user);

        await Rooms.unsetTeamId(teamId);
    }

    async updateRoom(uid: string, rid: string, isDefault: boolean, canUpdateAnyRoom = false): Promise<IRoom> {
        if (!rid) {
            throw new Error('missing-roomId');
        }
        if (!uid) {
            throw new Error('missing-userId');
        }

        const room = await Rooms.findOneById(rid);
        if (!room) {
            throw new Error('invalid-room');
        }

        const user = await Users.findOneById(uid);
        if (!user) {
            throw new Error('invalid-user');
        }
        if (!canUpdateAnyRoom) {
            const canSeeRoom = await Authorization.canAccessRoom(room, user);
            if (!canSeeRoom) {
                throw new Error('invalid-room');
            }
        }

        if (!room.teamId) {
            throw new Error('room-not-on-team');
        }
        room.teamDefault = isDefault;
        await Rooms.setTeamDefaultById(rid, isDefault);

        if (isDefault) {
            const maxNumberOfAutoJoinMembers = settings.get<number>('API_User_Limit');
            const teamMembers = await this.members(
                uid,
                room.teamId,
                true,
                { offset: 0, count: maxNumberOfAutoJoinMembers },
                // We should not get the owner of the room, since he is already a member
                { _id: { $ne: room.u._id } },
            );

            for await (const m of teamMembers.records) {
                if (await addUserToRoom(room._id, m.user, user)) {
                    room.usersCount++;
                }
            }
        }

        return {
            ...room,
        };
    }

    listTeamsBySubscriberUserId(uid: string): Promise<ITeamMember[]>;

    listTeamsBySubscriberUserId(uid: string, options: FindOptions<ITeamMember>): Promise<ITeamMember[]>;

    listTeamsBySubscriberUserId<P extends Document>(uid: string, options: FindOptions<P>): Promise<P[]>;

    listTeamsBySubscriberUserId<P extends Document>(
        uid: string,
        options?: undefined | FindOptions<ITeamMember> | FindOptions<P extends ITeamMember ? ITeamMember : P>,
    ): Promise<P[] | ITeamMember[]> {
        if (options) {
            return TeamMember.findByUserId(uid, options).toArray();
        }
        return TeamMember.findByUserId(uid).toArray();
    }

    async listRooms(
        uid: string,
        teamId: string,
        filter: IListRoomsFilter,
        { offset: skip, count: limit }: IPaginationOptions = { offset: 0, count: 50 },
    ): Promise<IRecordsWithTotal<IRoom>> {
        if (!teamId) {
            throw new Error('missing-teamId');
        }
        const team = await Team.findOneById<Pick<ITeam, '_id' | 'type'>>(teamId, {
            projection: { _id: 1, type: 1 },
        });
        if (!team) {
            throw new Error('invalid-team');
        }

        const { getAllRooms, allowPrivateTeam, name, isDefault } = filter;

        const isMember = await TeamMember.findOneByUserIdAndTeamId(uid, teamId);
        if (team.type === TEAM_TYPE.PRIVATE && !allowPrivateTeam && !isMember) {
            throw new Error('user-not-on-private-team');
        }

        if (getAllRooms) {
            const { cursor, totalCount } = Rooms.findPaginatedByTeamIdContainingNameAndDefault(teamId, name, isDefault, undefined, {
                skip,
                limit,
            });
            const [records, total] = await Promise.all([cursor.toArray(), totalCount]);
            return {
                total,
                records,
            };
        }

        const user = await Users.findOneById<{ __rooms: string[] }>(uid, {
            projection: { __rooms: 1 },
        });
        const userRooms = user?.__rooms;

        const { cursor, totalCount } = Rooms.findPaginatedByTeamIdContainingNameAndDefault(teamId, name, isDefault, userRooms, { skip, limit });

        const [records, total] = await Promise.all([cursor.toArray(), totalCount]);

        return {
            total,
            records,
        };
    }

    async listRoomsOfUser(
        uid: string,
        teamId: string,
        userId: string,
        allowPrivateTeam: boolean,
        showCanDeleteOnly: boolean,
        { offset: skip, count: limit }: IPaginationOptions = { offset: 0, count: 50 },
    ): Promise<IRecordsWithTotal<IRoom>> {
        if (!teamId) {
            throw new Error('missing-teamId');
        }
        const team = await Team.findOneById(teamId, {});
        if (!team) {
            throw new Error('invalid-team');
        }
        const isMember = await TeamMember.findOneByUserIdAndTeamId(uid, teamId);
        if (team.type === TEAM_TYPE.PRIVATE && !allowPrivateTeam && !isMember) {
            throw new Error('user-not-on-private-team');
        }

        const teamRooms: (IRoom & {
            userCanDelete?: boolean;
        })[] = await Rooms.findByTeamId(teamId, {
            projection: { _id: 1, t: 1 },
        }).toArray();

        let teamRoomIds: string[];

        if (showCanDeleteOnly) {
            for await (const room of teamRooms) {
                const roomType = room.t;
                const canDeleteRoom = await Authorization.hasPermission(userId, roomType === 'c' ? 'delete-c' : 'delete-p', room._id);
                room.userCanDelete = canDeleteRoom;
            }

            teamRoomIds = teamRooms.filter((room) => (room.t === 'c' || room.t === 'p') && room.userCanDelete).map((room) => room._id);
        } else {
            teamRoomIds = teamRooms.filter((room) => room.t === 'p' || room.t === 'c').map((room) => room._id);
        }

        const subscriptionsCursor = Subscriptions.findByUserIdAndRoomIds(userId, teamRoomIds);
        const subscriptionRoomIds = (await subscriptionsCursor.toArray()).map((subscription) => subscription.rid);
        const { cursor, totalCount } = Rooms.findPaginatedByIds(subscriptionRoomIds, {
            skip,
            limit,
        });

        const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]);

        const roomData = await getSubscribedRoomsForUserWithDetails(userId, false, teamRoomIds);
        const records = [];

        for (const room of rooms) {
            const roomInfo = roomData.find((data) => data.rid === room._id);
            if (!roomInfo) {
                continue;
            }
            room.isLastOwner = roomInfo.userIsLastOwner;
            records.push(room);
        }

        return {
            total,
            records,
        };
    }

    async getMatchingTeamRooms(teamId: string, rids: Array<string>): Promise<Array<string>> {
        if (!teamId) {
            throw new Error('missing-teamId');
        }

        if (!rids) {
            return [];
        }

        if (!Array.isArray(rids)) {
            throw new Error('invalid-list-of-rooms');
        }

        const rooms = await Rooms.findByTeamIdAndRoomsId(teamId, rids, {
            projection: { _id: 1 },
        }).toArray();
        return rooms.map(({ _id }: { _id: string }) => _id);
    }

    async getMembersByTeamIds(teamIds: Array<string>, options: FindOptions<ITeamMember>): Promise<Array<ITeamMember>> {
        return TeamMember.findByTeamIds(teamIds, options).toArray();
    }

    async members(
        uid: string,
        teamId: string,
        canSeeAll: boolean,
        { offset, count }: IPaginationOptions = { offset: 0, count: 50 },
        query: Filter<IUser> = {},
    ): Promise<IRecordsWithTotal<ITeamMemberInfo>> {
        const isMember = await TeamMember.findOneByUserIdAndTeamId(uid, teamId);
        if (!isMember && !canSeeAll) {
            return {
                total: 0,
                records: [],
            };
        }

        const users = await Users.findActive({ ...query }).toArray();
        const userIds = users.map((m) => m._id);
        const { cursor, totalCount } = TeamMember.findPaginatedMembersInfoByTeamId(teamId, count, offset, {
            userId: { $in: userIds },
        });

        const results: ITeamMemberInfo[] = [];
        for await (const record of cursor) {
            const user = users.find((u) => u._id === record.userId);
            if (!user) {
                continue;
            }

            results.push({
                user: {
                    _id: user._id,
                    username: user.username,
                    name: user.name,
                    status: user.status,
                    settings: user.settings,
                },
                roles: record.roles,
                createdBy: {
                    _id: record.createdBy._id,
                    username: record.createdBy.username,
                },
                createdAt: record.createdAt,
            });
        }

        return {
            total: await totalCount,
            records: results,
        };
    }

    async addMembers(uid: string, teamId: string, members: Array<ITeamMemberParams>): Promise<void> {
        const createdBy = (await Users.findOneById(uid, { projection: { username: 1 } })) as Pick<IUser, '_id' | 'username'>;
        if (!createdBy) {
            throw new Error('invalid-user');
        }

        const team = await Team.findOneById<Pick<ITeam, 'roomId'>>(teamId, {
            projection: { roomId: 1 },
        });
        if (!team) {
            throw new Error('team-does-not-exist');
        }

        for await (const member of members) {
            const user = (await Users.findOneById(member.userId, { projection: { username: 1 } })) as Pick<IUser, '_id' | 'username'>;
            await addUserToRoom(team.roomId, user, createdBy, { skipSystemMessage: false });

            if (member.roles) {
                await this.addRolesToMember(teamId, member.userId, member.roles);
            }
        }
    }

    async updateMember(teamId: string, member: ITeamMemberParams): Promise<void> {
        if (!member.userId) {
            throw new Error('invalid-user');
        }

        const memberUpdate: Partial<ITeamMember> = {
            roles: member.roles ? member.roles : [],
        };

        const team = await Team.findOneById(teamId);

        if (!team) {
            throw new Error('invalid-team');
        }

        const responses = await Promise.all([
            TeamMember.updateOneByUserIdAndTeamId(member.userId, teamId, memberUpdate),
            Subscriptions.updateOne(
                {
                    'rid': team?.roomId,
                    'u._id': member.userId,
                },
                {
                    $set: memberUpdate,
                },
            ),
        ]);

        if (responses[1].modifiedCount) {
            void notifyOnSubscriptionChangedByRoomIdAndUserId(team.roomId, member.userId);
        }
    }

    async removeMember(teamId: string, userId: string): Promise<void> {
        await TeamMember.deleteByUserIdAndTeamId(userId, teamId);
    }

    async removeMembers(uid: string, teamId: string, members: Array<ITeamMemberParams>): Promise<boolean> {
        const team = await Team.findOneById<Pick<ITeam, 'roomId' | '_id'>>(teamId, {
            projection: { _id: 1, roomId: 1 },
        });
        if (!team) {
            throw new Error('team-does-not-exist');
        }

        const membersIds = members.map((m) => m.userId);
        const usersToRemove = await Users.findByIds(membersIds, {
            projection: { _id: 1, username: 1 },
        }).toArray();
        const byUser = await Users.findOneById(uid);

        for await (const member of members) {
            if (!member.userId) {
                throw new Error('invalid-user');
            }

            const existingMember = await TeamMember.findOneByUserIdAndTeamId(member.userId, team._id);
            const subscription = await Subscriptions.findOneByRoomIdAndUserId(team.roomId, member.userId);

            if (!existingMember && !subscription) {
                throw new Error('member-does-not-exist');
            }

            if (existingMember) {
                if (existingMember.roles?.includes('owner')) {
                    const owners = TeamMember.findByTeamIdAndRole(team._id, 'owner');
                    const totalOwners = await owners.count();
                    if (totalOwners === 1) {
                        throw new Error('last-owner-can-not-be-removed');
                    }
                }

                await TeamMember.removeById(existingMember._id);
            }

            const removedUser = usersToRemove.find((u) => u._id === (existingMember || member).userId);
            if (removedUser) {
                await removeUserFromRoom(
                    team.roomId,
                    removedUser,
                    uid !== member.userId && byUser
                        ? {
                                byUser,
                          }
                        : undefined,
                );
            }
        }

        return true;
    }

    async insertMemberOnTeams(userId: string, teamIds: Array<string>): Promise<void> {
        const inviter = { _id: 'rocket.cat', username: 'rocket.cat' };

        await Promise.all(
            teamIds.map(async (teamId) => {
                const team = await Team.findOneById(teamId);
                const user = await Users.findOneById(userId);

                if (!team || !user) {
                    return;
                }

                await addUserToRoom(team.roomId, user, inviter, { skipSystemMessage: false });
            }),
        );
    }

    async removeMemberFromTeams(userId: string, teamIds: Array<string>): Promise<void> {
        await Promise.all(
            teamIds.map(async (teamId) => {
                const team = await Team.findOneById(teamId);
                const user = await Users.findOneById(userId);

                if (!team || !user) {
                    return;
                }

                await removeUserFromRoom(team.roomId, user);
            }),
        );
    }

    async removeAllMembersFromTeam(teamId: string): Promise<void> {
        if (!teamId) {
            throw new Error('missing-teamId');
        }

        await TeamMember.deleteByTeamId(teamId);
    }

    async addMember(inviter: Pick<IUser, '_id' | 'username'>, userId: string, teamId: string): Promise<boolean> {
        const isAlreadyAMember = await TeamMember.findOneByUserIdAndTeamId(userId, teamId, {
            projection: { _id: 1 },
        });

        if (isAlreadyAMember) {
            return false;
        }

        let inviterData = {} as Pick<IUser, '_id' | 'username'>;
        if (inviter) {
            inviterData = { _id: inviter._id, username: inviter.username };
        }

        await TeamMember.createOneByTeamIdAndUserId(teamId, userId, inviterData);

        await this.addMembersToDefaultRooms(inviter, teamId, [{ userId }]);

        return true;
    }

    getAllPublicTeams(): Promise<ITeam[]>;

    getAllPublicTeams(options: FindOptions<ITeam>): Promise<ITeam[]>;

    async getAllPublicTeams(options?: undefined | FindOptions<ITeam>): Promise<ITeam[]> {
        return options ? Team.findByType(TEAM_TYPE.PUBLIC, options).toArray() : Team.findByType(TEAM_TYPE.PUBLIC).toArray();
    }

    async getOneById(teamId: string, options?: FindOptions<ITeam>): Promise<ITeam | null> {
        if (options === undefined) {
            return Team.findOneById(teamId);
        }
        return Team.findOneById(teamId, options);
    }

    async getOneByName(teamName: string | RegExp): Promise<ITeam | null>;

    async getOneByName(teamName: string | RegExp, options: FindOptions<ITeam>): Promise<ITeam | null>;

    async getOneByName(teamName: string | RegExp, options?: undefined | FindOptions<ITeam>): Promise<ITeam | null> {
        if (!options) {
            return Team.findOneByName(teamName);
        }
        return Team.findOneByName(teamName, options);
    }

    async getOneByMainRoomId(roomId: string): Promise<Pick<ITeam, '_id'> | null> {
        return Team.findOneByMainRoomId<Pick<ITeam, '_id'>>(roomId, {
            projection: { _id: 1 },
        });
    }

    async getOneByRoomId(roomId: string, options?: FindOptions<ITeam>): Promise<ITeam | null> {
        const room = await Rooms.findOneById(roomId, { projection: { teamId: 1 } });

        if (!room) {
            throw new Error('invalid-room');
        }

        if (!room.teamId) {
            throw new Error('room-not-on-team');
        }

        return Team.findOneById(room.teamId, options);
    }

    async addRolesToMember(teamId: string, userId: string, roles: Array<string>): Promise<boolean> {
        const isMember = await TeamMember.findOneByUserIdAndTeamId(userId, teamId, {
            projection: { _id: 1 },
        });

        if (!isMember) {
            // TODO should this throw an error instead?
            return false;
        }

        return !!(await TeamMember.updateRolesByTeamIdAndUserId(teamId, userId, roles));
    }

    async removeRolesFromMember(teamId: string, userId: string, roles: Array<string>): Promise<boolean> {
        const isMember = await TeamMember.findOneByUserIdAndTeamId(userId, teamId, {
            projection: { _id: 1 },
        });

        if (!isMember) {
            // TODO should this throw an error instead?
            return false;
        }

        return !!(await TeamMember.removeRolesByTeamIdAndUserId(teamId, userId, roles));
    }

    async getInfoByName(teamName: string): Promise<Omit<ITeam, 'usernames'> | null> {
        return Team.findOne<Omit<ITeam, 'usernames'>>(
            {
                name: teamName,
            },
            { projection: { usernames: 0 } },
        );
    }

    async getInfoById(teamId: string): Promise<Omit<ITeam, 'usernames'> | null> {
        return Team.findOne<Omit<ITeam, 'usernames'>>(
            {
                _id: teamId,
            },
            { projection: { usernames: 0 } },
        );
    }

    async addMembersToDefaultRooms(
        inviter: Pick<IUser, '_id' | 'username'>,
        teamId: string,
        members: Array<Pick<ITeamMember, 'userId'>>,
    ): Promise<void> {
        const defaultRooms = await Rooms.findDefaultRoomsForTeam(teamId).toArray();
        const users = await Users.findActiveByIds(members.map((member) => member.userId)).toArray();

        defaultRooms.map(async (room) => {
            // at this point, users are already part of the team so we won't check for membership
            for await (const user of users) {
                // add each user to the default room
                await addUserToRoom(room._id, user, inviter, { skipSystemMessage: false });
            }
        });
    }

    async deleteById(teamId: string): Promise<boolean> {
        return !!(await Team.deleteOneById(teamId));
    }

    async deleteByName(teamName: string): Promise<boolean> {
        return !!(await Team.deleteOneByName(teamName));
    }

    async getStatistics(): Promise<ITeamStats> {
        return {
            totalTeams: await Team.find({}).count(),
            totalRoomsInsideTeams: await Rooms.findRoomsInsideTeams().count(),
            totalDefaultRoomsInsideTeams: await Rooms.findRoomsInsideTeams(true).count(),
        };
    }

    async autocomplete(uid: string, name: string): Promise<ITeamAutocompleteResult[]> {
        const nameRegex = new RegExp(`^${escapeRegExp(name).trim()}`, 'i');

        const subscriptions = await Subscriptions.find<Pick<ISubscription, 'rid'>>({ 'u._id': uid }, { projection: { rid: 1 } }).toArray();
        const subscriptionIds = subscriptions.map(({ rid }) => rid);

        const rooms = await Rooms.find<ITeamAutocompleteResult>(
            {
                teamMain: true,
                $and: [
                    {
                        $or: [
                            {
                                name: nameRegex,
                            },
                            {
                                fname: nameRegex,
                            },
                        ],
                    },
                    {
                        $or: [
                            {
                                t: 'c',
                            },
                            {
                                _id: { $in: subscriptionIds },
                            },
                        ],
                    },
                ],
            },
            {
                projection: {
                    fname: 1,
                    teamId: 1,
                    name: 1,
                    t: 1,
                    avatarETag: 1,
                },
                limit: 10,
                sort: {
                    name: 1,
                    fname: 1,
                },
            },
        ).toArray();

        return rooms;
    }

    private getParentRoom(team: AtLeast<ITeam, 'roomId'>): Promise<Pick<IRoom, 'name' | 'fname' | 't' | '_id' | 'sidepanel'> | null> {
        return Rooms.findOneById<Pick<IRoom, 'name' | 'fname' | 't' | '_id' | 'sidepanel'>>(team.roomId, {
            projection: { name: 1, fname: 1, t: 1, sidepanel: 1 },
        });
    }

    async getRoomInfo(
        room: AtLeast<IRoom, 'teamId' | 'teamMain' | '_id'>,
    ): Promise<{ team?: Pick<ITeam, 'name' | 'roomId' | 'type'>; parentRoom?: Pick<IRoom, 'name' | 'fname' | 't' | '_id'> }> {
        if (!room.teamId) {
            return {};
        }

        const team = await Team.findOneById(room.teamId, { projection: { _id: 1, name: 1, roomId: 1, type: 1 } });
        if (!team) {
            return {};
        }

        if (room.teamMain) {
            return { team };
        }

        const parentRoom = await this.getParentRoom(team);
        return { team, ...(parentRoom && { parentRoom }) };
    }

    // Returns the list of rooms and discussions a user has access to inside a team
    // Rooms returned are a composition of the rooms the user is in + public rooms + discussions from the main room (if any)
    async listChildren(
        userId: string,
        team: AtLeast<ITeam, '_id' | 'roomId' | 'type'>,
        filter?: string,
        type?: 'channels' | 'discussions',
        sort?: Record<string, 1 | -1>,
        skip = 0,
        limit = 10,
    ): Promise<{ total: number; data: IRoom[] }> {
        const mainRoom = await Rooms.findOneById(team.roomId, { projection: { _id: 1 } });
        if (!mainRoom) {
            throw new Error('error-invalid-team-no-main-room');
        }

        const isMember = await TeamMember.findOneByUserIdAndTeamId(userId, team._id, {
            projection: { _id: 1 },
        });

        if (!isMember) {
            throw new Error('error-invalid-team-not-a-member');
        }

        const [{ totalCount: [{ count: total }] = [], paginatedResults: data = [] }] =
            (await Rooms.findChildrenOfTeam(team._id, mainRoom._id, userId, filter, type, { skip, limit, sort }).toArray()) || [];

        return {
            total,
            data,
        };
    }
}