Team management

Here you will found all the necessary info about how to create the team, how to manage team creation and how to invite member an so on.

Teams are one of the main part of the application. Without it you won't be able to do nothing here. Let's start from backend.


Backend

Team

The main files responsible for team are lay in the Team module.

your_project/api/src/teams/ Team entity have @OneToMany relations with Team Membership entity, Businesses entity, Leads entity, Lead Status entity which means one team will have a lot of member, businesses and leads related to this team, and from 4 to 12 lead statuses. @MenyToOne with Subscription entity - all the teams where user is owner will have his one subscription.

Also team module have connection with Users Admin module.

In the entity file, where relations at, you can see that all of them have next code:

{ onDelete: 'CASCADE' }

This means that if you delete your team - all the data related to this team will be lost, so be careful with it.

Team service:

  • Create new team

  • Get all the info about team and it's members

  • Leave and delete team

  • Inviting new members

  • Accept or decline invitation

  • Check team params, such as team users limit (due to the subscription tier) or how many teams you can create

Let's talk about next methods. They are important.

Leave Team method. As you can guess it responsible for leaving team. But the main part of it is new owner. When you leaving the team, you must pass the ownership to other user, and this new owner must be an active user of your team, otherwise you won't be able to pass him/her ownership.

After the team receives new owner, this team also receives new owner's subscription with all of it's abilities, such as team users limit. Here you must understand that older users of the team will stay there, newer will be deactivated. You can activate them from team management page or support team can do it.

After all manipulations, will be removed the old owner's membership in this team. It means you will leave this team.

    async leaveTeam(user: JwtUserPayload, teamId: string, newOwnerId?: number) {
        const userMembership = await this.teamMembershipRepository.findOne({
            where: { user: { id: user.id }, team: { id: teamId } },
            withDeleted: true,
            relations: ['team', 'team.subscription'],
        });

        if (!userMembership) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: 'You are not a member of this team',
                    code: 'NOT_A_MEMBER',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        const team = await this.teamEntityRepository.findOne({
            where: { id: teamId },
            relations: ['memberships'],
        });

        if (!team) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: 'Team not found',
                    code: 'TEAM_NOT_FOUND',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        // we add check for membership id, in case archived user wants to leave the group
        if (team.memberships.length === 1 && team.memberships[0].id === userMembership.id) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: 'You are the last member',
                    code: 'LAST_MEMBER',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        if (newOwnerId) {
            const newOwner = await this.teamMembershipRepository.findOne({
                where: { id: newOwnerId },
                relations: ['user', 'user.subscription', 'team'],
            });

            if (!newOwner) {
                throw new HttpException(
                    {
                        message: 'Update failed',
                        description: 'Member not found',
                        code: 'MEMBER_NOT_FOUND',
                    },
                    HttpStatus.BAD_REQUEST
                );
            }

            if (newOwner.isDeleted || newOwner.user.status === AccountStatus.Pending) {
                throw new HttpException(
                    {
                        message: 'Error',
                        description: 'Archived or Pending status users cannot be new owners',
                        code: 'CANNOT_BE_NEW_OWNERS',
                    },
                    HttpStatus.BAD_REQUEST
                );
            }

            const isTeamsLimitReached = await this.checkTeamsLimit(newOwner.user);

            if (isTeamsLimitReached) {
                throw new HttpException(
                    {
                        message: 'Error',
                        description:
                            'User, whom you want to assign as a new owner, has reached the limit of teams and cannot be assigned as a new owner',
                        code: 'NEW_OWNER_TEAMS_LIMIT_REACHED',
                    },
                    HttpStatus.BAD_REQUEST
                );
            }
            // if new owner has subscription, assign it to the team, if not - assign free tier
            if (newOwner.user.subscription) {
                await this.subscriptionService.assignExistingSubscription(team.id, newOwner.user);
            } else {
                await this.subscriptionService.assignFreeTier(team.id, newOwner.user.id);
            }

            // checking if number of users doesn't exceed the limit within the new owner's subscription
            const { isLimitExceeded, exceedingMemberships } = await this.checkTeamUsersLimit(teamId);

            // if limit is reached, make newer members status to inactive (isDeleted = true)
            if (isLimitExceeded) {
                for (const member of exceedingMemberships) {
                    await this.teamMembershipRepository.softDelete(member.id);
                    await this.teamMembershipRepository.update({ id: member.id }, { isDeleted: true });
                }
            }

            // update new owner's role to Owner
            await this.teamMembershipService.update({ id: newOwnerId, role: TeamRole.Owner }, user);

            // send email to the new owner
            this.emailService.sendNewOwnerEmail(newOwner.user.email, team.name);
        }

        return this.teamMembershipRepository.delete(userMembership.id);
    }

