diff --git a/server/src/api/controllers/Accounts.ts b/server/src/api/controllers/Accounts.ts index 0a34c7c10..259137bbc 100644 --- a/server/src/api/controllers/Accounts.ts +++ b/server/src/api/controllers/Accounts.ts @@ -83,8 +83,9 @@ export default class AccountsController extends BaseController{ this.catchServiceErrors, ); router.delete( - '/', - this.bulkDeleteSchema, + '/', [ + ...this.bulkDeleteSchema, + ], this.validationResult, asyncMiddleware(this.deleteBulkAccounts.bind(this)), this.catchServiceErrors, @@ -279,8 +280,9 @@ export default class AccountsController extends BaseController{ const { ids: accountsIds } = req.query; try { - const isActive = (type === 'activate' ? 1 : 0); - await this.accountsService.activateAccounts(tenantId, accountsIds, isActive) + const isActive = (type === 'activate' ? true : false); + await this.accountsService.activateAccounts(tenantId, accountsIds, isActive); + return res.status(200).send({ ids: accountsIds }); } catch (error) { next(error); diff --git a/server/src/api/controllers/Views.ts b/server/src/api/controllers/Views.ts new file mode 100644 index 000000000..e82a8ae64 --- /dev/null +++ b/server/src/api/controllers/Views.ts @@ -0,0 +1,217 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, NextFunction, Response } from 'express'; +import { + check, + query, + param, + oneOf, + validationResult, +} from 'express-validator'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; +import { + validateViewRoles, +} from 'lib/ViewRolesBuilder'; +import ViewsService from 'services/Views/ViewsService'; +import BaseController from './BaseController'; +import { IViewDTO } from 'interfaces'; +import { ServiceError } from 'exceptions'; + +@Service() +export default class ViewsController extends BaseController{ + @Inject() + viewsService: ViewsService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get('/', [ + ...this.viewDTOSchemaValidation, + ], + asyncMiddleware(this.listViews) + ); + router.post('/', [ + ...this.viewDTOSchemaValidation, + ], + asyncMiddleware(this.createView) + ); + + router.post('/:view_id', [ + ...this.viewDTOSchemaValidation, + ], + asyncMiddleware(this.editView) + ); + + router.delete('/:view_id', [ + ...this.viewParamSchemaValidation + ], + asyncMiddleware(this.deleteView)); + + router.get('/:view_id', [ + ...this.viewParamSchemaValidation + ] + asyncMiddleware(this.getView) + ); + + router.get('/:view_id/resource', [ + ...this.viewParamSchemaValidation + ], + asyncMiddleware(this.getViewResource) + ); + + return router; + } + + get viewDTOSchemaValidation() { + return [ + check('resource_name').exists().escape().trim(), + check('name').exists().escape().trim(), + check('logic_expression').exists().trim().escape(), + check('roles').isArray({ min: 1 }), + check('roles.*.field_key').exists().escape().trim(), + check('roles.*.comparator').exists(), + check('roles.*.value').exists(), + check('roles.*.index').exists().isNumeric().toInt(), + check('columns').exists().isArray({ min: 1 }), + check('columns.*.key').exists().escape().trim(), + check('columns.*.index').exists().isNumeric().toInt(), + ]; + } + + get viewParamSchemaValidation() { + return [ + + ] + } + + + + /** + * List all views that associated with the given resource. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + listViews(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const filter = req.query; + + try { + const views = this.viewsService.listViews(tenantId, filter); + return res.status(200).send({ views }); + } catch (error) { + next(error); + } + } + + /** + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + getView(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + + try { + const view = this.viewsService.getView(tenantId, viewId); + return res.status(200).send({ view }); + } catch (error) { + next(error); + } + } + + /** + * Creates a new view. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + createView(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const viewDTO: IViewDTO = this.matchedBodyData(req); + + try { + await this.viewsService.newView(tenantId, viewDTO); + return res.status(200).send({ id: 1 }); + } catch (error) { + next(error); + } + } + + /** + * Edits views metadata. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + editView(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: viewId } = req.params; + const { body: viewEditDTO } = req; + + try { + await this.viewsService.editView(tenantId, viewId, viewEditDTO); + return res.status(200).send({ id: viewId }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given view. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + deleteView(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: viewId } = req.params; + + try { + await this.viewsService.deleteView(tenantId, viewId); + return res.status(200).send(); + } catch (error) { + next(error); + } + } + + /** + * Handles service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + handlerServiceErrors(error: Error, req: Request, res: Response, next: NextFunction) { + if (error instanceof ServiceError) { + if (error.errorType === 'INVALID_LOGIC_EXPRESSION') { + return res.boom.badRequest(null, { + errors: [{ type: 'VIEW.ROLES.LOGIC.EXPRESSION.INVALID', code: 400 }], + }); + } + if (error.errorType === '') { + return res.boom.badRequest(null, { + errors: [{ type: 'RESOURCE_FIELDS_NOT_EXIST', code: 100 }], + }); + } + if (error.errorType === '') { + return res.boom.badRequest(null, { + errors: [{ type: 'COLUMNS_NOT_EXIST', code: 200 }], + }); + } + if (error.errorType === 'VIEW_NOT_FOUND') { + return res.boom.notFound(null, { + errors: [{ type: 'VIEW_NOT_FOUND', code: 100 }], + }); + } + if (error.errorType === 'VIEW_PREDEFINED') { + return res.boom.badRequest(null, { + errors: [{ type: 'PREDEFINED_VIEW', code: 200 }], + }); + } + } + } +}; diff --git a/server/src/interfaces/Account.ts b/server/src/interfaces/Account.ts index 751c349b8..e381552f7 100644 --- a/server/src/interfaces/Account.ts +++ b/server/src/interfaces/Account.ts @@ -4,14 +4,22 @@ export interface IAccountDTO { name: string, code: string, description: string, - accountTypeNumber: number, + accountTypeId: number, + parentAccountId: number, + active: boolean, }; export interface IAccount { name: string, + slug: string, code: string, description: string, - accountTypeNumber: number, + accountTypeId: number, + parentAccountId: number, + active: boolean, + predefined: boolean, + amount: number, + currencyCode: string, }; export interface IAccountsFilter extends IDynamicListFilterDTO { diff --git a/server/src/services/Accounts/AccountsService.ts b/server/src/services/Accounts/AccountsService.ts index d36ea7f3a..825835b7e 100644 --- a/server/src/services/Accounts/AccountsService.ts +++ b/server/src/services/Accounts/AccountsService.ts @@ -263,7 +263,7 @@ export default class AccountsService { * @param {IAccount} account */ private throwErrorIfAccountPredefined(account: IAccount) { - if (account.prefined) { + if (account.predefined) { throw new ServiceError('account_predefined'); } } @@ -344,6 +344,11 @@ export default class AccountsService { return storedAccounts; } + /** + * Validate whether one of the given accounts is predefined. + * @param {IAccount[]} accounts - + * @return {IAccount[]} - Predefined accounts + */ private validatePrefinedAccounts(accounts: IAccount[]) { const predefined = accounts.filter((account: IAccount) => account.predefined); @@ -405,7 +410,7 @@ export default class AccountsService { */ public async activateAccounts(tenantId: number, accountsIds: number[], activate: boolean = true) { const { Account } = this.tenancy.models(tenantId); - const accounts = await this.getAccountsOrThrowError(tenantId, accountsIds); + await this.getAccountsOrThrowError(tenantId, accountsIds); this.logger.info('[account] trying activate/inactive the given accounts ids.', { accountsIds }); await Account.query().whereIn('id', accountsIds) @@ -438,7 +443,7 @@ export default class AccountsService { * @param {number} tenantId * @param {IAccountsFilter} accountsFilter */ - async getAccountsList(tenantId: number, filter: IAccountsFilter) { + public async getAccountsList(tenantId: number, filter: IAccountsFilter) { const { Account } = this.tenancy.models(tenantId); const dynamicList = await this.dynamicListService.dynamicList(tenantId, Account, filter); diff --git a/server/src/services/Views/ViewsService.ts b/server/src/services/Views/ViewsService.ts new file mode 100644 index 000000000..6af82e033 --- /dev/null +++ b/server/src/services/Views/ViewsService.ts @@ -0,0 +1,288 @@ +import { Service, Inject } from "typedi"; +import { pick, difference } from 'lodash'; +import { ServiceError } from 'exceptions'; +import { + IViewsService, + IViewDTO, + IView, + IViewRole, + IViewHasColumn, +} from 'interfaces'; +import TenancyService from 'services/Tenancy/TenancyService'; +import { validateRolesLogicExpression } from 'lib/ViewRolesBuilder'; + +const ERRORS = { + VIEW_NOT_FOUND: 'VIEW_NOT_FOUND', + VIEW_PREDEFINED: 'VIEW_PREDEFINED', + INVALID_LOGIC_EXPRESSION: 'INVALID_LOGIC_EXPRESSION', +}; + +@Service() +export default class ViewsService implements IViewsService { + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + /** + * Listing resource views. + * @param {number} tenantId + * @param {string} resourceModel + */ + public async listViews(tenantId: number, resourceModel: string) { + const { View } = this.tenancy.models(tenantId); + return View.query().where('resource_model', resourceModel); + } + + validateResourceFieldsExistance() { + + } + + validateResourceColumnsExistance() { + + } + + getView(tenantId: number, viewId: number) { + + } + + /** + * Precedures. + * ----–––––– + * - Validate resource fields existance. + * - Validate resource columns existance. + * - Validate view logic expression. + * - Store view to the storage. + * - Store view columns to the storage. + * - Store view roles/conditions to the storage. + * --------- + * @param {number} tenantId - Tenant id. + * @param {IViewDTO} viewDTO - View DTO. + */ + async newView(tenantId: number, viewDTO: IViewDTO): Promise { + const { View, ViewColumn, ViewRole } = this.tenancy.models(tenantId); + + this.logger.info('[views] trying to create a new view.', { tenantId, viewDTO }); + // Validates the view conditional logic expression. + if (!validateRolesLogicExpression(viewDTO.logicExpression, viewDTO.roles)) { + throw new ServiceError(ERRORS.INVALID_LOGIC_EXPRESSION); + } + // Save view details. + const view = await View.query().insert({ + name: viewDTO.name, + predefined: false, + rolesLogicExpression: viewDTO.logicExpression, + }); + this.logger.info('[views] inserted to the storage.', { tenantId, viewDTO }); + + // Save view roles async operations. + const saveViewRolesOpers = []; + + viewDTO.roles.forEach((role) => { + const saveViewRoleOper = ViewRole.query().insert({ + ...pick(role, ['fieldKey', 'comparator', 'value', 'index']), + viewId: view.id, + }); + saveViewRolesOpers.push(saveViewRoleOper); + }); + + viewDTO.columns.forEach((column) => { + const saveViewColumnOper = ViewColumn.query().insert({ + viewId: view.id, + index: column.index, + }); + saveViewRolesOpers.push(saveViewColumnOper); + }); + this.logger.info('[views] roles and columns inserted to the storage.', { tenantId, viewDTO }); + + await Promise.all(saveViewRolesOpers); + } + + /** + * + * @param {number} tenantId + * @param {number} viewId + * @param {IViewEditDTO} + */ + async editView(tenantId: number, viewId: number, viewEditDTO: IViewEditDTO) { + const { View, ViewRole, ViewColumn } = req.models; + const view = await View.query().where('id', viewId) + .withGraphFetched('roles.field') + .withGraphFetched('columns') + .first(); + + const errorReasons = []; + const fieldsSlugs = viewEditDTO.roles.map((role) => role.field_key); + const resourceFieldsKeys = resource.fields.map((f) => f.key); + const resourceFieldsKeysMap = new Map(resource.fields.map((field) => [field.key, field])); + const columnsKeys = viewEditDTO.columns.map((c) => c.key); + + // The difference between the stored resource fields and submit fields keys. + const notFoundFields = difference(fieldsSlugs, resourceFieldsKeys); + + // Validate not found resource fields keys. + if (notFoundFields.length > 0) { + errorReasons.push({ + type: 'RESOURCE_FIELDS_NOT_EXIST', code: 100, fields: notFoundFields, + }); + } + // The difference between the stored resource fields and the submit columns keys. + const notFoundColumns = difference(columnsKeys, resourceFieldsKeys); + + // Validate not found view columns. + if (notFoundColumns.length > 0) { + errorReasons.push({ type: 'RESOURCE_COLUMNS_NOT_EXIST', code: 200, columns: notFoundColumns }); + } + // Validates the view conditional logic expression. + if (!validateViewRoles(viewEditDTO.roles, viewEditDTO.logicExpression)) { + errorReasons.push({ type: 'VIEW.ROLES.LOGIC.EXPRESSION.INVALID', code: 400 }); + } + + const viewRolesIds = view.roles.map((r) => r.id); + const viewColumnsIds = view.columns.map((c) => c.id); + + const formUpdatedRoles = viewEditDTO.roles.filter((r) => r.id); + const formInsertRoles = viewEditDTO.roles.filter((r) => !r.id); + + const formRolesIds = formUpdatedRoles.map((r) => r.id); + + const formUpdatedColumns = viewEditDTO.columns.filter((r) => r.id); + const formInsertedColumns = viewEditDTO.columns.filter((r) => !r.id); + const formColumnsIds = formUpdatedColumns.map((r) => r.id); + + const rolesIdsShouldDeleted = difference(viewRolesIds, formRolesIds); + const columnsIdsShouldDelete = difference(viewColumnsIds, formColumnsIds); + + const notFoundViewRolesIds = difference(formRolesIds, viewRolesIds); + const notFoundViewColumnsIds = difference(viewColumnsIds, viewColumnsIds); + + // Validate the not found view roles ids. + if (notFoundViewRolesIds.length) { + errorReasons.push({ type: 'VIEW.ROLES.IDS.NOT.FOUND', code: 500, ids: notFoundViewRolesIds }); + } + // Validate the not found view columns ids. + if (notFoundViewColumnsIds.length) { + errorReasons.push({ type: 'VIEW.COLUMNS.IDS.NOT.FOUND', code: 600, ids: notFoundViewColumnsIds }); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + const asyncOpers = []; + + // Save view details. + await View.query() + .where('id', view.id) + .patch({ + name: viewEditDTO.name, + roles_logic_expression: viewEditDTO.logicExpression, + }); + + // Update view roles. + if (formUpdatedRoles.length > 0) { + formUpdatedRoles.forEach((role) => { + const fieldModel = resourceFieldsKeysMap.get(role.field_key); + const updateOper = ViewRole.query() + .where('id', role.id) + .update({ + ...pick(role, ['comparator', 'value', 'index']), + field_id: fieldModel.id, + }); + asyncOpers.push(updateOper); + }); + } + // Insert a new view roles. + if (formInsertRoles.length > 0) { + formInsertRoles.forEach((role) => { + const fieldModel = resourceFieldsKeysMap.get(role.field_key); + const insertOper = ViewRole.query() + .insert({ + ...pick(role, ['comparator', 'value', 'index']), + field_id: fieldModel.id, + view_id: view.id, + }); + asyncOpers.push(insertOper); + }); + } + // Delete view roles. + if (rolesIdsShouldDeleted.length > 0) { + const deleteOper = ViewRole.query() + .whereIn('id', rolesIdsShouldDeleted) + .delete(); + asyncOpers.push(deleteOper); + } + // Insert a new view columns to the storage. + if (formInsertedColumns.length > 0) { + formInsertedColumns.forEach((column) => { + const fieldModel = resourceFieldsKeysMap.get(column.key); + const insertOper = ViewColumn.query() + .insert({ + field_id: fieldModel.id, + index: column.index, + view_id: view.id, + }); + asyncOpers.push(insertOper); + }); + } + // Update the view columns on the storage. + if (formUpdatedColumns.length > 0) { + formUpdatedColumns.forEach((column) => { + const updateOper = ViewColumn.query() + .where('id', column.id) + .update({ + index: column.index, + }); + asyncOpers.push(updateOper); + }); + } + // Delete the view columns from the storage. + if (columnsIdsShouldDelete.length > 0) { + const deleteOper = ViewColumn.query() + .whereIn('id', columnsIdsShouldDelete) + .delete(); + asyncOpers.push(deleteOper); + } + await Promise.all(asyncOpers); + } + + /** + * Retrieve views details of the given id or throw not found error. + * @param {number} tenantId + * @param {number} viewId + */ + private async getViewByIdOrThrowError(tenantId: number, viewId: number): Promise { + const { View } = this.tenancy.models(tenantId); + + this.logger.info('[views] get stored view.', { tenantId, viewId }); + const view = await View.query().findById(viewId); + + if (!view) { + this.logger.info('[views] the given id not found.', { tenantId, viewId }); + throw new ServiceError(ERRORS.VIEW_NOT_FOUND); + } + return view; + } + + /** + * Deletes the given view with associated roles and columns. + * @param {number} tenantId - Tenant id. + * @param {number} viewId - View id. + */ + public async deleteView(tenantId: number, viewId: number): Promise { + const { View } = this.tenancy.models(tenantId); + + this.logger.info('[views] trying to delete the given view.', { tenantId, viewId }); + const view = await this.getViewByIdOrThrowError(tenantId, viewId); + + if (view.predefined) { + this.logger.info('[views] cannot delete predefined.', { tenantId, viewId }); + throw new ServiceError(ERRORS.VIEW_PREDEFINED); + } + await Promise.all([ + view.$relatedQuery('roles').delete(), + view.$relatedQuery('columns').delete(), + ]); + await View.query().where('id', view.id).delete(); + this.logger.info('[views] deleted successfully.', { tenantId, viewId }); + } +} \ No newline at end of file