From 5855d3f368b7beb206637d527649df5ce7ce0d6c Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Tue, 23 Mar 2021 18:57:04 +0200 Subject: [PATCH] fix(InviteUsers): fix invite users bugs. --- .../Alerts/Users/UserActivateAlert.js | 69 ++++- .../Alerts/Users/UserDeleteAlert.js | 1 + .../Alerts/Users/UserInactivateAlert.js | 11 +- .../Preferences/Users/UsersAlerts.js | 4 +- .../Preferences/Users/UsersDataTable.js | 30 +- .../Preferences/Users/components.js | 36 ++- client/src/hooks/query/invite.js | 10 + client/src/hooks/query/users.js | 26 +- client/src/lang/en/index.js | 1 + server/src/api/controllers/Authentication.ts | 66 +++-- server/src/api/controllers/Expenses.ts | 70 ----- server/src/api/controllers/InviteUsers.ts | 102 +++++-- server/src/interfaces/Expenses.ts | 18 -- server/src/interfaces/User.ts | 84 +++--- server/src/services/Authentication/index.ts | 3 +- .../src/services/Expenses/ExpensesService.ts | 110 -------- server/src/services/InviteUsers/constants.ts | 11 + server/src/services/InviteUsers/index.ts | 263 +++++++++++------- server/src/services/Users/UsersService.ts | 2 +- ...0200422225247_create_user_invites_table.js | 1 + server/src/system/models/Invite.js | 13 + server/src/system/models/SystemUser.js | 16 +- 22 files changed, 543 insertions(+), 404 deletions(-) create mode 100644 server/src/services/InviteUsers/constants.ts diff --git a/client/src/containers/Alerts/Users/UserActivateAlert.js b/client/src/containers/Alerts/Users/UserActivateAlert.js index e9d98a6e8..28737e586 100644 --- a/client/src/containers/Alerts/Users/UserActivateAlert.js +++ b/client/src/containers/Alerts/Users/UserActivateAlert.js @@ -1,6 +1,69 @@ +import React from 'react'; +import { FormattedMessage as T, useIntl } from 'react-intl'; +import { Alert, Intent } from '@blueprintjs/core'; +import { AppToaster } from 'components'; +import { useActivateUser } from 'hooks/query'; +import withAlertStoreConnect from 'containers/Alert/withAlertStoreConnect'; +import withAlertActions from 'containers/Alert/withAlertActions'; +import { compose } from 'utils'; -function UserActivateAlert() { - -} \ No newline at end of file +/** + * User inactivate alert. + */ +function UserActivateAlert({ + // #ownProps + name, + + // #withAlertStoreConnect + isOpen, + payload: { userId }, + + // #withAlertActions + closeAlert, +}) { + const { formatMessage } = useIntl(); + + const { mutateAsync: userActivateMutate } = useActivateUser(); + + const handleConfirmActivate = () => { + userActivateMutate(userId) + .then(() => { + AppToaster.show({ + message: formatMessage({ + id: 'the_user_has_been_activated_successfully', + }), + intent: Intent.SUCCESS, + }); + closeAlert(name); + }) + .catch((error) => { + closeAlert(name); + }); + }; + + const handleCancel = () => { + closeAlert(name); + }; + + return ( + } + confirmButtonText={} + intent={Intent.WARNING} + isOpen={isOpen} + onCancel={handleCancel} + onConfirm={handleConfirmActivate} + > +

+ +

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, +)(UserActivateAlert); diff --git a/client/src/containers/Alerts/Users/UserDeleteAlert.js b/client/src/containers/Alerts/Users/UserDeleteAlert.js index 13ccbb1da..5c6f8471b 100644 --- a/client/src/containers/Alerts/Users/UserDeleteAlert.js +++ b/client/src/containers/Alerts/Users/UserDeleteAlert.js @@ -40,6 +40,7 @@ function UserDeleteAlert({ }), intent: Intent.SUCCESS, }); + closeAlert(name); }) .catch(({ response: { data: { errors } } }) => { if (errors.find(e => e.type === 'CANNOT_DELETE_LAST_USER')) { diff --git a/client/src/containers/Alerts/Users/UserInactivateAlert.js b/client/src/containers/Alerts/Users/UserInactivateAlert.js index 335452283..b6894ae98 100644 --- a/client/src/containers/Alerts/Users/UserInactivateAlert.js +++ b/client/src/containers/Alerts/Users/UserInactivateAlert.js @@ -36,9 +36,16 @@ function UserInactivateAlert({ }), intent: Intent.SUCCESS, }); + closeAlert(name); }) - .catch((error) => { - + .catch(({ response: { data: { errors } } }) => { + if (errors.find(e => e.type === 'CANNOT.TOGGLE.ACTIVATE.AUTHORIZED.USER')) { + AppToaster.show({ + message: 'You could not activate/inactivate the same authorized user.', + intent: Intent.DANGER, + }); + } + closeAlert(name); }); }; diff --git a/client/src/containers/Preferences/Users/UsersAlerts.js b/client/src/containers/Preferences/Users/UsersAlerts.js index 73717281f..689004d2e 100644 --- a/client/src/containers/Preferences/Users/UsersAlerts.js +++ b/client/src/containers/Preferences/Users/UsersAlerts.js @@ -1,14 +1,14 @@ import React from 'react'; import UserDeleteAlert from 'containers/Alerts/Users/UserDeleteAlert'; import UserInactivateAlert from 'containers/Alerts/Users/UserInactivateAlert'; -// import UserActivateAlert from 'containers/Alerts/UserActivateAlert'; +import UserActivateAlert from 'containers/Alerts/Users/UserActivateAlert'; export default function UsersAlerts() { return ( <> - {/* */} + ); } diff --git a/client/src/containers/Preferences/Users/UsersDataTable.js b/client/src/containers/Preferences/Users/UsersDataTable.js index 3efcbf238..15bcf9053 100644 --- a/client/src/containers/Preferences/Users/UsersDataTable.js +++ b/client/src/containers/Preferences/Users/UsersDataTable.js @@ -2,6 +2,8 @@ import React, { useCallback } from 'react'; import { compose } from 'utils'; import { DataTable } from 'components'; +import { useResendInvitation } from 'hooks/query'; +import AppToaster from 'components/AppToaster'; import TableSkeletonRows from 'components/Datatable/TableSkeletonRows'; @@ -10,6 +12,7 @@ import withAlertActions from 'containers/Alert/withAlertActions'; import { ActionsMenu, useUsersListColumns } from './components'; import { useUsersListContext } from './UsersProvider'; +import { Intent } from '@blueprintjs/core'; /** * Users datatable. @@ -49,6 +52,26 @@ function UsersDataTable({ }, [openAlert] ); + + const { mutateAsync: resendInviation } = useResendInvitation(); + + const handleResendInvitation = useCallback( + (user) => { + resendInviation(user.id).then(() => { + AppToaster.show({ + message: 'User invitation has been re-sent to the user.', + intent: Intent.SUCCESS + }); + }).catch(({ response: { data: { errors } } }) => { + if (errors.find(e => e.type === 'USER_RECENTLY_INVITED')) { + AppToaster.show({ + message: 'This person was recently invited. No need to invite them again just yet.', + intent: Intent.DANGER + }); + } + }); + } + ) // Users list columns. const columns = useUsersListColumns(); @@ -67,9 +90,10 @@ function UsersDataTable({ ContextMenu={ActionsMenu} payload={{ onEdit: handleEditUserAction, - onActivate: handleInactivateUser, - onInactivate: handleActivateuser, - onDelete: handleDeleteUser + onActivate: handleActivateuser, + onInactivate: handleInactivateUser, + onDelete: handleDeleteUser, + onResendInvitation: handleResendInvitation }} /> ); diff --git a/client/src/containers/Preferences/Users/components.js b/client/src/containers/Preferences/Users/components.js index e4235c077..0118c569f 100644 --- a/client/src/containers/Preferences/Users/components.js +++ b/client/src/containers/Preferences/Users/components.js @@ -25,12 +25,7 @@ function AvatarCell(row) { */ export function ActionsMenu({ row: { original }, - payload: { - onEdit, - onInactivate, - onActivate, - onDelete - } + payload: { onEdit, onInactivate, onActivate, onDelete, onResendInvitation }, }) { const { formatMessage } = useIntl(); @@ -44,9 +39,26 @@ export function ActionsMenu({ /> + {original.active ? ( + } + /> + ) : ( + } + /> + )} + + + } /> @@ -64,7 +76,7 @@ export function ActionsMenu({ * Status accessor. */ function StatusAccessor(user) { - return !user.invite_accepted_at ? ( + return !user.is_invite_accepted ? ( @@ -93,6 +105,10 @@ function ActionsCell(props) { ); } +function FullNameAccessor(user) { + return user.is_invite_accepted ? user.full_name : user.email; +} + export const useUsersListColumns = () => { const { formatMessage } = useIntl(); @@ -107,7 +123,7 @@ export const useUsersListColumns = () => { { id: 'full_name', Header: formatMessage({ id: 'full_name' }), - accessor: 'full_name', + accessor: FullNameAccessor, width: 150, }, { diff --git a/client/src/hooks/query/invite.js b/client/src/hooks/query/invite.js index aab936bf5..b1b7960ff 100644 --- a/client/src/hooks/query/invite.js +++ b/client/src/hooks/query/invite.js @@ -27,4 +27,14 @@ export const useInviteMetaByToken = (token, props) => { ...props } ); +} + + +export const useResendInvitation = (props) => { + const apiRequest = useApiRequest(); + + return useMutation( + (userId) => apiRequest.post(`invite/resend/${userId}`), + props + ) } \ No newline at end of file diff --git a/client/src/hooks/query/users.js b/client/src/hooks/query/users.js index 0298e8c0c..cf513d371 100644 --- a/client/src/hooks/query/users.js +++ b/client/src/hooks/query/users.js @@ -47,10 +47,30 @@ export function useInactivateUser(props) { const queryClient = useQueryClient(); return useMutation( - ([id, values]) => apiRequest.post(`users/${id}/inactivate`, values), + (userId) => apiRequest.put(`users/${userId}/inactivate`), { - onSuccess: (res, [id, values]) => { - queryClient.invalidateQueries([t.USER, id]); + onSuccess: (res, userId) => { + queryClient.invalidateQueries([t.USER, userId]); + + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + + + +export function useActivateUser(props) { + const apiRequest = useApiRequest(); + const queryClient = useQueryClient(); + + return useMutation( + (userId) => apiRequest.put(`users/${userId}/activate`), + { + onSuccess: (res, userId) => { + queryClient.invalidateQueries([t.USER, userId]); // Common invalidate queries. commonInvalidateQueries(queryClient); diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js index a87f81eba..455f809c3 100644 --- a/client/src/lang/en/index.js +++ b/client/src/lang/en/index.js @@ -318,6 +318,7 @@ export default { edit_user: 'Edit User', edit_invite: 'Edit Invite', inactivate_user: 'Inactivate User', + activate_user: 'Activate User', delete_user: 'Delete User', full_name: 'Full Name', the_user_has_been_inactivated_successfully: diff --git a/server/src/api/controllers/Authentication.ts b/server/src/api/controllers/Authentication.ts index 9e8d77824..7e344c333 100644 --- a/server/src/api/controllers/Authentication.ts +++ b/server/src/api/controllers/Authentication.ts @@ -7,13 +7,13 @@ import BaseController from 'api/controllers/BaseController'; import asyncMiddleware from 'api/middleware/asyncMiddleware'; import AuthenticationService from 'services/Authentication'; import { ILoginDTO, ISystemUser, IRegisterDTO } from 'interfaces'; -import { ServiceError, ServiceErrors } from "exceptions"; +import { ServiceError, ServiceErrors } from 'exceptions'; import { DATATYPES_LENGTH } from 'data/DataTypes'; import LoginThrottlerMiddleware from 'api/middleware/LoginThrottlerMiddleware'; import config from 'config'; @Service() -export default class AuthenticationController extends BaseController{ +export default class AuthenticationController extends BaseController { @Inject() authService: AuthenticationService; @@ -116,19 +116,21 @@ export default class AuthenticationController extends BaseController{ * Country validator. */ countryValidator(value, { req }) { - const { countries: { whitelist, blacklist } } = config.registration; + const { + countries: { whitelist, blacklist }, + } = config.registration; const foundCountry = countries.findOne('countryCode', value); if (!foundCountry) { throw new Error('The country code is invalid.'); } if ( - // Focus with me! In case whitelist is not empty and the given coutry is not + // Focus with me! In case whitelist is not empty and the given coutry is not // in whitelist throw the error. - // - // Or in case the blacklist is not empty and the given country exists + // + // Or in case the blacklist is not empty and the given country exists // in the blacklist throw the goddamn error. - (whitelist.length > 0 && whitelist.indexOf(value) === -1) || + (whitelist.length > 0 && whitelist.indexOf(value) === -1) || (blacklist.length > 0 && blacklist.indexOf(value) !== -1) ) { throw new Error('The country code is not supported yet.'); @@ -153,7 +155,9 @@ export default class AuthenticationController extends BaseController{ */ get resetPasswordSchema(): ValidationChain[] { return [ - check('password').exists().isLength({ min: 5 }) + check('password') + .exists() + .isLength({ min: 5 }) .custom((value, { req }) => { if (value !== req.body.confirm_password) { throw new Error("Passwords don't match"); @@ -168,15 +172,13 @@ export default class AuthenticationController extends BaseController{ * Send reset password validation schema. */ get sendResetPasswordSchema(): ValidationChain[] { - return [ - check('email').exists().isEmail().trim().escape(), - ]; + return [check('email').exists().isEmail().trim().escape()]; } /** * Handle user login. - * @param {Request} req - * @param {Response} res + * @param {Request} req + * @param {Response} res */ async login(req: Request, res: Response, next: Function): Response { const userDTO: ILoginDTO = this.matchedBodyData(req); @@ -194,14 +196,16 @@ export default class AuthenticationController extends BaseController{ /** * Organization register handler. - * @param {Request} req - * @param {Response} res + * @param {Request} req + * @param {Response} res */ async register(req: Request, res: Response, next: Function) { const registerDTO: IRegisterDTO = this.matchedBodyData(req); try { - const registeredUser: ISystemUser = await this.authService.register(registerDTO); + const registeredUser: ISystemUser = await this.authService.register( + registerDTO + ); return res.status(200).send({ type: 'success', @@ -215,8 +219,8 @@ export default class AuthenticationController extends BaseController{ /** * Send reset password handler - * @param {Request} req - * @param {Response} res + * @param {Request} req + * @param {Response} res */ async sendResetPassword(req: Request, res: Response, next: Function) { const { email } = this.matchedBodyData(req); @@ -226,11 +230,10 @@ export default class AuthenticationController extends BaseController{ return res.status(200).send({ code: 'SEND_RESET_PASSWORD_SUCCESS', - message: 'The reset password message has been sent successfully.' + message: 'The reset password message has been sent successfully.', }); - } catch(error) { + } catch (error) { if (error instanceof ServiceError) { - } next(error); } @@ -238,8 +241,8 @@ export default class AuthenticationController extends BaseController{ /** * Reset password handler - * @param {Request} req - * @param {Response} res + * @param {Request} req + * @param {Response} res */ async resetPassword(req: Request, res: Response, next: Function) { const { token } = req.params; @@ -250,9 +253,9 @@ export default class AuthenticationController extends BaseController{ return res.status(200).send({ type: 'RESET_PASSWORD_SUCCESS', - message: 'The password has been reset successfully.' - }) - } catch(error) { + message: 'The password has been reset successfully.', + }); + } catch (error) { next(error); } } @@ -262,7 +265,9 @@ export default class AuthenticationController extends BaseController{ */ handlerErrors(error, req: Request, res: Response, next: Function) { if (error instanceof ServiceError) { - if (['INVALID_DETAILS', 'invalid_password'].indexOf(error.errorType) !== -1) { + if ( + ['INVALID_DETAILS', 'invalid_password'].indexOf(error.errorType) !== -1 + ) { return res.boom.badRequest(null, { errors: [{ type: 'INVALID_DETAILS', code: 100 }], }); @@ -272,7 +277,10 @@ export default class AuthenticationController extends BaseController{ errors: [{ type: 'USER_INACTIVE', code: 200 }], }); } - if (error.errorType === 'TOKEN_INVALID' || error.errorType === 'TOKEN_EXPIRED') { + if ( + error.errorType === 'TOKEN_INVALID' || + error.errorType === 'TOKEN_EXPIRED' + ) { return res.boom.badRequest(null, { errors: [{ type: 'TOKEN_INVALID', code: 300 }], }); @@ -303,4 +311,4 @@ export default class AuthenticationController extends BaseController{ } next(error); } -}; +} diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index 1cbcd2eb4..1df27843b 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -30,12 +30,6 @@ export default class ExpensesController extends BaseController { asyncMiddleware(this.newExpense.bind(this)), this.catchServiceErrors ); - router.post( - '/publish', - [...this.bulkSelectSchema], - this.bulkPublishExpenses.bind(this), - this.catchServiceErrors - ); router.post( '/:id/publish', [...this.expenseParamSchema], @@ -57,13 +51,6 @@ export default class ExpensesController extends BaseController { asyncMiddleware(this.deleteExpense.bind(this)), this.catchServiceErrors ); - router.delete( - '/', - [...this.bulkSelectSchema], - this.validationResult, - asyncMiddleware(this.bulkDeleteExpenses.bind(this)), - this.catchServiceErrors - ); router.get( '/', [...this.expensesListSchema], @@ -250,63 +237,6 @@ export default class ExpensesController extends BaseController { } } - /** - * Deletes the expenses in bulk. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async bulkDeleteExpenses(req: Request, res: Response, next: NextFunction) { - const { tenantId, user } = req; - const { ids: expensesIds } = req.query; - - try { - await this.expensesService.deleteBulkExpenses( - tenantId, - expensesIds, - user - ); - return res.status(200).send({ - ids: expensesIds, - message: 'The expenses have been deleted successfully.', - }); - } catch (error) { - next(error); - } - } - - /** - * Publishes the given expenses in bulk. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async bulkPublishExpenses(req: Request, res: Response, next: NextFunction) { - const { tenantId, user } = req; - const { ids: expensesIds } = req.query; - - try { - const { - meta: { alreadyPublished, published, total }, - } = await this.expensesService.publishBulkExpenses( - tenantId, - expensesIds, - user - ); - return res.status(200).send({ - ids: expensesIds, - message: 'The expenses have been published successfully.', - meta: { - alreadyPublished, - published, - total, - }, - }); - } catch (error) { - next(error); - } - } - /** * Retrieve expneses list. * @param {Request} req diff --git a/server/src/api/controllers/InviteUsers.ts b/server/src/api/controllers/InviteUsers.ts index 1a78e41c1..0b22269bd 100644 --- a/server/src/api/controllers/InviteUsers.ts +++ b/server/src/api/controllers/InviteUsers.ts @@ -1,5 +1,5 @@ import { Service, Inject } from 'typedi'; -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; import { check, body, param } from 'express-validator'; import { IInviteUserInput } from 'interfaces'; import asyncMiddleware from 'api/middleware/asyncMiddleware'; @@ -25,6 +25,15 @@ export default class InviteUsersController extends BaseController { asyncMiddleware(this.sendInvite.bind(this)), this.handleServicesError ); + router.post( + '/resend/:userId', + [ + param('userId').exists().isNumeric().toInt() + ], + this.validationResult, + this.asyncMiddleware(this.resendInvite.bind(this)), + this.handleServicesError + ); return router; } @@ -67,9 +76,9 @@ export default class InviteUsersController extends BaseController { /** * Invite a user to the authorized user organization. - * @param {Request} req - - * @param {Response} res - - * @param {NextFunction} next - + * @param {Request} req - Request object. + * @param {Response} res - Response object. + * @param {NextFunction} next - Next function. */ async sendInvite(req: Request, res: Response, next: Function) { const { email } = req.body; @@ -90,7 +99,29 @@ export default class InviteUsersController extends BaseController { } catch (error) { next(error); } - return res.status(200).send(); + } + + /** + * Resend the user invite. + * @param {Request} req - Request object. + * @param {Response} res - Response object. + * @param {NextFunction} next - Next function. + */ + async resendInvite(req: Request, res: Response, next: NextFunction) { + const { tenantId, user } = req; + const { userId } = req.params; + + try { + await this.inviteUsersService.resendInvite(tenantId, userId, user); + + return res.status(200).send({ + type: 'success', + code: 'INVITE.RESEND.SUCCESSFULLY', + message: 'The invite has been sent to the given email.', + }); + } catch (error) { + next(error); + } } /** @@ -151,38 +182,59 @@ export default class InviteUsersController extends BaseController { if (error instanceof ServiceError) { if (error.errorType === 'EMAIL_EXISTS') { return res.status(400).send({ - errors: [{ - type: 'EMAIL.ALREADY.EXISTS', - code: 100, - message: 'Email already exists in the users.' - }], + errors: [ + { + type: 'EMAIL.ALREADY.EXISTS', + code: 100, + message: 'Email already exists in the users.', + }, + ], }); } if (error.errorType === 'EMAIL_ALREADY_INVITED') { return res.status(400).send({ - errors: [{ - type: 'EMAIL.ALREADY.INVITED', - code: 200, - message: 'Email already invited.', - }], + errors: [ + { + type: 'EMAIL.ALREADY.INVITED', + code: 200, + message: 'Email already invited.', + }, + ], }); } if (error.errorType === 'INVITE_TOKEN_INVALID') { return res.status(400).send({ - errors: [{ - type: 'INVITE.TOKEN.INVALID', - code: 300, - message: 'Invite token is invalid, please try another one.', - }], + errors: [ + { + type: 'INVITE.TOKEN.INVALID', + code: 300, + message: 'Invite token is invalid, please try another one.', + }, + ], }); } if (error.errorType === 'PHONE_NUMBER_EXISTS') { return res.status(400).send({ - errors: [{ - type: 'PHONE_NUMBER.EXISTS', - code: 400, - message: 'Phone number is already invited, please try another unique one.' - }], + errors: [ + { + type: 'PHONE_NUMBER.EXISTS', + code: 400, + message: + 'Phone number is already invited, please try another unique one.', + }, + ], + }); + } + if (error.errorType === 'USER_RECENTLY_INVITED') { + return res.status(400).send({ + errors: [ + { + type: 'USER_RECENTLY_INVITED', + code: 500, + message: + 'This person was recently invited. No need to invite them again just yet.', + }, + ], }); } } diff --git a/server/src/interfaces/Expenses.ts b/server/src/interfaces/Expenses.ts index c3bbe9946..0d1a11a1f 100644 --- a/server/src/interfaces/Expenses.ts +++ b/server/src/interfaces/Expenses.ts @@ -84,24 +84,6 @@ export interface IExpensesService { authorizedUser: ISystemUser ): Promise; - deleteBulkExpenses( - tenantId: number, - expensesIds: number[], - authorizedUser: ISystemUser - ): Promise; - - publishBulkExpenses( - tenantId: number, - expensesIds: number[], - authorizedUser: ISystemUser - ): Promise<{ - meta: { - alreadyPublished: number; - published: number; - total: number, - }, - }>; - getExpensesList( tenantId: number, expensesFilter: IExpensesFilter diff --git a/server/src/interfaces/User.ts b/server/src/interfaces/User.ts index 162d1b879..7df463af5 100644 --- a/server/src/interfaces/User.ts +++ b/server/src/interfaces/User.ts @@ -1,45 +1,67 @@ import { Model } from 'objection'; export interface ISystemUser extends Model { - id: number, - firstName: string, - lastName: string, - active: boolean, - password: string, - email: string, - phoneNumber: string, + id: number; + firstName: string; + lastName: string; + active: boolean; + password: string; + email: string; + phoneNumber: string; - roleId: number, - tenantId: number, + roleId: number; + tenantId: number; - inviteAcceptAt: Date, - lastLoginAt: Date, - deletedAt: Date, + inviteAcceptAt: Date; + lastLoginAt: Date; + deletedAt: Date; - createdAt: Date, - updatedAt: Date, + createdAt: Date; + updatedAt: Date; } export interface ISystemUserDTO { - firstName: string, - lastName: string, - password: string, - phoneNumber: string, - active: boolean, - email: string, + firstName: string; + lastName: string; + password: string; + phoneNumber: string; + active: boolean; + email: string; } export interface IInviteUserInput { - firstName: string, - lastName: string, - phoneNumber: string, - password: string, -}; + firstName: string; + lastName: string; + phoneNumber: string; + password: string; +} export interface IUserInvite { - id: number, - email: string, - token: string, - tenantId: number, - createdAt?: Date, -} \ No newline at end of file + id: number; + email: string; + token: string; + tenantId: number; + userId: number; + createdAt?: Date; +} + +export interface IInviteUserService { + acceptInvite(token: string, inviteUserInput: IInviteUserInput): Promise; + resendInvite( + tenantId: number, + userId: number, + authorizedUser: ISystemUser + ): Promise<{ + invite: IUserInvite; + }>; + sendInvite( + tenantId: number, + email: string, + authorizedUser: ISystemUser + ): Promise<{ + invite: IUserInvite; + }>; + checkInvite( + token: string + ): Promise<{ inviteToken: IUserInvite; orgName: object }>; +} diff --git a/server/src/services/Authentication/index.ts b/server/src/services/Authentication/index.ts index f7d6856bc..9770f767b 100644 --- a/server/src/services/Authentication/index.ts +++ b/server/src/services/Authentication/index.ts @@ -177,7 +177,8 @@ export default class AuthenticationService implements IAuthenticationService { ...omit(registerDTO, 'country'), active: true, password: hashedPassword, - tenant_id: tenant.id, + tenantId: tenant.id, + inviteAcceptedAt: moment().format('YYYY-MM-DD'), }); // Triggers `onRegister` event. this.eventDispatcher.dispatch(events.auth.register, { diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts index 659cdbc35..13c239952 100644 --- a/server/src/services/Expenses/ExpensesService.ts +++ b/server/src/services/Expenses/ExpensesService.ts @@ -570,48 +570,6 @@ export default class ExpensesService implements IExpensesService { }); } - /** - * Deletes the given expenses in bulk. - * @param {number} tenantId - * @param {number[]} expensesIds - * @param {ISystemUser} authorizedUser - */ - public async deleteBulkExpenses( - tenantId: number, - expensesIds: number[], - authorizedUser: ISystemUser - ) { - const { - expenseRepository, - expenseEntryRepository, - } = this.tenancy.repositories(tenantId); - - // Retrieve olds expenses. - const oldExpenses = await this.getExpensesOrThrowError( - tenantId, - expensesIds - ); - - this.logger.info('[expense] trying to delete the given expenses.', { - tenantId, - expensesIds, - }); - await expenseEntryRepository.deleteWhereIn('expenseId', expensesIds); - await expenseRepository.deleteWhereIdIn(expensesIds); - - this.logger.info('[expense] the given expenses deleted successfully.', { - tenantId, - expensesIds, - }); - // Triggers `onExpenseBulkDeleted` event. - this.eventDispatcher.dispatch(events.expenses.onBulkDeleted, { - tenantId, - expensesIds, - oldExpenses, - authorizedUser, - }); - } - /** * Filters the not published expenses. * @param {IExpense[]} expenses - @@ -629,74 +587,6 @@ export default class ExpensesService implements IExpensesService { return expenses.filter((expense) => expense.publishedAt); } - /** - * Deletes the given expenses in bulk. - * @param {number} tenantId - * @param {number[]} expensesIds - * @param {ISystemUser} authorizedUser - */ - public async publishBulkExpenses( - tenantId: number, - expensesIds: number[], - authorizedUser: ISystemUser - ): Promise<{ - meta: { - alreadyPublished: number; - published: number; - total: number, - }, - }> { - const oldExpenses = await this.getExpensesOrThrowError( - tenantId, - expensesIds - ); - const { expenseRepository } = this.tenancy.repositories(tenantId); - - // Filters the not published expenses. - const notPublishedExpenses = this.getNonePublishedExpenses(oldExpenses); - - // Filters the published expenses. - const publishedExpenses = this.getPublishedExpenses(oldExpenses); - - // Mappes the not-published expenses to get id. - const notPublishedExpensesIds = map(notPublishedExpenses, 'id'); - - if (notPublishedExpensesIds.length > 0) { - this.logger.info('[expense] trying to publish the given expenses.', { - tenantId, - expensesIds, - }); - await expenseRepository.whereIdInPublish(notPublishedExpensesIds); - - this.logger.info( - '[expense] the given expenses ids published successfully.', - { tenantId, expensesIds } - ); - } - // Retrieve the new expenses after modification. - const expenses = await expenseRepository.findWhereIn( - 'id', - expensesIds, - 'categories' - ); - // Triggers `onExpenseBulkDeleted` event. - this.eventDispatcher.dispatch(events.expenses.onBulkPublished, { - tenantId, - expensesIds, - oldExpenses, - expenses, - authorizedUser, - }); - - return { - meta: { - alreadyPublished: publishedExpenses.length, - published: notPublishedExpenses.length, - total: oldExpenses.length, - }, - }; - } - /** * Retrieve expenses datatable lsit. * @param {number} tenantId diff --git a/server/src/services/InviteUsers/constants.ts b/server/src/services/InviteUsers/constants.ts new file mode 100644 index 000000000..1e0516917 --- /dev/null +++ b/server/src/services/InviteUsers/constants.ts @@ -0,0 +1,11 @@ + + +export const ERRORS = { + EMAIL_ALREADY_INVITED: 'EMAIL_ALREADY_INVITED', + INVITE_TOKEN_INVALID: 'INVITE_TOKEN_INVALID', + PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS', + USER_NOT_FOUND: 'USER_NOT_FOUND', + EMAIL_EXISTS: 'EMAIL_EXISTS', + EMAIL_NOT_EXISTS: 'EMAIL_NOT_EXISTS', + USER_RECENTLY_INVITED: 'USER_RECENTLY_INVITED', +}; \ No newline at end of file diff --git a/server/src/services/InviteUsers/index.ts b/server/src/services/InviteUsers/index.ts index d903f6334..6fbf79d23 100644 --- a/server/src/services/InviteUsers/index.ts +++ b/server/src/services/InviteUsers/index.ts @@ -12,17 +12,17 @@ import { hashPassword } from 'utils'; import TenancyService from 'services/Tenancy/TenancyService'; import InviteUsersMailMessages from 'services/InviteUsers/InviteUsersMailMessages'; import events from 'subscribers/events'; -import { ISystemUser, IInviteUserInput, IUserInvite } from 'interfaces'; +import { + ISystemUser, + IInviteUserInput, + IUserInvite, + IInviteUserService, +} from 'interfaces'; import TenantsManagerService from 'services/Tenancy/TenantsManager'; +import { ERRORS } from './constants'; -const ERRORS = { - EMAIL_ALREADY_INVITED: 'EMAIL_ALREADY_INVITED', - INVITE_TOKEN_INVALID: 'INVITE_TOKEN_INVALID', - PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS', - EMAIL_EXISTS: 'EMAIL_EXISTS' -}; @Service() -export default class InviteUserService { +export default class InviteUserService implements IInviteUserService { @EventDispatcher() eventDispatcher: EventDispatcherInterface; @@ -41,60 +41,6 @@ export default class InviteUserService { @Inject() tenantsManager: TenantsManagerService; - /** - * Accept the received invite. - * @param {string} token - * @param {IInviteUserInput} inviteUserInput - * @throws {ServiceErrors} - * @returns {Promise} - */ - async acceptInvite( - token: string, - inviteUserInput: IInviteUserInput - ): Promise { - const { systemUserRepository } = this.sysRepositories; - - // Retrieve the invite token or throw not found error. - const inviteToken = await this.getInviteOrThrowError(token); - - // Validates the user phone number. - await this.validateUserPhoneNumber(inviteUserInput); - - this.logger.info('[aceept_invite] trying to hash the user password.'); - const hashedPassword = await hashPassword(inviteUserInput.password); - - this.logger.info('[accept_invite] trying to update user details.'); - const user = await systemUserRepository.findOneByEmail(inviteToken.email); - - // Sets the invited user details after invite accepting. - const systemUserOper = systemUserRepository.create( - { - ...inviteUserInput, - email: inviteToken.email, - tenantId: inviteToken.tenantId, - active: 1, - inviteAcceptedAt: moment().format('YYYY-MM-DD'), - password: hashedPassword, - }, - ); - - this.logger.info('[accept_invite] trying to delete the given token.'); - const deleteInviteTokenOper = Invite.query() - .where('token', inviteToken.token) - .delete(); - - // Await all async operations. - const [systemUser] = await Promise.all([ - systemUserOper, - deleteInviteTokenOper, - ]); - // Triggers `onUserAcceptInvite` event. - this.eventDispatcher.dispatch(events.inviteUser.acceptInvite, { - inviteToken, - user: systemUser, - }); - } - /** * Sends invite mail to the given email from the given tenant and user. * @param {number} tenantId - @@ -110,27 +56,120 @@ export default class InviteUserService { ): Promise<{ invite: IUserInvite; }> { - // Throw error in case user email exists. - await this.throwErrorIfUserEmailExists(email); + const { systemUserRepository } = this.sysRepositories; - // Throws service error in case the user already invited. - await this.throwErrorIfUserInvited(email); + // Validates the given email not exists on the storage. + await this.validateUserEmailNotExists(email); - this.logger.info('[send_invite] trying to store invite token.'); + this.logger.info('[invite] trying to store user with email and tenant.', { + email, + }); + const user = await systemUserRepository.create({ + email, + tenantId, + active: 1, + }); + + this.logger.info('[invite] trying to store invite token.', { email }); const invite = await Invite.query().insert({ email, - tenant_id: authorizedUser.tenantId, + tenantId: authorizedUser.tenantId, + userId: user.id, token: uniqid(), }); - this.logger.info( - '[send_invite] trying to store user with email and tenant.' - ); // Triggers `onUserSendInvite` event. this.eventDispatcher.dispatch(events.inviteUser.sendInvite, { invite, authorizedUser, tenantId, + user, + }); + return { invite }; + } + + /** + * Accept the received invite. + * @param {string} token + * @param {IInviteUserInput} inviteUserInput + * @throws {ServiceErrors} + * @returns {Promise} + */ + public async acceptInvite( + token: string, + inviteUserInput: IInviteUserInput + ): Promise { + const { systemUserRepository } = this.sysRepositories; + + // Retrieve the invite token or throw not found error. + const inviteToken = await this.getInviteTokenOrThrowError(token); + + // Validates the user phone number. + await this.validateUserPhoneNumberNotExists(inviteUserInput.phoneNumber); + + this.logger.info('[invite] trying to hash the user password.'); + const hashedPassword = await hashPassword(inviteUserInput.password); + + this.logger.info('[invite] trying to update user details.'); + const user = await systemUserRepository.findOneByEmail(inviteToken.email); + + // Sets the invited user details after invite accepting. + const systemUser = await systemUserRepository.update( + { + ...inviteUserInput, + inviteAcceptedAt: moment().format('YYYY-MM-DD'), + password: hashedPassword, + }, + { id: inviteToken.userId } + ); + // Clear invite token by the given user id. + await this.clearInviteTokensByUserId(inviteToken.userId); + + // Triggers `onUserAcceptInvite` event. + this.eventDispatcher.dispatch(events.inviteUser.acceptInvite, { + inviteToken, + user: systemUser, + }); + } + + /** + * Re-send user invite. + * @param tenantId + * @param {string} email + * @return {Promise<{ invite: IUserInvite }>} + */ + public async resendInvite( + tenantId: number, + userId: number, + authorizedUser: ISystemUser + ): Promise<{ + invite: IUserInvite; + }> { + // Retrieve the user by id or throw not found service error. + const user = this.getUserByIdOrThrowError(userId); + + // Validate invite user active + await this.validateInviteUserNotActive(tenantId, userId); + + // Clear all invite tokens of the given user id. + await this.clearInviteTokensByUserId(userId); + + this.logger.info('[invite] trying to store invite token.', { + userId, + tenantId, + }); + const invite = await Invite.query().insert({ + email: user.email, + tenantId, + userId, + token: uniqid(), + }); + // Triggers `onUserSendInvite` event. + this.eventDispatcher.dispatch(events.inviteUser.sendInvite, { + invite, + authorizedUser, + tenantId, + user, }); return { invite }; } @@ -143,7 +182,7 @@ export default class InviteUserService { public async checkInvite( token: string ): Promise<{ inviteToken: IUserInvite; orgName: object }> { - const inviteToken = await this.getInviteOrThrowError(token); + const inviteToken = await this.getInviteTokenOrThrowError(token); // Find the tenant that associated to the given token. const tenant = await Tenant.query().findById(inviteToken.tenantId); @@ -166,13 +205,48 @@ export default class InviteUserService { return { inviteToken, orgName }; } + /** + * Validate the given user has no active invite token. + * @param {number} tenantId + * @param {number} userId - User id. + */ + private async validateInviteUserNotActive(tenantId: number, userId: number) { + // Retrieve the invite token or throw not found error. + const inviteTokens = await Invite.query() + .modify('notExpired') + .where('user_id', userId); + + // Throw the error if the one invite tokens is still active. + if (inviteTokens.length > 0) { + this.logger.info('[invite] email is already invited.', { + userId, + tenantId, + }); + throw new ServiceError(ERRORS.USER_RECENTLY_INVITED); + } + } + + /** + * Retrieve the given user by id or throw not found service error. + * @param {number} userId - User id. + */ + private async getUserByIdOrThrowError(userId: number) { + const { systemUserRepository } = this.sysRepositories; + const user = await systemUserRepository.findOneById(userId); + + // Throw if the user not found. + if (!user) { + throw new ServiceError(ERRORS.USER_NOT_FOUND); + } + return user; + } + /** * Throws error in case the given user email not exists on the storage. * @param {string} email + * @throws {ServiceError} */ - private async throwErrorIfUserEmailExists( - email: string - ): Promise { + private async validateUserEmailNotExists(email: string): Promise { const { systemUserRepository } = this.sysRepositories; const foundUser = await systemUserRepository.findOneByEmail(email); @@ -181,31 +255,21 @@ export default class InviteUserService { } } - /** - * Throws service error if the user already invited. - * @param {string} email - - */ - private async throwErrorIfUserInvited( - email: string, - ): Promise { - const inviteToken = await Invite.query().findOne('email', email); - - if (inviteToken) { - throw new ServiceError(ERRORS.EMAIL_ALREADY_INVITED); - } - } - /** * Retrieve invite model from the given token or throw error. * @param {string} token - Then given token string. * @throws {ServiceError} * @returns {Invite} */ - private async getInviteOrThrowError(token: string): Promise { - const inviteToken = await Invite.query().findOne('token', token); + private async getInviteTokenOrThrowError( + token: string + ): Promise { + const inviteToken = await Invite.query() + .modify('notExpired') + .findOne('token', token); if (!inviteToken) { - this.logger.info('[aceept_invite] the invite token is invalid.'); + this.logger.info('[invite] the invite token is invalid.'); throw new ServiceError(ERRORS.INVITE_TOKEN_INVALID); } return inviteToken; @@ -215,15 +279,24 @@ export default class InviteUserService { * Validate the given user email and phone number uniquine. * @param {IInviteUserInput} inviteUserInput */ - private async validateUserPhoneNumber( - inviteUserInput: IInviteUserInput + private async validateUserPhoneNumberNotExists( + phoneNumber: string ): Promise { const { systemUserRepository } = this.sysRepositories; const foundUser = await systemUserRepository.findOneByPhoneNumber( - inviteUserInput.phoneNumber + phoneNumber ); if (foundUser) { throw new ServiceError(ERRORS.PHONE_NUMBER_EXISTS); } } + + /** + * Clear invite tokens of the given user id. + * @param {number} userId - User id. + */ + private async clearInviteTokensByUserId(userId: number) { + this.logger.info('[invite] trying to delete the given token.'); + await Invite.query().where('user_id', userId).delete(); + } } diff --git a/server/src/services/Users/UsersService.ts b/server/src/services/Users/UsersService.ts index 22e623689..0d3bf4eef 100644 --- a/server/src/services/Users/UsersService.ts +++ b/server/src/services/Users/UsersService.ts @@ -113,7 +113,7 @@ export default class UsersService { // Throw serivce error if the user is already activated. this.throwErrorIfUserActive(user); - await systemUserRepository.activateUser(userId); + await systemUserRepository.activateById(userId); } /** diff --git a/server/src/system/migrations/20200422225247_create_user_invites_table.js b/server/src/system/migrations/20200422225247_create_user_invites_table.js index abb723b20..42028ccc7 100644 --- a/server/src/system/migrations/20200422225247_create_user_invites_table.js +++ b/server/src/system/migrations/20200422225247_create_user_invites_table.js @@ -5,6 +5,7 @@ exports.up = function(knex) { table.string('email').index(); table.string('token').unique().index(); table.bigInteger('tenant_id').unsigned().index().references('id').inTable('tenants'); + table.integer('user_id').unsigned().index().references('id').inTable('users'); table.datetime('created_at'); }); }; diff --git a/server/src/system/models/Invite.js b/server/src/system/models/Invite.js index bb226f88b..2de707735 100644 --- a/server/src/system/models/Invite.js +++ b/server/src/system/models/Invite.js @@ -1,4 +1,5 @@ import SystemModel from 'system/models/SystemModel'; +import moment from 'moment'; export default class UserInvite extends SystemModel { /** @@ -14,4 +15,16 @@ export default class UserInvite extends SystemModel { get timestamps() { return ['createdAt']; } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + notExpired(query) { + const comp = moment().subtract(24, 'hours').toMySqlDateTime(); + query.where('created_at', '>=', comp); + } + } + } } diff --git a/server/src/system/models/SystemUser.js b/server/src/system/models/SystemUser.js index a1f5bed74..a314240e2 100644 --- a/server/src/system/models/SystemUser.js +++ b/server/src/system/models/SystemUser.js @@ -29,7 +29,21 @@ export default class SystemUser extends SystemModel { * Virtual attributes. */ static get virtualAttributes() { - return ['fullName']; + return ['fullName', 'isDeleted', 'isInviteAccepted']; + } + + /** + * + */ + get isDeleted() { + return !!this.deletedAt; + } + + /** + * + */ + get isInviteAccepted() { + return !!this.inviteAcceptedAt; } /**