From 540a0434356ec2c52c1dcbf398312aed1a3328e2 Mon Sep 17 00:00:00 2001 From: Tran Minh Tri Date: Wed, 30 Dec 2020 18:37:09 +0700 Subject: [PATCH 1/2] invite member --- api/graphql/resolvers/team.resolver.js | 9 ++- api/graphql/schemas/team.schema.js | 12 +++- api/graphql/schemas/user.schema.js | 1 + api/migrations/20201130225822_create_teams.js | 7 ++- api/repository/team_invitations.repository.js | 40 ++++++++++++-- api/repository/team_members.repository.js | 13 +++-- .../get-user-logined.service.js | 3 + api/services/teams/acceptInvitation.js | 8 +++ api/services/teams/teams.service.js | 16 +++--- .../teams/verifyInvitationToken.service.js | 10 ++++ app/.eslintrc.js | 2 +- app/src/constants/index.js | 5 -- app/src/containers/Auth/SignIn.jsx | 3 + app/src/containers/Layout/Admin.jsx | 5 ++ app/src/containers/Teams/AcceptInvitation.jsx | 55 ++++++++++++++----- .../Teams/EditTeam/InviteMember.jsx | 10 ++-- app/src/containers/Teams/EditTeam/index.jsx | 2 +- app/src/containers/Teams/NewTeam.jsx | 12 ++-- app/src/queries/auth/getProfile.js | 1 + app/src/queries/teams/inviteMember.js | 8 ++- app/src/queries/teams/joinTeam.js | 7 +++ app/src/queries/teams/verifyInviteToken.js | 10 ++++ 22 files changed, 183 insertions(+), 56 deletions(-) create mode 100644 api/services/teams/acceptInvitation.js create mode 100644 api/services/teams/verifyInvitationToken.service.js create mode 100644 app/src/queries/teams/joinTeam.js create mode 100644 app/src/queries/teams/verifyInviteToken.js diff --git a/api/graphql/resolvers/team.resolver.js b/api/graphql/resolvers/team.resolver.js index ba22cb45..6e649005 100644 --- a/api/graphql/resolvers/team.resolver.js +++ b/api/graphql/resolvers/team.resolver.js @@ -1,5 +1,7 @@ import { combineResolvers } from 'graphql-resolvers'; import { getAllTeams, findTeamByAlias, createTeam, inviteTeamMember } from '~/services/teams/teams.service'; +import { verifyInvitationToken } from '~/services/teams/verifyInvitationToken.service'; +import { acceptInvitation } from '~/services/teams/acceptInvitation'; import { isAuthenticated } from './authorization.resolver'; const resolvers = { @@ -10,8 +12,9 @@ const resolvers = { ), getTeamDetail: combineResolvers( isAuthenticated, - (_, { alias }, { user }) => findTeamByAlias(alias), + (_, { alias }) => findTeamByAlias(alias), ), + verifyInvitationToken: (_, { invitationToken }) => verifyInvitationToken(invitationToken), }, Mutation: { createTeam: combineResolvers( @@ -22,6 +25,10 @@ const resolvers = { isAuthenticated, (_, { email, alias }, { user }) => inviteTeamMember(user, alias, email), ), + joinTeam: combineResolvers( + isAuthenticated, + (_, { token }, { user }) => acceptInvitation(token, user), + ), }, }; diff --git a/api/graphql/schemas/team.schema.js b/api/graphql/schemas/team.schema.js index 97e97311..169d71a0 100644 --- a/api/graphql/schemas/team.schema.js +++ b/api/graphql/schemas/team.schema.js @@ -4,6 +4,7 @@ export const TeamSchema = gql` enum TeamMemberType { active inactive + pending } type Team { @@ -20,13 +21,20 @@ export const TeamSchema = gql` status: TeamMemberType } + type VerifyTokenResponse{ + teamName: String! + owner: String! + } + extend type Query { - teams: [Team], + teams: [Team] getTeamDetail(alias: String!): [TeamMember] + verifyInvitationToken(invitationToken: String!): VerifyTokenResponse! } extend type Mutation { createTeam(name: String!, alias: String!): Team, - inviteMember(email: String!, alias: String!): Boolean + inviteMember(email: String!, alias: String!): TeamMember + joinTeam(token: String!): Boolean! } `; diff --git a/api/graphql/schemas/user.schema.js b/api/graphql/schemas/user.schema.js index bfecf256..a5bfa407 100644 --- a/api/graphql/schemas/user.schema.js +++ b/api/graphql/schemas/user.schema.js @@ -23,6 +23,7 @@ export const UserSchema = gql` name: String! isActive: Boolean! avatarUrl: String + invitationToken: String } type ResponseUserLogin { diff --git a/api/migrations/20201130225822_create_teams.js b/api/migrations/20201130225822_create_teams.js index 4d1b59a5..ef008fd9 100644 --- a/api/migrations/20201130225822_create_teams.js +++ b/api/migrations/20201130225822_create_teams.js @@ -16,18 +16,19 @@ export function up(knex) { }), knex.schema.createTable('team_invitations', (table) => { table.string('email').notNullable(); - table.integer('team_id').unsigned().notNullable(); + table.integer('team_id').unsigned(); table.foreign('team_id').references('id').inTable('teams'); table.unique(['email', 'team_id']); table.unique('token'); table.string('token').notNullable(); table.enu('status', ['active', 'inactive']).notNullable(); - table.dateTime('send_at') - .notNullable(); table.dateTime('valid_until') .notNullable(); table.integer('invited_by').unsigned().notNullable(); table.foreign('invited_by').references('id').inTable('users'); + table.dateTime('created_at') + .notNullable() + .defaultTo(knex.raw('CURRENT_TIMESTAMP')); }), knex.schema.createTable('team_members', (table) => { table.integer('user_id').unsigned().notNullable(); diff --git a/api/repository/team_invitations.repository.js b/api/repository/team_invitations.repository.js index b292595c..b2bf2eb1 100644 --- a/api/repository/team_invitations.repository.js +++ b/api/repository/team_invitations.repository.js @@ -1,17 +1,19 @@ import database from '~/config/database.config'; import { TABLES } from '~/constants/database.constant'; +import { teamsColumns } from './team.repository'; +import { usersColumns } from './user.repository'; const TABLE = TABLES.teamInvitations; export const VALID_PERIOD_DAYS = 14; -export const teamsColumns = { +export const teamInvitationsColumns = { email: 'team_invitations.email', teamId: 'team_invitations.team_id', token: 'team_invitations.token', - sendAt: 'teams.send_at', - validUntil: 'teams.valid_until', - invitedBy: 'teams.invited_by', + validUntil: 'team_invitations.valid_until', + invitedBy: 'team_invitations.invited_by', + status: 'team_invitations.status', }; /** @@ -28,6 +30,10 @@ export async function createTeamInvitation(data, transaction = null) { return query.transacting(transaction); } +export async function getTeamInvitation(condition) { + return database(TABLE).where(condition); +} + /** * Function to get team invitation by email, teamId and inviteUserId. * @@ -35,6 +41,28 @@ export async function createTeamInvitation(data, transaction = null) { * @param int teamId Id of team related to this invitation * @param int inviteUserId Id of user who create the invitation */ -export async function getTeamInvitation(email, teamId, inviteUserId) { - return database(TABLE).where({ email, team_id: teamId, invited_by: inviteUserId }).first(); +export async function getDetailTeamInvitation(token) { + return database(TABLE) + .join(TABLES.teams, function joinOn() { + this.on(teamsColumns.id, '=', teamInvitationsColumns.teamId); + }) + .join(TABLES.users, usersColumns.id, teamInvitationsColumns.invitedBy) + .where({ [teamInvitationsColumns.token]: token }) + .select({ + owner: usersColumns.email, + teamName: teamsColumns.name, + until: teamInvitationsColumns.validUntil, + status: teamInvitationsColumns.status, + }); } + +export async function updateTeamInvitationByToken(token, data) { + return database(TABLE).where({ token }).update(data); +} +// select * from team_invitations +// inner join teams +// on teams.id = team_invitations.team_id +// inner join users on users.id = team_invitations.invited_by +// where team_invitations.token = '3235f68696b46196c00e5aa359273c909613b3514ce9fe819f594f0033421a6c' + +// AND(team_invitations.token = '3235f68696b46196c00e5aa359273c909613b3514ce9fe819f594f0033421a6c') \ No newline at end of file diff --git a/api/repository/team_members.repository.js b/api/repository/team_members.repository.js index 563c18ae..bc8fbed2 100644 --- a/api/repository/team_members.repository.js +++ b/api/repository/team_members.repository.js @@ -5,7 +5,7 @@ import database from '~/config/database.config'; import { TABLES } from '~/constants/database.constant'; import { teamsColumns } from './team.repository'; import { usersColumns } from './user.repository'; -import { createTeamInvitation, VALID_PERIOD_DAYS } from "./team_invitations.repository"; +import { createTeamInvitation, VALID_PERIOD_DAYS } from './team_invitations.repository'; const TABLE = TABLES.teamMembers; @@ -46,20 +46,23 @@ export async function createMemberAndInviteToken({ userId, teamId, memberId, ema let transaction; try { transaction = await database.transaction(); - await createTeamMember({ user_id: memberId, team_id: teamId, status: 'pending' }, transaction); await createTeamInvitation({ email, invited_by: userId, team_id: teamId, - send_at: formatDateDB(), valid_until: formatDateDB(dayjs().add(VALID_PERIOD_DAYS, 'days')), status: 'active', token, }, transaction); - + await createTeamMember({ user_id: memberId, team_id: teamId, status: 'pending', invitation_token: token }, transaction); + await transaction.commit(); return true; } catch (error) { transaction.rollback(); - return new Error(error); + throw new Error(error); } } + +export async function updateTeamMember(condition, data) { + return database(TABLE).where(condition).update(data); +} diff --git a/api/services/authentication/get-user-logined.service.js b/api/services/authentication/get-user-logined.service.js index 53e442ab..a1c89cde 100644 --- a/api/services/authentication/get-user-logined.service.js +++ b/api/services/authentication/get-user-logined.service.js @@ -1,6 +1,7 @@ import { AuthenticationError } from 'apollo-server-express'; import { verify } from '~/helpers/jwt.helper'; import { findUser } from '~/repository/user.repository'; +import { getTeamInvitation } from "~/repository/team_invitations.repository"; export default async function getUserLogined(bearerToken) { if (bearerToken) { @@ -11,12 +12,14 @@ export default async function getUserLogined(bearerToken) { } const { user } = verify(token[1]); const userInfo = await findUser({ email: user.email }); + const [invitationToken] = await getTeamInvitation({ email: user.email, status: 'active' }); return { id: userInfo.id, email: userInfo.email, name: userInfo.name, isActive: userInfo.is_active, avatarUrl: userInfo.avatar_url, + invitationToken: invitationToken ? invitationToken.token : null, }; } catch (error) { throw new AuthenticationError('Authentication failure'); diff --git a/api/services/teams/acceptInvitation.js b/api/services/teams/acceptInvitation.js new file mode 100644 index 00000000..7eae42c9 --- /dev/null +++ b/api/services/teams/acceptInvitation.js @@ -0,0 +1,8 @@ +import { updateTeamInvitationByToken } from '../../repository/team_invitations.repository'; +import { updateTeamMember } from '../../repository/team_members.repository'; + +export async function acceptInvitation(token) { + await updateTeamInvitationByToken(token, { status: 'inactive' }); + await updateTeamMember({ invitation_token: token }, { status: 'active' }); + return true; +} diff --git a/api/services/teams/teams.service.js b/api/services/teams/teams.service.js index 66c05c6e..adda1611 100644 --- a/api/services/teams/teams.service.js +++ b/api/services/teams/teams.service.js @@ -96,7 +96,7 @@ export async function inviteTeamMember(user, alias, inviteeEmail) { fileName: 'inviteTeamMember.mjml', data: { teamName: team.name, - url: `${process.env.FRONTEND_URL}/team/join?token=${token}`, + url: `${process.env.FRONTEND_URL}/teams/invitation/{token}`, }, }); @@ -115,18 +115,20 @@ export async function inviteTeamMember(user, alias, inviteeEmail) { token, invited_by: user.id, team_id: team.id, - send_at: formatDateDB(), valid_until: formatDateDB(dayjs().add(VALID_PERIOD_DAYS, 'days')), status: 'active', })); } - // await sendMail(normalizeEmail(inviteeEmail), subject, template); - - // // TODO: Hardcode here created_by is 1, later need to take from user when implement frontend await Promise.all(queries); - return true; + return { + userName: member.name, + userId: member.id, + email: member.email, + status: 'pending', + isOwner: false, + }; } catch (error) { logger.error(error); - throw error; + throw new ApolloError('Something went wrong!'); } } diff --git a/api/services/teams/verifyInvitationToken.service.js b/api/services/teams/verifyInvitationToken.service.js new file mode 100644 index 00000000..9b99aa7a --- /dev/null +++ b/api/services/teams/verifyInvitationToken.service.js @@ -0,0 +1,10 @@ +import { ApolloError } from 'apollo-server-express'; +import { getDetailTeamInvitation } from '~/repository/team_invitations.repository'; + +export async function verifyInvitationToken(token) { + const [teamInvitation] = await getDetailTeamInvitation(token); + if (!teamInvitation || teamInvitation.status === 'inactive') { + throw new ApolloError('Token not valid'); + } + return teamInvitation; +} diff --git a/app/.eslintrc.js b/app/.eslintrc.js index 6ad98a35..4cbfd517 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -62,7 +62,7 @@ module.exports = { 'newline-per-chained-call': 0, 'no-confusing-arrow': 0, 'no-console': process.env.NODE_ENV === 'production' ? 1 : 0, - 'no-unused-vars': 2, + 'no-unused-vars': process.env.NODE_ENV === 'production' ? 1 : 0, 'no-param-reassign': 0, 'no-use-before-define': 0, 'prefer-template': 2, diff --git a/app/src/constants/index.js b/app/src/constants/index.js index c9f264a2..b13fe2da 100644 --- a/app/src/constants/index.js +++ b/app/src/constants/index.js @@ -1,6 +1 @@ export const JWT_STORAGE_KEY = 'tokenUser'; - -export const LIST_STEP_IN_TEAM = { - LIST_TEAMS: 'LIST_TEAMS', - CREATE_TEAM: 'CREATE_TEAM', -}; diff --git a/app/src/containers/Auth/SignIn.jsx b/app/src/containers/Auth/SignIn.jsx index ff2c6b88..51a96d73 100644 --- a/app/src/containers/Auth/SignIn.jsx +++ b/app/src/containers/Auth/SignIn.jsx @@ -9,6 +9,7 @@ import styled from 'styled-components'; import SignInForm from '@/components/Auth/SignInForm'; import { JWT_STORAGE_KEY } from '@/constants'; import loginQuery from '@/queries/auth/login'; +import getQueryParam from "@/utils/getQueryParam"; import FaceBookSvg from '@/assets/images/svg/facebook.svg'; import GoogleSvg from '@/assets/images/svg/google.svg'; @@ -58,6 +59,8 @@ function SignIn() { }); const [loginMutation, { error, loading }] = useMutation(loginQuery); const history = useHistory(); + const query = getQueryParam(); + const invitationToken = query.get('invitation'); const socialButton = useMemo( () => [ diff --git a/app/src/containers/Layout/Admin.jsx b/app/src/containers/Layout/Admin.jsx index 5fe67f45..d8131a47 100644 --- a/app/src/containers/Layout/Admin.jsx +++ b/app/src/containers/Layout/Admin.jsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import { useQuery } from '@apollo/react-hooks'; import { useDispatch } from 'react-redux'; +import { useHistory } from "react-router-dom"; import { JWT_STORAGE_KEY } from '@/constants'; import AdminLayout from '@/components/Layout/Admin'; @@ -13,8 +14,12 @@ function AdminLayoutContainer() { const { data, loading } = useQuery(getProfileQuery); const { data: userPlanData, loading: loadingUserPlan } = useQuery(getUserPlanQuery); const dispatch = useDispatch(); + const history = useHistory() useEffect(() => { + if (data?.profileUser?.invitationToken) { + history.push(`/teams/invitation/${data?.profileUser?.invitationToken}`) + } dispatch(setProfileUser({ data: data?.profileUser, loading })); }, [data, loading]); diff --git a/app/src/containers/Teams/AcceptInvitation.jsx b/app/src/containers/Teams/AcceptInvitation.jsx index fe1b5fda..e4992dc4 100644 --- a/app/src/containers/Teams/AcceptInvitation.jsx +++ b/app/src/containers/Teams/AcceptInvitation.jsx @@ -1,7 +1,11 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams, useHistory } from "react-router-dom"; -// import PropTypes from 'prop-types'; +import { useQuery, useLazyQuery, useMutation } from "@apollo/react-hooks"; +import verifyTokenQuery from "@/queries/teams/verifyInviteToken"; +import getProfileQuery from "@/queries/auth/getProfile"; +import joinTeamQuery from "@/queries/teams/joinTeam"; import logo from '@/assets/images/logo.png'; +import { JWT_STORAGE_KEY } from '@/constants'; AcceptInvitation.propTypes = { @@ -9,35 +13,58 @@ AcceptInvitation.propTypes = { function AcceptInvitation() { const { invitationToken } = useParams() + const [teamInfo, setTeamInfo] = useState(null) const history = useHistory() + const [verify, { data, loading, error }] = useLazyQuery(verifyTokenQuery) + const { data: userInfo, error: getProfileError, loading: getProfileLoading } = useQuery(getProfileQuery) + const [joinTeam] = useMutation(joinTeamQuery) useEffect(() => { - if (!invitationToken) { - // history.push('/') - console.log('history :>> ', history); + if (!getProfileLoading && userInfo?.profileUser) { + if (userInfo?.profileUser) verify({ variables: { invitationToken } }) + else history.replace(`/auth/signin?invitation=${invitationToken}`) } + }, [getProfileError, userInfo]) - }, [invitationToken]) + useEffect(() => { + if (!loading && data?.verifyInvitationToken) { + setTeamInfo(data.verifyInvitationToken) + } + if (error) { + history.push('/auth/signin') + } + }, [data, error, loading]) + + + async function handleUserJoinTeam() { + try { + await joinTeam({ variables: { token: invitationToken } }) + history.replace('/') + } catch (e) { + console.error(e) + } + } - return ( -
+ return loading && getProfileLoading ? +
Loading ....
+ :
JSlancer
Accept Invitation?
- You' ve been invitated to join .... by David@jslancer.com + You' ve been invitated to join {teamInfo?.teamName} by {teamInfo?.owner}
- - Create Account to Accept - + Accept +
- ); } export default AcceptInvitation; \ No newline at end of file diff --git a/app/src/containers/Teams/EditTeam/InviteMember.jsx b/app/src/containers/Teams/EditTeam/InviteMember.jsx index cc2b9be1..bbcc7d67 100644 --- a/app/src/containers/Teams/EditTeam/InviteMember.jsx +++ b/app/src/containers/Teams/EditTeam/InviteMember.jsx @@ -35,16 +35,16 @@ function InviteMember({ teamMembers, alias }) { async function onSubmit({ emailMember }) { try { - await InviteMemberMutation({ + const { data } = await InviteMemberMutation({ variables: { email: emailMember, alias } }) - dispatch(addTeamMember({ teamID: alias, data: [{ userName: '', email: emailMember, status: 'pending', isOwner: false }] })) - console.log(error) - console.log(loading) - + if (data?.inviteMember) { + const member = data.inviteMember + dispatch(addTeamMember({ teamID: alias, data: [member] })) + } } catch (e) { console.log(e) } diff --git a/app/src/containers/Teams/EditTeam/index.jsx b/app/src/containers/Teams/EditTeam/index.jsx index ab9f3e22..81569d01 100644 --- a/app/src/containers/Teams/EditTeam/index.jsx +++ b/app/src/containers/Teams/EditTeam/index.jsx @@ -38,6 +38,6 @@ export default function EditTeam() {
Team Members
it.status !== 'pending')} />
- it.isActive === 'pending')} /> + it.status === 'pending')} /> : null } \ No newline at end of file diff --git a/app/src/containers/Teams/NewTeam.jsx b/app/src/containers/Teams/NewTeam.jsx index 207ce82a..4e90149f 100644 --- a/app/src/containers/Teams/NewTeam.jsx +++ b/app/src/containers/Teams/NewTeam.jsx @@ -28,10 +28,12 @@ export default function NewTeam() { const { data, errors } = await createTeamMutation({ variables: { name: teamName, alias: teamID } }) - const { id, name, alias } = data.createTeam - dispatch(addNew({ data: { id, teamName: name, teamID: alias } })) - if (!errors) { - history.replace('/teams') + if (data?.createTeam) { + const { id, name, alias } = data.createTeam + dispatch(addNew({ data: { id, teamName: name, teamID: alias } })) + if (!errors) { + history.replace('/teams') + } } } @@ -44,4 +46,4 @@ export default function NewTeam() { )} ) -} +} \ No newline at end of file diff --git a/app/src/queries/auth/getProfile.js b/app/src/queries/auth/getProfile.js index 9255e73d..51141204 100644 --- a/app/src/queries/auth/getProfile.js +++ b/app/src/queries/auth/getProfile.js @@ -8,6 +8,7 @@ export default gql` name isActive avatarUrl + invitationToken } } `; diff --git a/app/src/queries/teams/inviteMember.js b/app/src/queries/teams/inviteMember.js index c94836a6..102f1265 100644 --- a/app/src/queries/teams/inviteMember.js +++ b/app/src/queries/teams/inviteMember.js @@ -2,6 +2,12 @@ import { gql } from 'graphql.macro'; export default gql` mutation InviteMember($email: String!, $alias: String!){ - inviteMember(email: $email, alias: $alias) + inviteMember(email: $email, alias: $alias){ + userName + userId + email + isOwner + status + } } ` \ No newline at end of file diff --git a/app/src/queries/teams/joinTeam.js b/app/src/queries/teams/joinTeam.js new file mode 100644 index 00000000..4bcd58b6 --- /dev/null +++ b/app/src/queries/teams/joinTeam.js @@ -0,0 +1,7 @@ +import { gql } from 'graphql.macro'; + +export default gql` + mutation InviteMember($token: String!){ + joinTeam(token: $token) + } +` \ No newline at end of file diff --git a/app/src/queries/teams/verifyInviteToken.js b/app/src/queries/teams/verifyInviteToken.js new file mode 100644 index 00000000..90d03ff3 --- /dev/null +++ b/app/src/queries/teams/verifyInviteToken.js @@ -0,0 +1,10 @@ +import { gql } from 'graphql.macro'; + +export default gql` + query VerifyInvitationToken($invitationToken: String! ) { + verifyInvitationToken(invitationToken: $invitationToken) { + teamName + owner + } + } +`; From fff58cd07641947d477bb3e9fac2f0d49549efa1 Mon Sep 17 00:00:00 2001 From: Tran Minh Tri Date: Mon, 4 Jan 2021 18:39:00 +0700 Subject: [PATCH 2/2] fix feature invite member --- api/graphql/resolvers/team.resolver.js | 5 ++ api/graphql/schemas/team.schema.js | 2 + api/migrations/20201130225822_create_teams.js | 4 +- api/repository/team_invitations.repository.js | 8 ++- api/repository/team_members.repository.js | 42 ++++++++++++++-- api/repository/user.repository.js | 14 ++++++ api/services/teams/acceptInvitation.js | 4 +- api/services/teams/cancelInvitation.js | 25 ++++++++++ api/services/teams/teams.service.js | 50 ++++++++++++++----- app/src/components/Team/InviteMemberForm.jsx | 8 ++- app/src/components/Team/ListTeamMember.jsx | 5 +- .../Teams/EditTeam/InviteMember.jsx | 13 +++-- app/src/features/admin/team.js | 9 +++- app/src/queries/teams/cancelInvitation.js | 7 +++ app/src/queries/teams/getTeamDetail.js | 1 + app/src/queries/teams/joinTeam.js | 2 +- 16 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 api/services/teams/cancelInvitation.js create mode 100644 app/src/queries/teams/cancelInvitation.js diff --git a/api/graphql/resolvers/team.resolver.js b/api/graphql/resolvers/team.resolver.js index 6e649005..c8ee673e 100644 --- a/api/graphql/resolvers/team.resolver.js +++ b/api/graphql/resolvers/team.resolver.js @@ -3,6 +3,7 @@ import { getAllTeams, findTeamByAlias, createTeam, inviteTeamMember } from '~/se import { verifyInvitationToken } from '~/services/teams/verifyInvitationToken.service'; import { acceptInvitation } from '~/services/teams/acceptInvitation'; import { isAuthenticated } from './authorization.resolver'; +import { cancelInvitation } from '../../services/teams/cancelInvitation'; const resolvers = { Query: { @@ -29,6 +30,10 @@ const resolvers = { isAuthenticated, (_, { token }, { user }) => acceptInvitation(token, user), ), + cancelInvitation: combineResolvers( + isAuthenticated, + (_, { userId, teamId }) => cancelInvitation(userId, teamId), + ), }, }; diff --git a/api/graphql/schemas/team.schema.js b/api/graphql/schemas/team.schema.js index b4d54e0a..cc4e4479 100644 --- a/api/graphql/schemas/team.schema.js +++ b/api/graphql/schemas/team.schema.js @@ -25,6 +25,7 @@ export const TeamSchema = gql` email: String isOwner: Boolean status: TeamMemberType + teamId: ID } type VerifyTokenResponse{ @@ -42,5 +43,6 @@ export const TeamSchema = gql` createTeam(name: String!, alias: String!): Team, inviteMember(email: String!, alias: String!): TeamMember joinTeam(type: JoinTeamType!, token: String!): Boolean! + cancelInvitation(userId: String!, teamId: String!): Boolean! } `; diff --git a/api/migrations/20201130225822_create_teams.js b/api/migrations/20201130225822_create_teams.js index 471ca05b..12f6df49 100644 --- a/api/migrations/20201130225822_create_teams.js +++ b/api/migrations/20201130225822_create_teams.js @@ -15,10 +15,10 @@ export function up(knex) { table.integer('created_by').unsigned().notNullable(); }), knex.schema.createTable('team_invitations', (table) => { + table.increments('id'); table.string('email').notNullable(); table.integer('team_id').unsigned(); table.foreign('team_id').references('id').inTable('teams'); - table.unique(['email', 'team_id']); table.unique('token'); table.string('token').notNullable(); table.enu('status', ['active', 'inactive']).notNullable(); @@ -31,11 +31,11 @@ export function up(knex) { .defaultTo(knex.raw('CURRENT_TIMESTAMP')); }), knex.schema.createTable('team_members', (table) => { + table.increments('id'); table.integer('user_id').unsigned().notNullable(); table.integer('team_id').unsigned().notNullable(); table.foreign('user_id').references('id').inTable('users'); table.foreign('team_id').references('id').inTable('teams'); - table.unique(['user_id', 'team_id']); table.enu('status', ['pending', 'active', 'inactive', 'decline']).notNullable(); table.dateTime('created_at') .notNullable() diff --git a/api/repository/team_invitations.repository.js b/api/repository/team_invitations.repository.js index b2bf2eb1..2df460d3 100644 --- a/api/repository/team_invitations.repository.js +++ b/api/repository/team_invitations.repository.js @@ -56,8 +56,12 @@ export async function getDetailTeamInvitation(token) { }); } -export async function updateTeamInvitationByToken(token, data) { - return database(TABLE).where({ token }).update(data); +export async function updateTeamInvitation(condition, data, transaction = null) { + const query = database(TABLE).where(condition).update(data); + if (!transaction) { + return query; + } + return query.transacting(transaction); } // select * from team_invitations // inner join teams diff --git a/api/repository/team_members.repository.js b/api/repository/team_members.repository.js index bc8fbed2..809342ec 100644 --- a/api/repository/team_members.repository.js +++ b/api/repository/team_members.repository.js @@ -5,7 +5,7 @@ import database from '~/config/database.config'; import { TABLES } from '~/constants/database.constant'; import { teamsColumns } from './team.repository'; import { usersColumns } from './user.repository'; -import { createTeamInvitation, VALID_PERIOD_DAYS } from './team_invitations.repository'; +import { createTeamInvitation, updateTeamInvitation, VALID_PERIOD_DAYS } from './team_invitations.repository'; const TABLE = TABLES.teamMembers; @@ -25,8 +25,10 @@ export async function getListTeamMemberByAliasTeam({ alias }) { .whereIn(teamMembersColumns.teamId, function subQuery() { this.select('id').from(TABLES.teams).where({ alias }); }).join(TABLES.users, teamMembersColumns.userId, usersColumns.id) + .whereNot({ [teamMembersColumns.status]: 'decline' }) .select({ userName: usersColumns.name, + teamId: teamMembersColumns.teamId, userId: usersColumns.id, email: usersColumns.email, status: teamMembersColumns.status, @@ -63,6 +65,40 @@ export async function createMemberAndInviteToken({ userId, teamId, memberId, ema } } -export async function updateTeamMember(condition, data) { - return database(TABLE).where(condition).update(data); +export async function updateTeamMember(condition, data, transaction = null) { + const query = database(TABLE).where(condition).update(data); + if (!transaction) { + return query; + } + return query.transacting(transaction); +} + +export async function reInviteMemberHasDecline({ teamId, memberId, email, token, userId }) { + let transaction; + const queries = []; + try { + transaction = await database.transaction(); + + queries.push(updateTeamMember({ + team_id: teamId, + user_id: memberId, + }, { status: 'pending' }, transaction)); + + queries.push(updateTeamInvitation({ email }, { status: 'inactive' }, transaction)); + + queries.push(createTeamInvitation({ + email, + token, + invited_by: userId, + team_id: teamId, + valid_until: formatDateDB(dayjs().add(VALID_PERIOD_DAYS, 'days')), + status: 'active', + }, transaction)); + + await Promise.all(queries); + await transaction.commit(); + } catch (error) { + transaction.rollback(); + throw new Error(error); + } } diff --git a/api/repository/user.repository.js b/api/repository/user.repository.js index 449d0c03..2f5b37e4 100644 --- a/api/repository/user.repository.js +++ b/api/repository/user.repository.js @@ -5,6 +5,7 @@ import { TABLES } from '~/constants/database.constant'; import { insertUserPlan } from './user_plans.repository'; import { insertMultiPermission } from './user_permission.repository'; import { PERMISSION_PLAN } from '~/constants/billing.constant'; +import { teamMembersColumns } from './team_members.repository'; const TABLE = TABLES.users; @@ -82,3 +83,16 @@ export async function getUserByIdAndJoinUserToken(id, type) { export async function activeUser(id) { return database(TABLE).where({ id }).update({ is_active: true }); } + +export async function getUserAndTeamInfo({ email }) { + return database(TABLE) + .join(TABLES.teamMembers, usersColumns.id, teamMembersColumns.userId) + .where({ [usersColumns.email]: email }) + .select({ + id: usersColumns.id, + email: usersColumns.email, + name: usersColumns.name, + teamStatus: teamMembersColumns.status, + }) + .first(); +} diff --git a/api/services/teams/acceptInvitation.js b/api/services/teams/acceptInvitation.js index 688f6779..7d4d1fbc 100644 --- a/api/services/teams/acceptInvitation.js +++ b/api/services/teams/acceptInvitation.js @@ -1,9 +1,9 @@ -import { updateTeamInvitationByToken } from '../../repository/team_invitations.repository'; +import { updateTeamInvitation } from '../../repository/team_invitations.repository'; import { updateTeamMember } from '../../repository/team_members.repository'; export async function acceptInvitation(token, type) { const status = type === 'accept' ? 'active' : 'decline'; - await updateTeamInvitationByToken(token, { status: 'inactive' }); + await updateTeamInvitation({ token }, { status: 'inactive' }); await updateTeamMember({ invitation_token: token }, { status }); return true; } diff --git a/api/services/teams/cancelInvitation.js b/api/services/teams/cancelInvitation.js new file mode 100644 index 00000000..8e1ffc49 --- /dev/null +++ b/api/services/teams/cancelInvitation.js @@ -0,0 +1,25 @@ +import { updateTeamInvitation } from "../../repository/team_invitations.repository"; +import { updateTeamMember } from "../../repository/team_members.repository"; +import { findUser } from "../../repository/user.repository"; +import database from '~/config/database.config'; + +export async function cancelInvitation(userId, teamId) { + let transaction; + try { + transaction = await database.transaction(); + const user = await findUser({ id: userId }); + const queries = []; + queries.push(updateTeamMember({ + team_id: teamId, + user_id: userId, + }, { status: 'inactive' }, transaction)); + + queries.push(updateTeamInvitation({ email: user.email }, { status: 'inactive' }, transaction)); + await Promise.all(queries); + await transaction.commit(); + return true; + } catch (error) { + transaction.rollback(); + throw new Error(error); + } +} diff --git a/api/services/teams/teams.service.js b/api/services/teams/teams.service.js index b51a34af..7f59a0bd 100644 --- a/api/services/teams/teams.service.js +++ b/api/services/teams/teams.service.js @@ -9,8 +9,8 @@ import generateRandomKey from '~/helpers/genarateRandomkey'; import { normalizeEmail, stringToSlug } from '~/helpers/string.helper'; import logger from '~/utils/logger'; import sendMail from '~/libs/mail'; -import { createMemberAndInviteToken, getListTeamMemberByAliasTeam } from '../../repository/team_members.repository'; -import { findUser } from '../../repository/user.repository'; +import { createMemberAndInviteToken, getListTeamMemberByAliasTeam, reInviteMemberHasDecline } from '../../repository/team_members.repository'; +import { getUserAndTeamInfo } from '../../repository/user.repository'; /** * Function to get all team @@ -38,6 +38,7 @@ export async function findTeamByAlias(alias) { email: member.email, status: member.status, isOwner: member.userId === member.owner, + teamId: member.teamId, })); } @@ -88,7 +89,10 @@ export async function inviteTeamMember(user, alias, inviteeEmail) { throw new ApolloError('Team not found'); } - member = await findUser({ email: inviteeEmail }); + member = await getUserAndTeamInfo({ email: inviteeEmail }); + if (member.id === team.created_by) { + throw new ApolloError('Invalid Email'); + } const token = await generateRandomKey(); const subject = 'Team invitation'; @@ -99,16 +103,38 @@ export async function inviteTeamMember(user, alias, inviteeEmail) { url: `${process.env.FRONTEND_URL}/teams/invitation/${token}`, }, }); - const queries = [sendMail(normalizeEmail(inviteeEmail), subject, template)]; if (member) { - queries.push(createMemberAndInviteToken({ - email: inviteeEmail, - token, - teamId: team.id, - memberId: member.id, - userId: user.id, - })); + switch (member.teamStatus) { + case 'decline': + queries.push(reInviteMemberHasDecline({ + teamId: team.id, + memberId: member.id, + email: member.email, + token, + userId: user.id, + })); + break; + + case 'active': + throw new ApolloError('Account have been actived'); + + case 'inactive': + throw new ApolloError('Account has deactived'); + + case 'pending': + throw new ApolloError('Account have been invited'); + + default: + queries.push(createMemberAndInviteToken({ + email: inviteeEmail, + token, + teamId: team.id, + memberId: member.id, + userId: user.id, + })); + break; + } } else { queries.push(createTeamInvitation({ email: inviteeEmail, @@ -129,6 +155,6 @@ export async function inviteTeamMember(user, alias, inviteeEmail) { }; } catch (error) { logger.error(error); - throw new ApolloError('Something went wrong!'); + throw new ApolloError(error); } } diff --git a/app/src/components/Team/InviteMemberForm.jsx b/app/src/components/Team/InviteMemberForm.jsx index 3e992e94..9aee5efd 100644 --- a/app/src/components/Team/InviteMemberForm.jsx +++ b/app/src/components/Team/InviteMemberForm.jsx @@ -6,9 +6,11 @@ InviteMemberForm.propTypes = { onSubmit: PropTypes.func.isRequired, register: PropTypes.func.isRequired, formErrors: PropTypes.object, + apiError: PropTypes.string, + loading: PropTypes.bool }; -function InviteMemberForm({ onSubmit, register, formErrors }) { +function InviteMemberForm({ onSubmit, register, formErrors, apiError, loading }) { return (
@@ -21,6 +23,7 @@ function InviteMemberForm({ onSubmit, register, formErrors }) { /> +
} diff --git a/app/src/containers/Teams/EditTeam/InviteMember.jsx b/app/src/containers/Teams/EditTeam/InviteMember.jsx index bbcc7d67..23fe0a58 100644 --- a/app/src/containers/Teams/EditTeam/InviteMember.jsx +++ b/app/src/containers/Teams/EditTeam/InviteMember.jsx @@ -6,10 +6,11 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { useDispatch } from "react-redux"; import { useMutation } from '@apollo/react-hooks'; -import { addTeamMember } from "@/features/admin/team"; +import { addTeamMember, removeTeamMember } from "@/features/admin/team"; import InviteMemberForm from "@/components/Team/InviteMemberForm"; import ListTeamMember from "@/components/Team/ListTeamMember"; import InviteMemberQuery from "@/queries/teams/inviteMember"; +import CancelInvitationQuery from "@/queries/teams/cancelInvitation"; InviteMember.propTypes = { alias: PropsType.string.isRequired, @@ -32,6 +33,7 @@ function InviteMember({ teamMembers, alias }) { }); const dispatch = useDispatch() const [InviteMemberMutation, { loading, error }] = useMutation(InviteMemberQuery); + const [CancelInvitationMutation] = useMutation(CancelInvitationQuery); async function onSubmit({ emailMember }) { try { @@ -49,8 +51,11 @@ function InviteMember({ teamMembers, alias }) { console.log(e) } } - function onActionInlistMember(params) { - console.log(params) + async function onActionInlistMember({ type, member }) { + if (type === 'cancel') { + await CancelInvitationMutation({ variables: { userId: member.userId, teamId: member.teamId } }) + dispatch(removeTeamMember({ teamId: alias, memberId: member.userId })) + } } @@ -61,6 +66,8 @@ function InviteMember({ teamMembers, alias }) { register={register} onSubmit={handleSubmit(onSubmit)} formErrors={formErrors} + apiError={error?.message} + loading={loading} />
Pending Invitations
diff --git a/app/src/features/admin/team.js b/app/src/features/admin/team.js index b20bc452..17a84c47 100644 --- a/app/src/features/admin/team.js +++ b/app/src/features/admin/team.js @@ -21,10 +21,17 @@ const team = createSlice({ setTeams(state, action) { const { teams } = action.payload state.teams = teams.map(it => ({ teamName: it.name, teamID: it.alias, teamMembers: [] })) + }, + removeTeamMember(state, action) { + const { memberId, teamId } = action.payload + const teamIndex = state.teams.findIndex(it => it.teamID === teamId) + console.log(state.teams) + console.log(teamIndex) + state.teams[teamIndex].teamMembers = [...state.teams[teamIndex].teamMembers].filter(it => it.userId !== memberId) } }, }); -export const { addNew, addTeamMember, setTeams } = team.actions; +export const { addNew, addTeamMember, setTeams, removeTeamMember } = team.actions; export default team.reducer; diff --git a/app/src/queries/teams/cancelInvitation.js b/app/src/queries/teams/cancelInvitation.js new file mode 100644 index 00000000..94d3b2a9 --- /dev/null +++ b/app/src/queries/teams/cancelInvitation.js @@ -0,0 +1,7 @@ +import { gql } from 'graphql.macro'; + +export default gql` + mutation CancelInvitation($userId: String!, $teamId: String!){ + cancelInvitation(userId: $userId, teamId: $teamId) + } +` \ No newline at end of file diff --git a/app/src/queries/teams/getTeamDetail.js b/app/src/queries/teams/getTeamDetail.js index 586b0840..8c88617d 100644 --- a/app/src/queries/teams/getTeamDetail.js +++ b/app/src/queries/teams/getTeamDetail.js @@ -8,6 +8,7 @@ export default gql` email isOwner status + teamId } } `; diff --git a/app/src/queries/teams/joinTeam.js b/app/src/queries/teams/joinTeam.js index 32d81bd0..6358bc63 100644 --- a/app/src/queries/teams/joinTeam.js +++ b/app/src/queries/teams/joinTeam.js @@ -1,7 +1,7 @@ import { gql } from 'graphql.macro'; export default gql` - mutation InviteMember($type: String!, $token: String!){ + mutation InviteMember($type: JoinTeamType!, $token: String!){ joinTeam(type: $type, token: $token) } ` \ No newline at end of file