refactoring: custom views service.

fix: constraints of delete item from storage.
fix: constraints of delete item category from storage.
fix: localize database seeds files.
fix: view meta data in accounts list response.
This commit is contained in:
Ahmed Bouhuolia
2020-10-05 19:09:56 +02:00
parent 0114ed9f8b
commit 99e6fe273f
64 changed files with 1593 additions and 1103 deletions

View File

@@ -127,18 +127,12 @@ export default class AccountsController extends BaseController{
];
}
/**
* Account param schema validation.
*/
get accountParamSchema() {
return [
param('id').exists().isNumeric().toInt()
];
}
/**
* Accounts list schema validation.
*/
get accountsListSchema() {
return [
query('custom_view_id').optional().isNumeric().toInt(),
@@ -149,9 +143,6 @@ export default class AccountsController extends BaseController{
];
}
/**
*
*/
get bulkSelectIdsQuerySchema() {
return [
query('ids').isArray({ min: 2 }),
@@ -328,8 +319,12 @@ export default class AccountsController extends BaseController{
filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles);
}
try {
const accounts = await this.accountsService.getAccountsList(tenantId, filter);
return res.status(200).send({ accounts });
const { accounts, filterMeta } = await this.accountsService.getAccountsList(tenantId, filter);
return res.status(200).send({
accounts,
filter_meta: this.transfromToResponse(filterMeta)
});
} catch (error) {
next(error);
}
@@ -358,9 +353,8 @@ export default class AccountsController extends BaseController{
}
if (error.errorType === 'account_type_not_found') {
return res.boom.badRequest(
'The given account type not found.', {
errors: [{ type: 'ACCOUNT_TYPE_NOT_FOUND', code: 200 }]
}
'The given account type not found.',
{ errors: [{ type: 'ACCOUNT_TYPE_NOT_FOUND', code: 200 }] }
);
}
if (error.errorType === 'account_type_not_allowed_to_changed') {

View File

@@ -1,6 +1,6 @@
import { Response, Request, NextFunction } from 'express';
import { matchedData, validationResult } from "express-validator";
import { camelCase, omit } from "lodash";
import { camelCase, snakeCase, omit } from "lodash";
import { mapKeysDeep } from 'utils'
export default class BaseController {
@@ -55,4 +55,12 @@ export default class BaseController {
}
next();
}
/**
* Transform the given data to response.
* @param {any} data
*/
transfromToResponse(data: any) {
return mapKeysDeep(data, (v, k) => snakeCase(k));
}
}

View File

@@ -46,6 +46,12 @@ export default class CustomersController extends ContactsController {
this.validationResult,
asyncMiddleware(this.deleteBulkCustomers.bind(this))
);
router.get('/', [
],
this.validationResult,
asyncMiddleware(this.getCustomersList.bind(this))
);
router.get('/:id', [
...this.specificContactSchema,
],
@@ -193,4 +199,15 @@ export default class CustomersController extends ContactsController {
next(error);
}
}
async getCustomersList(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
try {
await this.customersService.getCustomersList(tenantId)
} catch (error) {
next(error);
}
}
}

View File

@@ -30,7 +30,8 @@ export default class ExpensesController extends BaseController {
asyncMiddleware(this.newExpense.bind(this)),
this.catchServiceErrors,
);
router.post('/publish', [
router.post(
'/publish', [
...this.bulkSelectSchema,
],
this.bulkPublishExpenses.bind(this),
@@ -69,7 +70,9 @@ export default class ExpensesController extends BaseController {
this.catchServiceErrors,
);
router.get(
'/',
'/', [
...this.expensesListSchema,
],
asyncMiddleware(this.getExpensesList.bind(this)),
this.dynamicListService.handlerErrorsToResponse,
this.catchServiceErrors,
@@ -89,6 +92,7 @@ export default class ExpensesController extends BaseController {
check('currency_code').optional(),
check('exchange_rate').optional().isNumeric().toFloat(),
check('publish').optional().isBoolean().toBoolean(),
check('categories').exists().isArray({ min: 1 }),
check('categories.*.index').exists().isNumeric().toInt(),
check('categories.*.expense_account_id').exists().isNumeric().toInt(),
@@ -120,6 +124,20 @@ export default class ExpensesController extends BaseController {
];
}
get expensesListSchema() {
return [
query('custom_view_id').optional().isNumeric().toInt(),
query('stringified_filter_roles').optional().isJSON(),
query('column_sort_by').optional(),
query('sort_order').optional().isIn(['desc', 'asc']),
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
];
}
/**
* Creates a new expense on
* @param {Request} req
@@ -240,12 +258,23 @@ export default class ExpensesController extends BaseController {
const filter = {
filterRoles: [],
sortOrder: 'asc',
columnSortBy: 'created_at',
page: 1,
pageSize: 12,
...this.matchedQueryData(req),
};
if (filter.stringifiedFilterRoles) {
filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles);
}
try {
const expenses = await this.expensesService.getExpensesList(tenantId, filter);
return res.status(200).send({ expenses });
const { expenses, pagination, filterMeta } = await this.expensesService.getExpensesList(tenantId, filter);
return res.status(200).send({
expenses,
pagination: this.transfromToResponse(pagination),
filter_meta: this.transfromToResponse(filterMeta),
});
} catch (error) {
next(error);
}
@@ -259,29 +288,34 @@ export default class ExpensesController extends BaseController {
catchServiceErrors(error: Error, req: Request, res: Response, next: NextFunction) {
if (error instanceof ServiceError) {
if (error.errorType === 'expense_not_found') {
return res.boom.badRequest(null, {
errors: [{ type: 'EXPENSE_NOT_FOUND', code: 100 }],
});
return res.boom.badRequest(
'Expense not found.',
{ errors: [{ type: 'EXPENSE_NOT_FOUND', code: 100 }] }
);
}
if (error.errorType === 'total_amount_equals_zero') {
return res.boom.badRequest(null, {
errors: [{ type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 200 }],
});
return res.boom.badRequest(
'Expense total should not equal zero.',
{ errors: [{ type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 200 }] },
);
}
if (error.errorType === 'payment_account_not_found') {
return res.boom.badRequest(null, {
errors: [{ type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 300 }],
});
return res.boom.badRequest(
'Payment account not found.',
{ errors: [{ type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 300 }] },
);
}
if (error.errorType === 'some_expenses_not_found') {
return res.boom.badRequest(null, {
errors: [{ type: 'SOME.EXPENSE.ACCOUNTS.NOT.FOUND', code: 400 }]
})
return res.boom.badRequest(
'Some expense accounts not found.',
{ errors: [{ type: 'SOME.EXPENSE.ACCOUNTS.NOT.FOUND', code: 400 }] },
);
}
if (error.errorType === 'payment_account_has_invalid_type') {
return res.boom.badRequest(null, {
errors: [{ type: 'PAYMENT.ACCOUNT.HAS.INVALID.TYPE', code: 500 }],
});
return res.boom.badRequest(
'Payment account has invalid type.',
{ errors: [{ type: 'PAYMENT.ACCOUNT.HAS.INVALID.TYPE', code: 500 }], },
);
}
if (error.errorType === 'expenses_account_has_invalid_type') {
return res.boom.badRequest(null, {

View File

@@ -10,12 +10,16 @@ import asyncMiddleware from 'api/middleware/asyncMiddleware';
import { IItemCategoryOTD } from 'interfaces';
import { ServiceError } from 'exceptions';
import BaseController from 'api/controllers/BaseController';
import DynamicListingService from 'services/DynamicListing/DynamicListService';
@Service()
export default class ItemsCategoriesController extends BaseController {
@Inject()
itemCategoriesService: ItemCategoriesService;
@Inject()
dynamicListService: DynamicListingService;
/**
* Router constructor method.
*/
@@ -64,6 +68,7 @@ export default class ItemsCategoriesController extends BaseController {
this.validationResult,
asyncMiddleware(this.getList.bind(this)),
this.handlerServiceError,
this.dynamicListService.handlerErrorsToResponse,
);
return router;
}
@@ -112,8 +117,9 @@ export default class ItemsCategoriesController extends BaseController {
*/
get categoriesListValidationSchema() {
return [
query('column_sort_order').optional().trim().escape(),
query('column_sort_by').optional().trim().escape(),
query('sort_order').optional().trim().escape().isIn(['desc', 'asc']),
query('stringified_filter_roles').optional().isJSON(),
];
}
@@ -189,13 +195,21 @@ export default class ItemsCategoriesController extends BaseController {
*/
async getList(req: Request, res: Response, next: NextFunction) {
const { tenantId, user } = req;
const itemCategoriesFilter = this.matchedQueryData(req);
const itemCategoriesFilter = {
filterRoles: [],
sortOrder: 'asc',
columnSortBy: 'created_at',
...this.matchedQueryData(req),
};
try {
const itemCategories = await this.itemCategoriesService.getItemCategoriesList(
const { itemCategories, filterMeta } = await this.itemCategoriesService.getItemCategoriesList(
tenantId, itemCategoriesFilter, user,
);
return res.status(200).send({ item_categories: itemCategories });
return res.status(200).send({
item_categories: itemCategories,
filter_meta: this.transfromToResponse(filterMeta),
});
} catch (error) {
next(error);
}

View File

@@ -149,10 +149,12 @@ export default class ItemsController extends BaseController {
*/
get validateListQuerySchema() {
return [
query('column_sort_order').optional().trim().escape(),
query('column_sort_by').optional().trim().escape(),
query('sort_order').optional().isIn(['desc', 'asc']),
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
query('custom_view_id').optional().isNumeric().toInt(),
query('stringified_filter_roles').optional().isJSON(),
]
@@ -239,14 +241,22 @@ export default class ItemsController extends BaseController {
const filter = {
filterRoles: [],
sortOrder: 'asc',
columnSortBy: 'created_at',
page: 1,
pageSize: 12,
...this.matchedQueryData(req),
};
if (filter.stringifiedFilterRoles) {
filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles);
}
try {
const items = await this.itemsService.itemsList(tenantId, filter);
return res.status(200).send({ items });
const { items, pagination, filterMeta } = await this.itemsService.itemsList(tenantId, filter);
return res.status(200).send({
items,
pagination: this.transfromToResponse(pagination),
filter_meta: this.transfromToResponse(filterMeta),
});
} catch (error) {
next(error);
}
@@ -344,6 +354,16 @@ export default class ItemsController extends BaseController {
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.CURRENT.ASSET', code: 300 }],
});
}
if (error.errorType === 'ITEMS_HAVE_ASSOCIATED_TRANSACTIONS') {
return res.status(400).send({
errors: [{ type: 'ITEMS_HAVE_ASSOCIATED_TRANSACTIONS', code: 310 }],
});
}
if (error.errorType === 'ITEM_HAS_ASSOCIATED_TRANSACTINS') {
return res.status(400).send({
errors: [{ type: 'ITEM_HAS_ASSOCIATED_TRANSACTINS', code: 320 }],
})
}
}
}
}

View File

@@ -299,17 +299,22 @@ export default class ManualJournalsController extends BaseController {
sortOrder: 'asc',
columnSortBy: 'created_at',
filterRoles: [],
page: 1,
pageSize: 12,
...this.matchedQueryData(req),
}
if (filter.stringifiedFilterRoles) {
filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles);
}
try {
const manualJournals = await this.manualJournalsService.getManualJournals(tenantId, filter);
return res.status(200).send({ manualJournals });
} catch (error) {
console.log(error);
const { manualJournals, pagination, filterMeta } = await this.manualJournalsService.getManualJournals(tenantId, filter);
return res.status(200).send({
manual_journals: manualJournals,
pagination: this.transfromToResponse(pagination),
filter_meta: this.transfromToResponse(filterMeta),
});
} catch (error) {
next(error);
}
}

View File

@@ -381,6 +381,20 @@ export default class BillsPayments extends BaseController {
* @return {Response}
*/
async getBillsPayments(req: Request, res: Response) {
const { tenantId } = req.params;
const billPaymentsFilter = this.matchedQueryData(req);
try {
const { billPayments, pagination, filterMeta } = await this.billPaymentService
.listBillPayments(tenantId, billPaymentsFilter);
return res.status(200).send({
bill_payments: billPayments,
pagination: this.transfromToResponse(pagination),
filter_meta: this.transfromToResponse(filterMeta)
});
} catch (error) {
next(error);
}
}
}

View File

@@ -1,115 +0,0 @@
import express from 'express';
import {
param,
query,
} from 'express-validator';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/:resource_slug/data',
this.resourceData.validation,
asyncMiddleware(this.resourceData.handler));
router.get('/:resource_slug/columns',
this.resourceColumns.validation,
asyncMiddleware(this.resourceColumns.handler));
router.get('/:resource_slug/fields',
this.resourceFields.validation,
asyncMiddleware(this.resourceFields.handler));
return router;
},
/**
* Retrieve resource data of the given resource key/slug.
*/
resourceData: {
validation: [
param('resource_slug').trim().escape().exists(),
],
async handler(req, res) {
const { AccountType } = req.models;
const { resource_slug: resourceSlug } = req.params;
const data = await AccountType.query();
return res.status(200).send({
data,
resource_slug: resourceSlug,
});
},
},
/**
* Retrieve resource columns of the given resource.
*/
resourceColumns: {
validation: [
param('resource_slug').trim().escape().exists(),
],
async handler(req, res) {
const { resource_slug: resourceSlug } = req.params;
const { Resource } = req.models;
const resource = await Resource.query()
.where('name', resourceSlug)
.withGraphFetched('fields')
.first();
if (!resource) {
return res.status(400).send({
errors: [{ type: 'RESOURCE.SLUG.NOT.FOUND', code: 200 }],
});
}
const resourceFields = resource.fields
.filter((field) => field.columnable)
.map((field) => ({
id: field.id,
label: field.labelName,
key: field.key,
}));
return res.status(200).send({
resource_columns: resourceFields,
resource_slug: resourceSlug,
});
},
},
/**
* Retrieve resource fields of the given resource.
*/
resourceFields: {
validation: [
param('resource_slug').trim().escape().exists(),
query('predefined').optional().isBoolean().toBoolean(),
query('builtin').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const { resource_slug: resourceSlug } = req.params;
const { Resource } = req.models;
const resource = await Resource.query()
.where('name', resourceSlug)
.withGraphFetched('fields')
.first();
if (!resource) {
return res.status(400).send({
errors: [{ type: 'RESOURCE.SLUG.NOT.FOUND', code: 200 }],
});
}
return res.status(200).send({
resource_fields: resource.fields,
resource_slug: resourceSlug,
});
},
},
};