Create Invitation method. The main thing is email what user receives. If user is already in the system, he/she will receive one email type. If user not in system - another type of email. Take a look.

    async createInvitation(body: InvitationDto, user: JwtUserPayload) {
        if (body.role === TeamRole.Owner) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: "You can't add owner to an existing team",
                    code: 'ADD_OWNER_TO_TEAM',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        const teamId = body.teamId || user.teamId;

        const team = await this.teamEntityRepository.findOne({
            where: { id: teamId },
            relations: ['memberships'],
        });

        const userMembership = await this.teamMembershipRepository.findOne({
            where: { team: { id: teamId }, user: { id: user.id } },
        });

        // we should check permission here, because when the user try to invite people
        // immedeatly after team creation he doesn't have team id and guard can't check permission
        if (!userMembership || userMembership.role === TeamRole.Viewer) {
            throw new ForbiddenException('Access denied');
        }

        const invitedUser = await this.userRepository.findOne({ where: { email: body.email }, withDeleted: true });

        if (!team) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: 'Team not found',
                    code: 'TEAM_NOT_FOUND',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        if (team.usersLimit <= team.memberships.length) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: 'Team is full',
                    code: 'TEAM_FULL',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        if (invitedUser) {
            const isMembershipValid = await this.teamMembershipService.validateMembership(teamId, invitedUser.id);

            if (isMembershipValid) {
                throw new HttpException(
                    {
                        message: 'Error',
                        description: 'User is already a member of this team',
                        code: 'USER_ALREADY_MEMBER',
                    },
                    HttpStatus.BAD_REQUEST
                );
            }

            await this.teamMembershipService.create(
                { id: invitedUser.id, firstName: body.firstName, lastName: body.lastName, phone: body.phone },
                teamId,
                body.role
            );

            this.emailService.sendExistedUserInvitationEmail(body.email, team.name);

            return {
                message: `Invitation email send`,
                description: 'Wait for invited person to finish his registration',
            };
        }

        const invite = await this.userService.createInvitedUser(body);

        await this.teamMembershipService.create(
            { id: invite.user.id, firstName: body.firstName, lastName: body.lastName, phone: body.phone },
            team.id,
            body.role
        );

        this.emailService.sendInvitationEmail(body.email, invite.token, team.name);

        return {
            message: `Invitation email send`,
            description: 'Wait for invited person to finish his registration',
        };
    }

About emails and how this service works, please read related block of modules.

Team membership

Above we mentioned Team Membership. This membership have it's own module as you understand. You can find it in the following path.

your_project/api/src/team-memberships/

Membership entity have @ManyToOne relations with User entity, Team entity, which means every member is a user, each member will be member with many teams. @OneToOne with Businesses entity - one team member will be responsible for one business.

Deleting a membership will mean you break ties with your team, but it doesn't mean deleting the user. These are different things.

N.B. In entity file you can find first- and lastName fields. You can find similar in User entity. This is two different fields! In membership entity it's mean first name and last name, other word this is the alias, of the member of the team.

    @Column({ default: '' })
    firstName!: string;

    @Column({ default: '' })
    lastName!: string;

Same thing with phone number. For each team, each member can have their own, unique phone number.

    @Column({ nullable: true })
    phone!: string;

Why they are null or empty by default?

It's because we can invite new members from two different places. In one place, team creation page, we can enter only email and then the new team member will enter the information they want the other members to see in the team. In another place, from team management page, team owner will type all the info and chose the role of the member.

Next field will show if is person accepted the invitation or not. Every non new user of the app will see this status in Profile page and will also receive an invitation letter by email. There this can be changed - accept or decline invitation.

    @Column({ type: 'boolean', default: false })
    invitationAccepted!: boolean;

What this module actually do?

As you can guess, this module responsible for members of the team. After you create one you will be able to invite member to it. From this point, you will start your work in this app.

Each member will have it's own field of responsibilities and abilities.

About roles and their permissions, please read related block of modules

Team memberships service:

  • Memberships CRUD (many methods for different tasks)

  • Batch archiving and activating

  • Canceling subscription (from membership prospective, not actually canceling it)

Just take a look what's goin on there.


Frontend

Team page and components:

your_project/ui/src/pages/CreateTeam your_project/ui/src/components/teams

Team management page and components:

your_project/ui/src/pages/Team your_project/ui/src/component/team-management

Team or Team Creation

Team creation process is built using AntD Tabs component and used in two different places. When we in login process and when we already logged in.

In the main page file you will find an array of items:

const items = [
        {
            key: '0',
            children: <CreateTeamMain setActiveTabKey={setActiveTabKey} t={t} currentUser={currentUser} />,
        },
        {
            key: '1',
            children: (
                <CreateTeamForm
                    activeTabKey={activeTabKey}
                    setActiveTabKey={setActiveTabKey}
                    t={t}
                    currentUser={currentUser}
                />
            ),
        },
        {
            key: '2',
            children: (
                <Invitation
                    activeTabKey={activeTabKey}
                    setActiveTabKey={setActiveTabKey}
                    t={t}
                    currentUser={currentUser}
                />
            ),
        },
        {
            key: '3',
            children: (
                <UpgradePlan
                    activeTabKey={activeTabKey}
                    setActiveTabKey={setActiveTabKey}
                    t={t}
                    currentUser={currentUser}
                />
            ),
        },
    ];

Each item of the tabs is the some sort of "page".

Why tabs? This were made, because we have some steps of team creation, that we must go through, with invitation and even subscription change on demand.

Also we use local storage here to store and to control some processes here.

const isSubscriptionsChanged = localStorage.getItem('isSubscriptionChanged');

For example, if we changed the subscription to invite more members, this information from local storage will allow us to go back to the tab where we invite members and to save those emails in that form.

Here is the state that controls tab change:

const [activeTabKey, setActiveTabKey] = useState(isSubscriptionsChanged ? '3' : '0');

Few word about components.

When we try to sign-in, we will find JoinTeam component. It will allow us to login with certain team.

InvitationForm component. Here we will invite new members. The main part of it is AntD Input.TextArea component. Here we can type multiple emails and all of them will receive letter with invitation to our team.

Special mention to the validation function. Take a closer look:

const validator = async (_, value) => {
        if (!hasInteracted) {
            return Promise.resolve();
        }

        if (value && value.trim()) {
            const emails = value.split(/[\s,]+/);

            if (emails.some(email => !email.match(regexPatterns.email))) {
                return Promise.reject(`${emails.length > 1 ? t('form.email-plural') : t('form.email-singular')}`);
            }
            if (emails.some((email, index) => emails.indexOf(email) !== index)) {
                return Promise.reject(t('form.emails-unique'));
            }
        } else {
            return Promise.reject(t('form.email-required'));
        }

        return Promise.resolve();
    };

And the navigation function, which is not only help us navigate but cleares out the local storage:

    const handleNavigate = async () => {
        await changeTeam(localStorage.getItem('teamId'));
        navigate('/team');
        // clearing local storage
        localStorage.removeItem('emails');
        localStorage.removeItem('teamId');
        localStorage.removeItem('isSubscriptionChanged');
    };

Submit will check emails number before sending invitations.

    const handleFormSubmit = async () => {
        const emails = form.getFieldValue('emails').split(/[\s,]+/);

        if (usersSubscription?.usersLimit && emails.length >= usersSubscription.usersLimit) {
            upgradeModalHandler();
            return;
        }

        try {
            setLoading(true);
            for (const email of emails) {
                await sendInvitation({ email, teamId: localStorage.getItem('teamId') || currentUser.teamId });
            }
            globalNotification.open({
                type: notificationType.SUCCESS,
                message: t('general:success'),
                description: t('notification.inv-success'),
            });

            handleNavigate();
        } catch (error) {
            const errorCode = error.response.data?.code;
            globalNotification.open({
                type: notificationType.ERROR,
                message: t(`apiResponses:${errorCode}.message`),
                description: t(`apiResponses:${errorCode}.description`),
            });
        } finally {
            setLoading(false);
        }
    };

N.B. Please notice where local storage is used!

Another place from where we can get here and go through this steps is TeamSelector component of the SideMenu.

Team Management

From Team component, that responsible for team management page, we can find the information about out team members and change their personal information that used within team: alias, phone number, email etc. Also here we can invite new members and cancel the invitation.

In the page file you will find almost the same filters, sorters and search that we can find on the leads page.

N.B. Please notice the status of the members. After invitation was sent, member will have status "Pending". It will change to "Active" or "Inactive," indicating whether the person is currently logged in with this team or not.

You will be able to set team role when inviting one. This role is crucial on what member can do and what cannot within your team. Roles have different permissions. How they work, please read related block of module.

Last updated