From 5855d3f368b7beb206637d527649df5ce7ce0d6c Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Tue, 23 Mar 2021 18:57:04 +0200 Subject: [PATCH 1/2] 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; } /** From 96f20bf51b935d526caccaed38510e2194eafde2 Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Tue, 23 Mar 2021 21:28:58 +0200 Subject: [PATCH 2/2] fix(Currencies): Currencies preferences. --- client/src/containers/Accounts/components.js | 2 +- .../Alerts/Currencies/CurrencyDeleteAlert.js | 8 +- .../CurrencyFormDialog/CurrencyForm.js | 15 ++-- .../CurrencyFormDialog/CurrencyForm.schema.js | 1 + .../CurrencyFormDialogContent.js | 1 - .../CurrencyFormDialog/CurrencyFormFields.js | 54 +++++++++---- .../CurrencyFormProvider.js | 9 +-- .../Currencies/CurrenciesDataTable.js | 1 + .../Preferences/Currencies/components.js | 1 + client/src/lang/en/index.js | 2 + .../src/style/pages/Billing/BillingPage.scss | 4 +- .../pages/Currency/CurrencyFormDialog.scss | 6 ++ client/src/utils.js | 16 ++++ server/src/api/controllers/Accounts.ts | 76 ----------------- .../src/api/controllers/Contacts/Contacts.ts | 10 --- .../src/api/controllers/Contacts/Vendors.ts | 22 ----- server/src/api/controllers/Currencies.ts | 34 ++++++-- server/src/api/controllers/Expenses.ts | 7 -- server/src/api/controllers/ItemCategories.ts | 42 ---------- server/src/api/controllers/Items.ts | 11 --- server/src/api/controllers/ManualJournals.ts | 81 ------------------- .../20200419171451_create_currencies_table.js | 1 + server/src/interfaces/Currency.ts | 3 + .../services/Currencies/CurrenciesService.ts | 35 +++++++- .../BalanceSheet/BalanceSheetService.ts | 3 +- 25 files changed, 153 insertions(+), 292 deletions(-) diff --git a/client/src/containers/Accounts/components.js b/client/src/containers/Accounts/components.js index 1453e4fe6..0ea6e45ea 100644 --- a/client/src/containers/Accounts/components.js +++ b/client/src/containers/Accounts/components.js @@ -115,7 +115,7 @@ export function NormalCell({ cell: { value } }) { export function BalanceCell({ cell }) { const account = cell.row.original; - return account.amount ? ( + return account.amount !== null ? ( diff --git a/client/src/containers/Alerts/Currencies/CurrencyDeleteAlert.js b/client/src/containers/Alerts/Currencies/CurrencyDeleteAlert.js index 48c40f7dd..2f89ee8fb 100644 --- a/client/src/containers/Alerts/Currencies/CurrencyDeleteAlert.js +++ b/client/src/containers/Alerts/Currencies/CurrencyDeleteAlert.js @@ -45,7 +45,13 @@ function CurrencyDeleteAlert({ }); closeAlert(name); }) - .catch(() => { + .catch(({ response: { data: { errors } } }) => { + if (errors.find(e => e.type === 'CANNOT_DELETE_BASE_CURRENCY')) { + AppToaster.show({ + intent: Intent.DANGER, + message: 'Cannot delete the base currency.' + }); + } closeAlert(name); }); }; diff --git a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.js b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.js index 440802aed..f3448171a 100644 --- a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.js +++ b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.js @@ -1,9 +1,8 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo } from 'react'; import { Intent } from '@blueprintjs/core'; import { Formik } from 'formik'; import { FormattedMessage as T, useIntl } from 'react-intl'; import { AppToaster } from 'components'; -import { pick } from 'lodash'; import CurrencyFormContent from './CurrencyFormContent'; import { useCurrencyFormContext } from './CurrencyFormProvider'; @@ -18,6 +17,7 @@ import { compose, transformToForm } from 'utils'; const defaultInitialValues = { currency_name: '', currency_code: '', + currency_sign: '', }; /** @@ -59,7 +59,7 @@ function CurrencyForm({ const afterSubmit = () => { closeDialog(dialogName); }; - + // Handle the request success. const onSuccess = ({ response }) => { AppToaster.show({ message: formatMessage({ @@ -71,9 +71,14 @@ function CurrencyForm({ }); afterSubmit(response); }; - // Handle the response error. - const onError = (errors) => { + const onError = ({ response: { data: { errors } } }) => { + if (errors.find(e => e.type === 'CURRENCY_CODE_EXISTS')) { + AppToaster.show({ + message: 'The given currency code is already exists.', + intent: Intent.DANGER, + }); + } setSubmitting(false); }; if (isEditMode) { diff --git a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.schema.js b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.schema.js index 31c6dfe8b..556069355 100644 --- a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.schema.js +++ b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyForm.schema.js @@ -10,6 +10,7 @@ const Schema = Yup.object().shape({ .max(4) .required() .label(formatMessage({ id: 'currency_code_' })), + currency_sign: Yup.string().required(), }); export const CreateCurrencyFormSchema = Schema; diff --git a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormDialogContent.js b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormDialogContent.js index d8c1dc07b..5bcba93c4 100644 --- a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormDialogContent.js +++ b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormDialogContent.js @@ -1,6 +1,5 @@ import React from 'react'; import { CurrencyFormProvider } from './CurrencyFormProvider'; -import { pick } from 'lodash'; import CurrencyForm from './CurrencyForm'; import withCurrencyDetail from 'containers/Currencies/withCurrencyDetail'; diff --git a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormFields.js b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormFields.js index 61df70c3f..c24588e34 100644 --- a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormFields.js +++ b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormFields.js @@ -1,24 +1,47 @@ import React from 'react'; -import { - Classes, - FormGroup, - InputGroup, -} from '@blueprintjs/core'; +import { Classes, FormGroup, InputGroup } from '@blueprintjs/core'; import { FastField } from 'formik'; import { FormattedMessage as T } from 'react-intl'; -import { - ErrorMessage, - FieldRequiredHint, -} from 'components'; + +import { useCurrencyFormContext } from './CurrencyFormProvider'; +import { ErrorMessage, FieldRequiredHint, ListSelect } from 'components'; import { useAutofocus } from 'hooks'; -import { inputIntent } from 'utils'; +import { inputIntent, currenciesOptions } from 'utils'; +/** + * Currency form fields. + */ export default function CurrencyFormFields() { const currencyNameFieldRef = useAutofocus(); - + + const { isEditMode } = useCurrencyFormContext(); + return (
+ + {({ + form: { setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + + { + setFieldValue('currency_code', currency.currency_code); + setFieldValue('currency_name', currency.name); + setFieldValue('currency_sign', currency.symbol); + }} + disabled={isEditMode} + /> + + )} + {/* ----------- Currency name ----------- */} {({ field, field: { value }, meta: { error, touched } }) => ( @@ -38,15 +61,14 @@ export default function CurrencyFormFields() { )} {/* ----------- Currency Code ----------- */} - + {({ field, field: { value }, meta: { error, touched } }) => ( } + label={} labelInfo={} - className={'form-group--currency-code'} + className={'form-group--currency-sign'} intent={inputIntent({ error, touched })} - helperText={} - // inline={true} + helperText={} > diff --git a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormProvider.js b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormProvider.js index 545b86b47..aa74f8539 100644 --- a/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormProvider.js +++ b/client/src/containers/Dialogs/CurrencyFormDialog/CurrencyFormProvider.js @@ -1,5 +1,5 @@ import React, { createContext } from 'react'; -import { useCurrencies, useEditCurrency, useCreateCurrency } from 'hooks/query'; +import { useEditCurrency, useCreateCurrency } from 'hooks/query'; import { DialogContent } from 'components'; const CurrencyFormContext = createContext(); @@ -7,27 +7,22 @@ const CurrencyFormContext = createContext(); /** * Currency Form page provider. */ - function CurrencyFormProvider({ isEditMode, currency, dialogName, ...props }) { // Create and edit item currency mutations. const { mutateAsync: createCurrencyMutate } = useCreateCurrency(); const { mutateAsync: editCurrencyMutate } = useEditCurrency(); - // fetch Currencies list. - const { data: currencies, isLoading: isCurrenciesLoading } = useCurrencies(); - // Provider state. const provider = { createCurrencyMutate, editCurrencyMutate, dialogName, currency, - isCurrenciesLoading, isEditMode, }; return ( - + ); diff --git a/client/src/containers/Preferences/Currencies/CurrenciesDataTable.js b/client/src/containers/Preferences/Currencies/CurrenciesDataTable.js index ceadb4a8e..5bbb54cda 100644 --- a/client/src/containers/Preferences/Currencies/CurrenciesDataTable.js +++ b/client/src/containers/Preferences/Currencies/CurrenciesDataTable.js @@ -52,6 +52,7 @@ function CurrenciesDataTable({ loading={isCurrenciesLoading} progressBarLoading={isCurrenciesLoading} TableLoadingRenderer={TableSkeletonRows} + ContextMenu={ActionMenuList} noInitialFetch={true} payload={{ onDeleteCurrency: handleDeleteCurrency, diff --git a/client/src/containers/Preferences/Currencies/components.js b/client/src/containers/Preferences/Currencies/components.js index 41aa096b1..6a0a8a9c7 100644 --- a/client/src/containers/Preferences/Currencies/components.js +++ b/client/src/containers/Preferences/Currencies/components.js @@ -69,6 +69,7 @@ export function useCurrenciesTableColumns() { { Header: 'Currency sign', width: 120, + accessor: 'currency_sign' }, { id: 'actions', diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js index 455f809c3..16ff6167e 100644 --- a/client/src/lang/en/index.js +++ b/client/src/lang/en/index.js @@ -1034,4 +1034,6 @@ export default { 'This customer cannot be deleted as it is associated with transactions.', this_vendor_cannot_be_deleted_as_it_is_associated_with_transactions: 'This vendor cannot be deleted as it is associated with transactions.', + + currency_sign: 'Currency sign', }; diff --git a/client/src/style/pages/Billing/BillingPage.scss b/client/src/style/pages/Billing/BillingPage.scss index 3583e6898..7250ddaa8 100644 --- a/client/src/style/pages/Billing/BillingPage.scss +++ b/client/src/style/pages/Billing/BillingPage.scss @@ -14,11 +14,11 @@ } .plan-radio, .period-radio{ - background: transparent; + background: #fff; border-color: #bbcad4; &.is-selected{ - background: #f1f3fb; + background: #fff; border-color: #0269ff } } diff --git a/client/src/style/pages/Currency/CurrencyFormDialog.scss b/client/src/style/pages/Currency/CurrencyFormDialog.scss index ba6b4d0a2..9440a1cdf 100644 --- a/client/src/style/pages/Currency/CurrencyFormDialog.scss +++ b/client/src/style/pages/Currency/CurrencyFormDialog.scss @@ -12,4 +12,10 @@ height: 170px; } } + + .bp3-dialog-footer{ + .bp3-button{ + min-width: 75px; + } + } } diff --git a/client/src/utils.js b/client/src/utils.js index 937cabf89..891916f12 100644 --- a/client/src/utils.js +++ b/client/src/utils.js @@ -1,5 +1,7 @@ import moment from 'moment'; import _, { castArray } from 'lodash'; +import Currencies from 'js-money/lib/currency'; + import { Intent } from '@blueprintjs/core'; import Currency from 'js-money/lib/currency'; import accounting from 'accounting'; @@ -620,3 +622,17 @@ export const updateTableRow = (rowIndex, columnId, value) => (old) => { export const transformGeneralSettings = (data) => { return _.mapKeys(data, (value, key) => _.snakeCase(key)); }; + +const getCurrenciesOptions = () => { + return Object.keys(Currencies).map((currencyCode) => { + const currency = Currencies[currencyCode]; + + return { + ...currency, + currency_code: currencyCode, + formatted_name: `${currencyCode} - ${currency.name}`, + }; + }) +} + +export const currenciesOptions = getCurrenciesOptions(); \ No newline at end of file diff --git a/server/src/api/controllers/Accounts.ts b/server/src/api/controllers/Accounts.ts index 82601b7b4..ccce9c4a8 100644 --- a/server/src/api/controllers/Accounts.ts +++ b/server/src/api/controllers/Accounts.ts @@ -23,12 +23,6 @@ export default class AccountsController extends BaseController { router() { const router = Router(); - router.post( - '/bulk/:type(activate|inactivate)', - [...this.bulkSelectIdsQuerySchema], - this.validationResult, - asyncMiddleware(this.bulkToggleActivateAccounts.bind(this)) - ); router.post( '/:id/activate', [...this.accountParamSchema], @@ -77,13 +71,6 @@ export default class AccountsController extends BaseController { this.dynamicListService.handlerErrorsToResponse, this.catchServiceErrors ); - router.delete( - '/', - [...this.bulkSelectIdsQuerySchema], - this.validationResult, - asyncMiddleware(this.deleteBulkAccounts.bind(this)), - this.catchServiceErrors - ); router.delete( '/:id', [...this.accountParamSchema], @@ -140,13 +127,6 @@ export default class AccountsController extends BaseController { ]; } - get bulkSelectIdsQuerySchema() { - return [ - query('ids').isArray({ min: 1 }), - query('ids.*').isNumeric().toInt(), - ]; - } - get closingAccountSchema() { return [ check('to_account_id').exists().isNumeric().toInt(), @@ -293,62 +273,6 @@ export default class AccountsController extends BaseController { } } - /** - * Bulk activate/inactivate accounts. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async bulkToggleActivateAccounts( - req: Request, - res: Response, - next: Function - ) { - const { type } = req.params; - const { tenantId } = req; - const { ids: accountsIds } = req.query; - - try { - const isActive = type === 'activate' ? true : false; - await this.accountsService.activateAccounts( - tenantId, - accountsIds, - isActive - ); - - const activatedText = isActive ? 'activated' : 'inactivated'; - - return res.status(200).send({ - ids: accountsIds, - message: `The given accounts have been ${activatedText} successfully`, - }); - } catch (error) { - next(error); - } - } - - /** - * Deletes accounts in bulk. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async deleteBulkAccounts(req: Request, res: Response, next: NextFunction) { - const { ids: accountsIds } = req.query; - const { tenantId } = req; - - try { - await this.accountsService.deleteAccounts(tenantId, accountsIds); - - return res.status(200).send({ - ids: accountsIds, - message: 'The given accounts have been deleted successfully.', - }); - } catch (error) { - next(error); - } - } - /** * Retrieve accounts datatable list. * @param {Request} req diff --git a/server/src/api/controllers/Contacts/Contacts.ts b/server/src/api/controllers/Contacts/Contacts.ts index 5a6d79866..f2c257639 100644 --- a/server/src/api/controllers/Contacts/Contacts.ts +++ b/server/src/api/controllers/Contacts/Contacts.ts @@ -304,14 +304,4 @@ export default class ContactsController extends BaseController { get specificContactSchema(): ValidationChain[] { return [param('id').exists().isNumeric().toInt()]; } - - /** - * @returns {ValidationChain[]} - */ - get bulkContactsSchema(): ValidationChain[] { - return [ - query('ids').isArray({ min: 1 }), - query('ids.*').isNumeric().toInt(), - ]; - } } diff --git a/server/src/api/controllers/Contacts/Vendors.ts b/server/src/api/controllers/Contacts/Vendors.ts index 31a4f50f7..d47ec60fd 100644 --- a/server/src/api/controllers/Contacts/Vendors.ts +++ b/server/src/api/controllers/Contacts/Vendors.ts @@ -219,28 +219,6 @@ export default class VendorsController extends ContactsController { } } - /** - * Deletes vendors in bulk. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async deleteBulkVendors(req: Request, res: Response, next: NextFunction) { - const { ids: contactsIds } = req.query; - const { tenantId, user } = req; - - try { - await this.vendorsService.deleteBulkVendors(tenantId, contactsIds, user) - - return res.status(200).send({ - ids: contactsIds, - message: 'The vendors have been deleted successfully.', - }); - } catch (error) { - next(error); - } - } - /** * Retrieve vendors datatable list. * @param {Request} req diff --git a/server/src/api/controllers/Currencies.ts b/server/src/api/controllers/Currencies.ts index 1c8417936..f878ceea7 100644 --- a/server/src/api/controllers/Currencies.ts +++ b/server/src/api/controllers/Currencies.ts @@ -49,13 +49,17 @@ export default class CurrenciesController extends BaseController { get currencyDTOSchemaValidation(): ValidationChain[] { return [ - check('currency_name').exists().trim().escape(), - check('currency_code').exists().trim().escape(), + check('currency_name').exists().trim(), + check('currency_code').exists().trim(), + check('currency_sign').exists().trim(), ]; } get currencyEditDTOSchemaValidation(): ValidationChain[] { - return [check('currency_name').exists().trim().escape()]; + return [ + check('currency_name').exists().trim(), + check('currency_sign').exists().trim(), + ]; } get currencyIdParamSchema(): ValidationChain[] { @@ -84,7 +88,10 @@ export default class CurrenciesController extends BaseController { try { const currencies = await this.currenciesService.listCurrencies(tenantId); - return res.status(200).send({ currencies: [...currencies] }); + + return res.status(200).send({ + currencies: this.transfromToResponse(currencies), + }); } catch (error) { next(error); } @@ -142,7 +149,7 @@ export default class CurrenciesController extends BaseController { async editCurrency(req: Request, res: Response, next: Function) { const { tenantId } = req; const { id: currencyId } = req.params; - const { body: editCurrencyDTO } = req; + const editCurrencyDTO = this.matchedBodyData(req); try { const currency = await this.currenciesService.editCurrency( @@ -180,7 +187,22 @@ export default class CurrenciesController extends BaseController { } if (error.errorType === 'currency_code_exists') { return res.boom.badRequest(null, { - errors: [{ type: 'CURRENCY_CODE_EXISTS', code: 200 }], + errors: [{ + type: 'CURRENCY_CODE_EXISTS', + message: 'The given currency code is already exists.', + code: 200, + }], + }); + } + if (error.errorType === 'CANNOT_DELETE_BASE_CURRENCY') { + return res.boom.badRequest(null, { + errors: [ + { + type: 'CANNOT_DELETE_BASE_CURRENCY', + code: 300, + message: 'Cannot delete the base currency.', + }, + ], }); } } diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index 1df27843b..c30dca97e 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -121,13 +121,6 @@ export default class ExpensesController extends BaseController { return [param('id').exists().isNumeric().toInt()]; } - get bulkSelectSchema() { - return [ - query('ids').isArray({ min: 1 }), - query('ids.*').isNumeric().toInt(), - ]; - } - get expensesListSchema() { return [ query('custom_view_id').optional().isNumeric().toInt(), diff --git a/server/src/api/controllers/ItemCategories.ts b/server/src/api/controllers/ItemCategories.ts index 22aa6b20a..73b6acf8e 100644 --- a/server/src/api/controllers/ItemCategories.ts +++ b/server/src/api/controllers/ItemCategories.ts @@ -40,13 +40,6 @@ export default class ItemsCategoriesController extends BaseController { asyncMiddleware(this.newCategory.bind(this)), this.handlerServiceError ); - router.delete( - '/', - [...this.categoriesBulkValidationSchema], - this.validationResult, - asyncMiddleware(this.bulkDeleteCategories.bind(this)), - this.handlerServiceError - ); router.delete( '/:id', [...this.specificCategoryValidationSchema], @@ -103,16 +96,6 @@ export default class ItemsCategoriesController extends BaseController { ]; } - /** - * Validate items categories bulk actions. - */ - get categoriesBulkValidationSchema() { - return [ - query('ids').isArray({ min: 1 }), - query('ids.*').isNumeric().toInt(), - ]; - } - /** * Validate items categories schema. */ @@ -263,31 +246,6 @@ export default class ItemsCategoriesController extends BaseController { } } - /** - * Bulk delete the given item categories. - * @param {Request} req - - * @param {Response} res - - * @return {Response} - */ - async bulkDeleteCategories(req: Request, res: Response, next: NextFunction) { - const itemCategoriesIds = req.query.ids; - const { tenantId, user } = req; - - try { - await this.itemCategoriesService.deleteItemCategories( - tenantId, - itemCategoriesIds, - user - ); - return res.status(200).send({ - ids: itemCategoriesIds, - message: 'The item categories have been deleted successfully.', - }); - } catch (error) { - next(error); - } - } - /** * Handles service error. * @param {Error} error diff --git a/server/src/api/controllers/Items.ts b/server/src/api/controllers/Items.ts index 697addee5..67df445ef 100644 --- a/server/src/api/controllers/Items.ts +++ b/server/src/api/controllers/Items.ts @@ -172,17 +172,6 @@ export default class ItemsController extends BaseController { return [param('id').exists().isNumeric().toInt()]; } - /** - * Bulk select validation schema. - * @return {ValidationChain[]} - */ - get validateBulkSelectSchema(): ValidationChain[] { - return [ - query('ids').isArray({ min: 1 }), - query('ids.*').isNumeric().toInt(), - ]; - } - /** * Validate list query schema */ diff --git a/server/src/api/controllers/ManualJournals.ts b/server/src/api/controllers/ManualJournals.ts index 9b38ed1b4..eec84227a 100644 --- a/server/src/api/controllers/ManualJournals.ts +++ b/server/src/api/controllers/ManualJournals.ts @@ -35,13 +35,6 @@ export default class ManualJournalsController extends BaseController { asyncMiddleware(this.getManualJournal.bind(this)), this.catchServiceErrors.bind(this) ); - router.post( - '/publish', - [...this.manualJournalIdsSchema], - this.validationResult, - asyncMiddleware(this.publishManualJournals.bind(this)), - this.catchServiceErrors.bind(this) - ); router.post( '/:id/publish', [...this.manualJournalParamSchema], @@ -63,13 +56,6 @@ export default class ManualJournalsController extends BaseController { asyncMiddleware(this.deleteManualJournal.bind(this)), this.catchServiceErrors.bind(this) ); - router.delete( - '/', - [...this.manualJournalIdsSchema], - this.validationResult, - asyncMiddleware(this.deleteBulkManualJournals.bind(this)), - this.catchServiceErrors.bind(this) - ); router.post( '/', [...this.manualJournalValidationSchema], @@ -87,16 +73,6 @@ export default class ManualJournalsController extends BaseController { return [param('id').exists().isNumeric().toInt()]; } - /** - * Manual journal bulk ids validation schema. - */ - get manualJournalIdsSchema() { - return [ - query('ids').isArray({ min: 1 }), - query('ids.*').isNumeric().toInt(), - ]; - } - /** * Manual journal DTO schema. */ @@ -277,34 +253,6 @@ export default class ManualJournalsController extends BaseController { } } - /** - * Publish the given manual journals in bulk. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async publishManualJournals(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const { ids: manualJournalsIds } = req.query; - - try { - const { - meta: { alreadyPublished, published, total }, - } = await this.manualJournalsService.publishManualJournals( - tenantId, - manualJournalsIds - ); - - return res.status(200).send({ - ids: manualJournalsIds, - message: 'The manual journals have been published successfully.', - meta: { alreadyPublished, published, total }, - }); - } catch (error) { - next(error); - } - } - /** * Delete the given manual journal. * @param {Request} req @@ -330,35 +278,6 @@ export default class ManualJournalsController extends BaseController { } } - /** - * Deletes manual journals in bulk. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async deleteBulkManualJournals( - req: Request, - res: Response, - next: NextFunction - ) { - const { tenantId } = req; - const { ids: manualJournalsIds } = req.query; - - try { - await this.manualJournalsService.deleteManualJournals( - tenantId, - manualJournalsIds - ); - - return res.status(200).send({ - ids: manualJournalsIds, - message: 'Manual journal have been delete successfully.', - }); - } catch (error) { - next(error); - } - } - /** * Retrieve manual journals list. * @param {Request} req diff --git a/server/src/database/migrations/20200419171451_create_currencies_table.js b/server/src/database/migrations/20200419171451_create_currencies_table.js index c2d847140..4d06717b9 100644 --- a/server/src/database/migrations/20200419171451_create_currencies_table.js +++ b/server/src/database/migrations/20200419171451_create_currencies_table.js @@ -4,6 +4,7 @@ exports.up = function(knex) { table.increments(); table.string('currency_name').index(); table.string('currency_code', 4).index(); + table.string('currency_sign').index(); table.timestamps(); }).raw('ALTER TABLE `CURRENCIES` AUTO_INCREMENT = 1000'); }; diff --git a/server/src/interfaces/Currency.ts b/server/src/interfaces/Currency.ts index 112d62ac5..2bcfc5620 100644 --- a/server/src/interfaces/Currency.ts +++ b/server/src/interfaces/Currency.ts @@ -3,14 +3,17 @@ export interface ICurrencyDTO { currencyName: string, currencyCode: string, + currencySign: string, }; export interface ICurrencyEditDTO { currencyName: string, + currencySign: string, } export interface ICurrency { id: number, currencyName: string, currencyCode: string, + currencySign: string, createdAt: Date, updatedAt: Date, }; diff --git a/server/src/services/Currencies/CurrenciesService.ts b/server/src/services/Currencies/CurrenciesService.ts index 18e7702ed..adb94cfa5 100644 --- a/server/src/services/Currencies/CurrenciesService.ts +++ b/server/src/services/Currencies/CurrenciesService.ts @@ -16,7 +16,8 @@ import TenancyService from 'services/Tenancy/TenancyService'; const ERRORS = { CURRENCY_NOT_FOUND: 'currency_not_found', CURRENCY_CODE_EXISTS: 'currency_code_exists', - BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID' + BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID', + CANNOT_DELETE_BASE_CURRENCY: 'CANNOT_DELETE_BASE_CURRENCY' }; @Service() @@ -131,6 +132,7 @@ export default class CurrenciesService implements ICurrenciesService { tenantId, currencyDTO, }); + // Validate currency code uniquiness. await this.validateCurrencyCodeUniquiness( tenantId, currencyDTO.currencyCode @@ -174,6 +176,22 @@ export default class CurrenciesService implements ICurrenciesService { return currency; } + /** + * Validate cannot delete base currency. + * @param {number} tenantId + * @param {string} currencyCode + */ + validateCannotDeleteBaseCurrency(tenantId: number, currencyCode: string) { + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); + if (baseCurrency === currencyCode) { + throw new ServiceError(ERRORS.CANNOT_DELETE_BASE_CURRENCY); + } + } + /** * Delete the given currency code. * @param {number} tenantId @@ -192,6 +210,9 @@ export default class CurrenciesService implements ICurrenciesService { await this.getCurrencyByCodeOrThrowError(tenantId, currencyCode); + // Validate currency code not equals base currency. + await this.validateCannotDeleteBaseCurrency(tenantId, currencyCode); + await Currency.query().where('currency_code', currencyCode).delete(); this.logger.info('[currencies] the currency deleted successfully.', { tenantId, @@ -207,10 +228,20 @@ export default class CurrenciesService implements ICurrenciesService { public async listCurrencies(tenantId: number): Promise { const { Currency } = this.tenancy.models(tenantId); + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); + const currencies = await Currency.query().onBuild((query) => { query.orderBy('createdAt', 'ASC'); }); - return currencies; + const formattedCurrencies = currencies.map((currency) => ({ + isBaseCurrency: baseCurrency === currency.currencyCode, + ...currency, + })); + return formattedCurrencies; } /** diff --git a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts index 7b4ef5526..5fb30b244 100644 --- a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts +++ b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts @@ -95,8 +95,7 @@ export default class BalanceSheetStatementService // Settings tenant service. const settings = this.tenancy.settings(tenantId); const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', + group: 'organization', key: 'base_currency', }); const filter = {