View File

@@ -0,0 +1,44 @@
import { Router, Request, Response, NextFunction } from 'express';
import {
param,
query,
} from 'express-validator';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseController from './BaseController';
import { Service } from 'typedi';
import ResourceFieldsKeys from 'data/ResourceFieldsKeys';
@Service()
export default class ResourceController extends BaseController{
/**
* Router constructor.
*/
router() {
const router = Router();
router.get('/:resource_model/fields',
this.resourceModelParamSchema,
asyncMiddleware(this.resourceFields.bind(this))
);
return router;
}
get resourceModelParamSchema() {
return [
param('resource_model').exists().trim().escape(),
];
}
/**
* Retrieve resource fields of the given resource.
*/
resourceFields(req: Request, res: Response, next: NextFunction) {
const { resource_model: resourceModel } = req.params;
try {
} catch (error) {
next(error);
}
}
};

View File

@@ -297,7 +297,21 @@ export default class SalesEstimatesController extends BaseController {
* @param {Request} req
* @param {Response} res
*/
async getEstimates(req: Request, res: Response) {
async getEstimates(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const estimatesFilter: ISalesEstimatesFilter = this.matchedQueryData(req);
try {
const { salesEstimates, pagination, filterMeta } = await this.saleEstimateService
.estimatesList(tenantId, estimatesFilter);
return res.status(200).send({
sales_estimates: this.transfromToResponse(salesEstimates),
pagination,
filter_meta: this.transfromToResponse(filterMeta),
})
} catch (error) {
next(error);
}
}
};

View File

@@ -1,4 +1,4 @@
import { Router, Request, Response } from 'express';
import { Router, Request, Response, NextFunction } from 'express';
import { check, param, query, matchedData } from 'express-validator';
import { difference } from 'lodash';
import { raw } from 'objection';
@@ -7,7 +7,8 @@ import BaseController from '../BaseController';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import SaleInvoiceService from 'services/Sales/SalesInvoices';
import ItemsService from 'services/Items/ItemsService';
import { ISaleInvoiceOTD } from 'interfaces';
import DynamicListingService from 'services/DynamicListing/DynamicListService';
import { ISaleInvoiceOTD, ISalesInvoicesFilter } from 'interfaces';
@Service()
export default class SaleInvoicesController extends BaseController{
@@ -17,6 +18,9 @@ export default class SaleInvoicesController extends BaseController{
@Inject()
saleInvoiceService: SaleInvoiceService;
@Inject()
dynamicListService: DynamicListingService;
/**
* Router constructor.
*/
@@ -412,7 +416,21 @@ export default class SaleInvoicesController extends BaseController{
* @param {Response} res
* @param {Function} next
*/
async getSalesInvoices(req, res) {
public async getSalesInvoices(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req.params;
const salesInvoicesFilter: ISalesInvoicesFilter = req.query;
try {
const { salesInvoices, filterMeta, pagination } = await this.saleInvoiceService.salesInvoicesList(
tenantId, salesInvoicesFilter,
);
return res.status(200).send({
sales_invoices: salesInvoices,
pagination: this.transfromToResponse(pagination),
filter_meta: this.transfromToResponse(filterMeta),
});
} catch (error) {
next(error);
}
}
}

View File

@@ -274,7 +274,6 @@ export default class SalesReceiptsController extends BaseController{
const { id: saleReceiptId } = req.params;
const saleReceipt = { ...req.body };
const errorReasons = [];
// Handle all errors with reasons messages.
@@ -296,7 +295,19 @@ export default class SalesReceiptsController extends BaseController{
* @param {Request} req
* @param {Response} res
*/
async listingSalesReceipts(req: Request, res: Response) {
async getSalesReceipts(req: Request, res: Response) {
const { tenantId } = req;
const filter = {
sortOrder: 'asc',
page: 1,
pageSize: 12,
...this.matchedBodyData(req),
};
try {
} catch (error) {
next(error);
}
}
};

View File

@@ -222,19 +222,22 @@ export default class UsersController extends BaseController{
}
if (error instanceof ServiceError) {
if (error.errorType === 'user_not_found') {
return res.status(404).send({
errors: [{ type: 'USER.NOT.FOUND', code: 100 }],
});
return res.boom.badRequest(
'User not found.',
{ errors: [{ type: 'USER.NOT.FOUND', code: 100 }] }
);
}
if (error.errorType === 'user_already_active') {
return res.status(404).send({
errors: [{ type: 'USER.ALREADY.ACTIVE', code: 200 }],
});
return res.boom.badRequest(
'User is already active.',
{ errors: [{ type: 'USER.ALREADY.ACTIVE', code: 200 }] },
);
}
if (error.errorType === 'user_already_inactive') {
return res.status(404).send({
errors: [{ type: 'USER.ALREADY.INACTIVE', code: 200 }],
});
return res.boom.badRequest(
'User is already inactive.',
{ errors: [{ type: 'USER.ALREADY.INACTIVE', code: 200 }] },
);
}
if (error.errorType === 'user_same_the_authorized_user') {
return res.boom.badRequest(

View File

@@ -1,473 +0,0 @@
import { difference, pick } from 'lodash';
import express from 'express';
import {
check,
query,
param,
oneOf,
validationResult,
} from 'express-validator';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import {
validateViewRoles,
} from 'lib/ViewRolesBuilder';
export default {
resource: 'items',
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/',
this.listViews.validation,
asyncMiddleware(this.listViews.handler));
router.post('/',
this.createView.validation,
asyncMiddleware(this.createView.handler));
router.post('/:view_id',
this.editView.validation,
asyncMiddleware(this.editView.handler));
router.delete('/:view_id',
this.deleteView.validation,
asyncMiddleware(this.deleteView.handler));
router.get('/:view_id',
asyncMiddleware(this.getView.handler));
router.get('/:view_id/resource',
this.getViewResource.validation,
asyncMiddleware(this.getViewResource.handler));
return router;
},
/**
* List all views that associated with the given resource.
*/
listViews: {
validation: [
oneOf([
query('resource_name').exists().trim().escape(),
], [
query('resource_id').exists().isNumeric().toInt(),
]),
],
async handler(req, res) {
const { Resource, View } = req.models;
const filter = { ...req.query };
const resource = await Resource.query().onBuild((builder) => {
if (filter.resource_id) {
builder.where('id', filter.resource_id);
}
if (filter.resource_name) {
builder.where('name', filter.resource_name);
}
builder.first();
});
const views = await View.query().where('resource_id', resource.id);
return res.status(200).send({ views });
},
},
/**
* Retrieve view details of the given view id.
*/
getView: {
validation: [
param('view_id').exists().isNumeric().toInt(),
],
async handler(req, res) {
const { view_id: viewId } = req.params;
const { View } = req.models;
const view = await View.query()
.where('id', viewId)
.withGraphFetched('resource')
.withGraphFetched('columns')
.withGraphFetched('roles.field')
.first();
if (!view) {
return res.boom.notFound(null, {
errors: [{ type: 'VIEW_NOT_FOUND', code: 100 }],
});
}
return res.status(200).send({ view: view.toJSON() });
},
},
/**
* Delete the given view of the resource.
*/
deleteView: {
validation: [
param('view_id').exists().isNumeric().toInt(),
],
async handler(req, res) {
const { View } = req.models;
const { view_id: viewId } = req.params;
const view = await View.query().findById(viewId);
if (!view) {
return res.boom.notFound(null, {
errors: [{ type: 'VIEW_NOT_FOUND', code: 100 }],
});
}
if (view.predefined) {
return res.boom.badRequest(null, {
errors: [{ type: 'PREDEFINED_VIEW', code: 200 }],
});
}
await Promise.all([
view.$relatedQuery('roles').delete(),
view.$relatedQuery('columns').delete(),
]);
await View.query().where('id', view.id).delete();
return res.status(200).send({ id: view.id });
},
},
/**
* Creates a new view.
*/
createView: {
validation: [
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(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const {
Resource,
View,
ViewColumn,
ViewRole,
} = req.models;
const form = { roles: [], ...req.body };
const resource = await Resource.query().where('name', form.resource_name).first();
if (!resource) {
return res.boom.notFound(null, {
errors: [{ type: 'RESOURCE_NOT_FOUND', code: 100 }],
});
}
const errorReasons = [];
const fieldsSlugs = form.roles.map((role) => role.field_key);
const resourceFields = await resource.$relatedQuery('fields');
const resourceFieldsKeys = resourceFields.map((f) => f.key);
const resourceFieldsKeysMap = new Map(resourceFields.map((field) => [field.key, field]));
const columnsKeys = form.columns.map((c) => c.key);
// The difference between the stored resource fields and submit fields keys.
const notFoundFields = difference(fieldsSlugs, resourceFieldsKeys);
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);
if (notFoundColumns.length > 0) {
errorReasons.push({ type: 'COLUMNS_NOT_EXIST', code: 200, columns: notFoundColumns });
}
// Validates the view conditional logic expression.
if (!validateViewRoles(form.roles, form.logic_expression)) {
errorReasons.push({ type: 'VIEW.ROLES.LOGIC.EXPRESSION.INVALID', code: 400 });
}
if (errorReasons.length > 0) {
return res.boom.badRequest(null, { errors: errorReasons });
}
// Save view details.
const view = await View.query().insert({
name: form.name,
predefined: false,
resource_id: resource.id,
roles_logic_expression: form.logic_expression,
});
// Save view roles async operations.
const saveViewRolesOpers = [];
form.roles.forEach((role) => {
const fieldModel = resourceFieldsKeysMap.get(role.field_key);
const saveViewRoleOper = ViewRole.query().insert({
...pick(role, ['comparator', 'value', 'index']),
field_id: fieldModel.id,
view_id: view.id,
});
saveViewRolesOpers.push(saveViewRoleOper);
});
form.columns.forEach((column) => {
const fieldModel = resourceFieldsKeysMap.get(column.key);
const saveViewColumnOper = ViewColumn.query().insert({
field_id: fieldModel.id,
view_id: view.id,
index: column.index,
});
saveViewRolesOpers.push(saveViewColumnOper);
});
await Promise.all(saveViewRolesOpers);
return res.status(200).send({ id: view.id });
},
},
/**
* Edit the given custom view metadata.
*/
editView: {
validation: [
param('view_id').exists().isNumeric().toInt(),
check('name').exists().escape().trim(),
check('logic_expression').exists().trim().escape(),
check('columns').exists().isArray({ min: 1 }),
check('columns.*.id').optional().isNumeric().toInt(),
check('columns.*.key').exists().escape().trim(),
check('columns.*.index').exists().isNumeric().toInt(),
check('roles').isArray(),
check('roles.*.id').optional().isNumeric().toInt(),
check('roles.*.field_key').exists().escape().trim(),
check('roles.*.comparator').exists(),
check('roles.*.value').exists(),
check('roles.*.index').exists().isNumeric().toInt(),
],
async handler(req, res) {
const { view_id: viewId } = req.params;
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const {
View, ViewRole, ViewColumn, Resource,
} = req.models;
const view = await View.query().where('id', viewId)
.withGraphFetched('roles.field')
.withGraphFetched('columns')
.first();
if (!view) {
return res.boom.notFound(null, {
errors: [{ type: 'ROLE_NOT_FOUND', code: 100 }],
});
}
const form = { ...req.body };
const resource = await Resource.query()
.where('id', view.resourceId)
.withGraphFetched('fields')
.withGraphFetched('views')
.first();
const errorReasons = [];
const fieldsSlugs = form.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 = form.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(form.roles, form.logic_expression)) {
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 = form.roles.filter((r) => r.id);
const formInsertRoles = form.roles.filter((r) => !r.id);
const formRolesIds = formUpdatedRoles.map((r) => r.id);
const formUpdatedColumns = form.columns.filter((r) => r.id);
const formInsertedColumns = form.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: form.name,
roles_logic_expression: form.logic_expression,
});
// 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);
return res.status(200).send();
},
},
/**
* Retrieve resource columns that associated to the given custom view.
*/
getViewResource: {
validation: [
param('view_id').exists().isNumeric().toInt(),
],
async handler(req, res) {
const { view_id: viewId } = req.params;
const { View } = req.models;
const view = await View.query()
.where('id', viewId)
.withGraphFetched('resource.fields')
.first();
if (!view) {
return res.boom.notFound(null, {
errors: [{ type: 'VIEW.NOT.FOUND', code: 100 }],
});
}
if (!view.resource) {
return res.boom.badData(null, {
errors: [{ type: 'VIEW.HAS.NOT.ASSOCIATED.RESOURCE', code: 200 }],
});
}
const resourceColumns = view.resource.fields
.filter((field) => field.columnable)
.map((field) => ({
id: field.id,
label: field.labelName,
key: field.key,
}));
return res.status(200).send({
resource_slug: view.resource.name,
resource_columns: resourceColumns,
resource_fields: view.resource.fields,
});
}
},
};

View File

@@ -1,19 +1,10 @@
import { Inject, Service } from 'typedi';
import { Router, Request, NextFunction, Response } from 'express';
import {
check,
query,
param,
oneOf,
validationResult,
} from 'express-validator';
import { check, param } 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 BaseController from 'api/controllers/BaseController';
import { IViewDTO, IViewEditDTO } from 'interfaces';
import { ServiceError } from 'exceptions';
@Service()
@@ -27,66 +18,96 @@ export default class ViewsController extends BaseController{
router() {
const router = Router();
router.get('/', [
...this.viewDTOSchemaValidation,
router.get('/resource/:resource_model', [
...this.viewsListSchemaValidation,
],
asyncMiddleware(this.listViews)
this.validationResult,
asyncMiddleware(this.listResourceViews.bind(this)),
this.handlerServiceErrors,
);
router.post('/', [
...this.viewDTOSchemaValidation,
],
asyncMiddleware(this.createView)
this.validationResult,
asyncMiddleware(this.createView.bind(this)),
this.handlerServiceErrors
);
router.post('/:view_id', [
...this.viewDTOSchemaValidation,
router.post('/:id', [
...this.viewParamSchemaValidation,
...this.viewEditDTOSchemaValidation,
],
asyncMiddleware(this.editView)
this.validationResult,
asyncMiddleware(this.editView.bind(this)),
this.handlerServiceErrors,
);
router.delete('/:view_id', [
router.delete('/:id', [
...this.viewParamSchemaValidation
],
asyncMiddleware(this.deleteView));
router.get('/:view_id', [
...this.viewParamSchemaValidation
]
asyncMiddleware(this.getView)
this.validationResult,
asyncMiddleware(this.deleteView.bind(this)),
this.handlerServiceErrors,
);
router.get('/:view_id/resource', [
router.get('/:id', [
...this.viewParamSchemaValidation
],
asyncMiddleware(this.getViewResource)
this.validationResult,
asyncMiddleware(this.getView.bind(this)),
);
return router;
}
/**
* New view DTO schema validation.
*/
get viewDTOSchemaValidation() {
return [
check('resource_name').exists().escape().trim(),
check('resource_model').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.*.field_key').exists().escape().trim(),
check('columns.*.index').exists().isNumeric().toInt(),
];
}
/**
* Edit view DTO schema validation.
*/
get viewEditDTOSchemaValidation() {
return [
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.*.field_key').exists().escape().trim(),
check('columns.*.index').exists().isNumeric().toInt(),
];
}
get viewParamSchemaValidation() {
return [
]
param('id').exists().isNumeric().toInt(),
];
}
get viewsListSchemaValidation() {
return [
param('resource_model').exists().trim().escape(),
]
}
/**
* List all views that associated with the given resource.
@@ -94,12 +115,12 @@ export default class ViewsController extends BaseController{
* @param {Response} res -
* @param {NextFunction} next -
*/
listViews(req: Request, res: Response, next: NextFunction) {
async listResourceViews(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const filter = req.query;
const { resource_model: resourceModel } = req.params;
try {
const views = this.viewsService.listViews(tenantId, filter);
const views = await this.viewsService.listResourceViews(tenantId, resourceModel);
return res.status(200).send({ views });
} catch (error) {
next(error);
@@ -107,16 +128,17 @@ export default class ViewsController extends BaseController{
}
/**
*
* Retrieve view details with assocaited roles and columns.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
getView(req: Request, res: Response, next: NextFunction) {
async getView(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const { id: viewId } = req.params;
try {
const view = this.viewsService.getView(tenantId, viewId);
const view = await this.viewsService.getView(tenantId, viewId);
return res.status(200).send({ view });
} catch (error) {
next(error);
@@ -129,13 +151,13 @@ export default class ViewsController extends BaseController{
* @param {Response} res -
* @param {NextFunction} next -
*/
createView(req: Request, res: Response, next: NextFunction) {
async 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 });
const view = await this.viewsService.newView(tenantId, viewDTO);
return res.status(200).send({ id: view.id });
} catch (error) {
next(error);
}
@@ -147,10 +169,10 @@ export default class ViewsController extends BaseController{
* @param {Response} res -
* @param {NextFunction} next -
*/
editView(req: Request, res: Response, next: NextFunction) {
async editView(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const { id: viewId } = req.params;
const { body: viewEditDTO } = req;
const viewEditDTO: IViewEditDTO = this.matchedBodyData(req);
try {
await this.viewsService.editView(tenantId, viewId, viewEditDTO);
@@ -166,7 +188,7 @@ export default class ViewsController extends BaseController{
* @param {Response} res -
* @param {NextFunction} next -
*/
deleteView(req: Request, res: Response, next: NextFunction) {
async deleteView(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const { id: viewId } = req.params;
@@ -187,6 +209,16 @@ export default class ViewsController extends BaseController{
*/
handlerServiceErrors(error: Error, req: Request, res: Response, next: NextFunction) {
if (error instanceof ServiceError) {
if (error.errorType === 'VIEW_NAME_NOT_UNIQUE') {
return res.boom.badRequest(null, {
errors: [{ type: 'VIEW_NAME_NOT_UNIQUE', code: 110 }],
});
}
if (error.errorType === 'RESOURCE_MODEL_NOT_FOUND') {
return res.boom.badRequest(null, {
errors: [{ type: 'RESOURCE_MODEL_NOT_FOUND', code: 150, }],
});
}
if (error.errorType === 'INVALID_LOGIC_EXPRESSION') {
return res.boom.badRequest(null, {
errors: [{ type: 'VIEW.ROLES.LOGIC.EXPRESSION.INVALID', code: 400 }],
@@ -212,6 +244,17 @@ export default class ViewsController extends BaseController{
errors: [{ type: 'PREDEFINED_VIEW', code: 200 }],
});
}
if (error.errorType === 'RESOURCE_FIELDS_KEYS_NOT_FOUND') {
return res.boom.badRequest(null, {
errors: [{ type: 'RESOURCE_FIELDS_KEYS_NOT_FOUND', code: 300 }],
})
}
if (error.errorType === 'RESOURCE_COLUMNS_KEYS_NOT_FOUND') {
return res.boom.badRequest(null, {
errors: [{ type: 'RESOURCE_COLUMNS_KEYS_NOT_FOUND', code: 310 }],
})
}
}
next(error);
}
};