From d915813195c5e82ec7f023c6ded227ba07b8e9c4 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 8 Jun 2020 18:50:04 +0200 Subject: [PATCH] feat: Customers resource. --- server/src/database/factories/index.js | 6 + .../20200607212203_create_customers_table.js | 38 +- server/src/database/seeds/seed_resources.js | 1 + server/src/http/controllers/Customers.js | 375 ++++++++++++++++-- server/src/http/index.js | 2 +- server/src/models/Customer.js | 11 + server/tests/routes/customers.test.js | 212 ++++++++++ server/tests/routes/expenses.test.js | 4 +- 8 files changed, 613 insertions(+), 36 deletions(-) create mode 100644 server/src/models/Customer.js create mode 100644 server/tests/routes/customers.test.js diff --git a/server/src/database/factories/index.js b/server/src/database/factories/index.js index 7c2079634..dff209519 100644 --- a/server/src/database/factories/index.js +++ b/server/src/database/factories/index.js @@ -294,5 +294,11 @@ export default (tenantDb) => { }; }); + factory.define('customer', 'customers', async () => { + return { + customer_type: 'business', + }; + }); + return factory; } diff --git a/server/src/database/migrations/20200607212203_create_customers_table.js b/server/src/database/migrations/20200607212203_create_customers_table.js index 99dc14fcb..7815f7eea 100644 --- a/server/src/database/migrations/20200607212203_create_customers_table.js +++ b/server/src/database/migrations/20200607212203_create_customers_table.js @@ -1,8 +1,42 @@ exports.up = function(knex) { - + return knex.schema.createTable('customers', table => { + table.increments(); + + table.string('customer_type'); + table.string('first_name').nullable(); + table.string('last_name').nullable(); + table.string('company_name').nullable(); + + table.string('display_name'); + + table.string('email').nullable(); + table.string('work_phone').nullable(); + table.string('personal_phone').nullable(); + + table.string('billing_address_1').nullable(); + table.string('billing_address_2').nullable(); + table.string('billing_address_city').nullable(); + table.string('billing_address_country').nullable(); + table.string('billing_address_email').nullable(); + table.string('billing_address_zipcode').nullable(); + table.string('billing_address_phone').nullable(); + table.string('billing_address_state').nullable(), + + table.string('shipping_address_1').nullable(); + table.string('shipping_address_2').nullable(); + table.string('shipping_address_city').nullable(); + table.string('shipping_address_country').nullable(); + table.string('shipping_address_email').nullable(); + table.string('shipping_address_zipcode').nullable(); + table.string('shipping_address_phone').nullable(); + table.string('shipping_address_state').nullable(); + + table.text('note'); + table.boolean('active').defaultTo(true); + }); }; exports.down = function(knex) { - + return knex.schema.dropTableIfExists('customers'); }; diff --git a/server/src/database/seeds/seed_resources.js b/server/src/database/seeds/seed_resources.js index 710ffe686..e9c2ec45c 100644 --- a/server/src/database/seeds/seed_resources.js +++ b/server/src/database/seeds/seed_resources.js @@ -10,6 +10,7 @@ exports.seed = (knex) => { { id: 3, name: 'expenses' }, { id: 4, name: 'manual_journals' }, { id: 5, name: 'items_categories' }, + { id: 6, name: 'customers' }, ]); }); }; diff --git a/server/src/http/controllers/Customers.js b/server/src/http/controllers/Customers.js index 4a56bd130..c706a2d62 100644 --- a/server/src/http/controllers/Customers.js +++ b/server/src/http/controllers/Customers.js @@ -5,10 +5,63 @@ import { query, validationResult, } from 'express-validator'; +import { pick } from 'lodash'; import asyncMiddleware from '@/http/middleware/asyncMiddleware'; +import { + mapViewRolesToConditionals, + mapFilterRolesToDynamicFilter, +} from '@/lib/ViewRolesBuilder'; +import { + DynamicFilter, + DynamicFilterSortBy, + DynamicFilterViews, + DynamicFilterFilterRoles, +} from '@/lib/DynamicFilter'; + + +const validatioRoles = [ + check('customer_type') + .exists() + .isIn(['individual', 'business']) + .trim() + .escape(), + check('first_name').optional().trim().escape(), + check('last_name').optional().trim().escape(), + + check('company_name').optional().trim().escape(), + + check('display_name').exists().trim().escape(), + + check('email').optional().isEmail().trim().escape(), + check('work_phone').optional().trim().escape(), + check('personal_phone').optional().trim().escape(), + + check('billing_address_city').optional().trim().escape(), + check('billing_address_country').optional().trim().escape(), + check('billing_address_email').optional().isEmail().trim().escape(), + check('billing_address_zipcode').optional().trim().escape(), + check('billing_address_phone').optional().trim().escape(), + check('billing_address_state').optional().trim().escape(), + + check('shipping_address_city').optional().trim().escape(), + check('shipping_address_country').optional().trim().escape(), + check('shipping_address_email').optional().isEmail().trim().escape(), + check('shipping_address_zip_code').optional().trim().escape(), + check('shipping_address_phone').optional().trim().escape(), + check('shipping_address_state').optional().trim().escape(), + + check('note').optional().trim().escape(), + check('active').optional().isBoolean().toBoolean(), + + check('custom_fields').optional().isArray({ min: 1 }), + check('custom_fields.*.key').exists().trim().escape(), + check('custom_fields.*.value').exists(), +]; export default { - + /** + * Router constructor. + */ router() { const router = express.Router(); @@ -20,52 +73,312 @@ export default { this.editCustomer.validation, asyncMiddleware(this.editCustomer.handler)); + router.delete('/:id', + this.deleteCustomer.validation, + asyncMiddleware(this.deleteCustomer.handler)); + + router.get('/', + this.listCustomers.validation, + asyncMiddleware(this.listCustomers.handler)); + + router.get('/:id', + this.getCustomer.validation, + asyncMiddleware(this.getCustomer.handler)); + return router; }, + /** + * Retrieve customers list with pagination and custom view metadata. + */ + listCustomers: { + validation: [ + query('column_sort_order').optional().isIn(['created_at']), + 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(), + ], + async handler(req, res) { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const { Resource, View, Customer } = req.models; + const errorReasons = []; + + const customersResource = await Resource.query() + .where('name', 'customers') + .withGraphFetched('fields') + .first(); + + if (!customersResource) { + return res.status(400).send({ + errors: [{ type: 'CUSTOMERS.RESOURCE.NOT.FOUND', code: 200 }], + }); + } + + const filter = { + column_sort_order: '', + sort_order: '', + page: 1, + page_size: 10, + custom_view_id: null, + filter_roles: [], + ...req.query, + }; + if (filter.stringified_filter_roles) { + filter.filter_roles = JSON.parse(filter.stringified_filter_roles); + } + const view = await View.query().onBuild((builder) => { + if (filter.custom_view_id) { + builder.where('id', filter.custom_view_id); + } else { + builder.where('favourite', true); + } + builder.where('resource_id', customersResource.id); + builder.withGraphFetched('roles.field'); + builder.withGraphFetched('columns'); + builder.first(); + }); + const resourceFieldsKeys = customersResource.fields.map((c) => c.key); + const dynamicFilter = new DynamicFilter(Customer.tableName); + + // Dynamic filter with view roles. + if (view && view.roles.length > 0) { + const viewFilter = new DynamicFilterViews( + mapViewRolesToConditionals(view.roles), + view.rolesLogicExpression, + ); + if (!viewFilter.validateFilterRoles()) { + errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 }); + } + dynamicFilter.setFilter(viewFilter); + } + + // Dynamic filter with filter roles. + if (filter.filter_roles.length > 0) { + // Validate the accounts resource fields. + const filterRoles = new DynamicFilterFilterRoles( + mapFilterRolesToDynamicFilter(filter.filter_roles), + customersResource.fields, + ); + dynamicFilter.setFilter(filterRoles); + + if (filterRoles.validateFilterRoles().length > 0) { + errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 }); + } + } + + // Dynamic filter with column sort order. + if (filter.column_sort_order) { + if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) { + errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 }); + } + const sortByFilter = new DynamicFilterSortBy( + filter.column_sort_order, + filter.sort_order, + ); + dynamicFilter.setFilter(sortByFilter); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + // Customers query. + const customers = await Customer.query().onBuild((builder) => { + dynamicFilter.buildQuery()(builder); + }).pagination(filter.page - 1, filter.page_size); + + return res.status(200).send({ + customers, + ...(view) ? { + customViewId: view.id, + } : {}, + }); + } + }, + + /** + * Submit a new customer details. + */ newCustomer: { validation: [ - check('custom_type').exists().trim().escape(), - check('first_name').exists().trim().escape(), - check('last_name'), - check('company_name'), - check('email'), - check('work_phone'), - check('personal_phone'), - - check('billing_address.country'), - check('billing_address.address'), - check('billing_address.city'), - check('billing_address.phone'), - check('billing_address.zip_code'), - - check('shiping_address.country'), - check('shiping_address.address'), - check('shiping_address.city'), - check('shiping_address.phone'), - check('shiping_address.zip_code'), - - check('contact.additional_phone'), - check('contact.additional_email'), - - check('custom_fields').optional().isArray({ min: 1 }), - check('custom_fields.*.key').exists().trim().escape(), - check('custom_fields.*.value').exists(), - - check('inactive').optional().isBoolean().toBoolean(), + ...validatioRoles, ], - async handler(req, res) { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const { Customer } = req.models; + const form = { ...req.body }; + + const customer = await Customer.query().insertAndFetch({ + ...pick(form, [ + 'customer_type', + 'first_name', + 'last_name', + 'company_name', + 'display_name', + + 'email', + 'work_phone', + 'personal_phone', + + 'billing_address_1', + 'billing_address_2', + 'billing_address_city', + 'billing_address_country', + 'billing_address_email', + 'billing_address_zipcode', + 'billing_address_phone', + 'billing_address_state', + + 'shipping_address_1', + 'shipping_address_2', + 'shipping_address_city', + 'shipping_address_country', + 'shipping_address_email', + 'shipping_address_zipcode', + 'shipping_address_phone', + 'shipping_address_state', + + 'note', + 'active', + ]), + }); + + return res.status(200).send({ id: customer.id }); }, }, + /** + * Edit details of the given customer id. + */ editCustomer: { validation: [ - + param('id').exists().isNumeric().toInt(), + ...validatioRoles, ], async handler(req, res) { + const validationErrors = validationResult(req); + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const { id } = req.params; + const form = { ...req.body }; + const { Customer } = req.models; + const customer = await Customer.query().where('id', id).first(); + + if (!customer) { + return res.status(404).send({ + errors: [{ type: 'CUSTOMER.NOT.FOUND', code: 200 }], + }); + } + + await Customer.query().where('id', id).patch({ + ...pick(form, [ + 'customer_type', + 'first_name', + 'last_name', + 'company_name', + 'display_name', + + 'email', + 'work_phone', + 'personal_phone', + + 'billing_address_1', + 'billing_address_2', + 'billing_address_city', + 'billing_address_country', + 'billing_address_email', + 'billing_address_zipcode', + 'billing_address_phone', + 'billing_address_state', + + 'shipping_address_1', + 'shipping_address_2', + 'shipping_address_city', + 'shipping_address_country', + 'shipping_address_email', + 'shipping_address_zipcode', + 'shipping_address_phone', + 'shipping_address_state', + + 'note', + 'active', + ]), + }); + return res.status(200).send({ id }); }, }, + + /** + * Retrieve details of the given customer id. + */ + getCustomer: { + validation: [ + param('id').exists().isNumeric().toInt(), + ], + async handler(req, res) { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const { Customer } = req.models; + const { id } = req.params; + const customer = await Customer.query().where('id', id).first(); + + if (!customer) { + return res.status(404).send({ + errors: [{ type: 'CUSTOMER.NOT.FOUND', code: 200 }], + }); + } + return res.status(200).send({ customer }); + }, + }, + + /** + * Delete the given customer. + */ + deleteCustomer: { + validation: [ + param('id').exists().isNumeric().toInt(), + ], + async handler(req, res) { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const { Customer } = req.models; + const { id } = req.params; + const customer = await Customer.query().where('id', id).first(); + + if (!customer) { + return res.status(404).send({ + errors: [{ type: 'CUSTOMER.NOT.FOUND', code: 200 }], + }); + } + await Customer.query().where('id', id).delete(); + + return res.status(200).send(); + }, + } }; diff --git a/server/src/http/index.js b/server/src/http/index.js index 253ab77c6..2e1cd0d95 100644 --- a/server/src/http/index.js +++ b/server/src/http/index.js @@ -56,7 +56,7 @@ export default (app) => { dashboard.use('/api/financial_statements', FinancialStatements.router()); dashboard.use('/api/options', Options.router()); // app.use('/api/budget_reports', BudgetReports.router()); - // dashboard.use('/api/customers', Customers.router()); + dashboard.use('/api/customers', Customers.router()); // app.use('/api/suppliers', Suppliers.router()); // app.use('/api/bills', Bills.router()); // app.use('/api/budget', Budget.router()); diff --git a/server/src/models/Customer.js b/server/src/models/Customer.js new file mode 100644 index 000000000..3c5d88365 --- /dev/null +++ b/server/src/models/Customer.js @@ -0,0 +1,11 @@ +import { Model } from 'objection'; +import TenantModel from '@/models/TenantModel'; + +export default class Customer extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'customers'; + } +} diff --git a/server/tests/routes/customers.test.js b/server/tests/routes/customers.test.js new file mode 100644 index 000000000..0fcd7d52e --- /dev/null +++ b/server/tests/routes/customers.test.js @@ -0,0 +1,212 @@ +import { + request, + expect, +} from '~/testInit'; +import Currency from '@/models/Currency'; +import { + tenantWebsite, + tenantFactory, + loginRes +} from '~/dbInit'; +import Customer from '../../src/models/Customer'; + +describe('route: `/customers`', () => { + describe('POST: `/customers`', () => { + it('Should response unauthorized in case the user was not logged in.', async () => { + const res = await request() + .post('/api/customers') + .send({}); + + expect(res.status).equals(401); + expect(res.body.message).equals('Unauthorized'); + }); + + it('Should `display_name` be required field.', async () => { + const res = await request() + .post('/api/customers') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send({ + + }); + + expect(res.status).equals(422); + expect(res.body.errors).include.something.deep.equals({ + msg: 'Invalid value', param: 'display_name', location: 'body', + }) + }); + + it('Should `customer_type` be required field', async () => { + const res = await request() + .post('/api/customers') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send(); + + expect(res.status).equals(422); + expect(res.body.errors).include.something.deep.equals({ + msg: 'Invalid value', param: 'customer_type', location: 'body', + }); + }); + + it('Should store the customer data to the storage.', async () => { + const res = await request() + .post('/api/customers') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send({ + customer_type: 'business', + + first_name: 'Ahmed', + last_name: 'Bouhuolia', + + company_name: 'Bigcapital', + + display_name: 'Ahmed Bouhuolia, Bigcapital', + + email: 'a.bouhuolia@live.com', + work_phone: '0927918381', + personal_phone: '0925173379', + + billing_address_city: 'Tripoli', + billing_address_country: 'Libya', + billing_address_email: 'a.bouhuolia@live.com', + billing_address_state: 'State Tripoli', + billing_address_zipcode: '21892', + + shipping_address_city: 'Tripoli', + shipping_address_country: 'Libya', + shipping_address_email: 'a.bouhuolia@live.com', + shipping_address_state: 'State Tripoli', + shipping_address_zipcode: '21892', + + note: '__desc__', + + active: true, + }); + + expect(res.status).equals(200); + + const foundCustomer = await Customer.tenant().query().where('id', res.body.id); + + expect(foundCustomer[0].customerType).equals('business'); + expect(foundCustomer[0].firstName).equals('Ahmed'); + expect(foundCustomer[0].lastName).equals('Bouhuolia'); + expect(foundCustomer[0].companyName).equals('Bigcapital'); + expect(foundCustomer[0].displayName).equals('Ahmed Bouhuolia, Bigcapital'); + + expect(foundCustomer[0].email).equals('a.bouhuolia@live.com'); + + expect(foundCustomer[0].workPhone).equals('0927918381'); + expect(foundCustomer[0].personalPhone).equals('0925173379'); + + expect(foundCustomer[0].billingAddressCity).equals('Tripoli'); + expect(foundCustomer[0].billingAddressCountry).equals('Libya'); + expect(foundCustomer[0].billingAddressEmail).equals('a.bouhuolia@live.com'); + expect(foundCustomer[0].billingAddressState).equals('State Tripoli'); + expect(foundCustomer[0].billingAddressZipcode).equals('21892'); + + expect(foundCustomer[0].shippingAddressCity).equals('Tripoli'); + expect(foundCustomer[0].shippingAddressCountry).equals('Libya'); + expect(foundCustomer[0].shippingAddressEmail).equals('a.bouhuolia@live.com'); + expect(foundCustomer[0].shippingAddressState).equals('State Tripoli'); + expect(foundCustomer[0].shippingAddressZipcode).equals('21892'); + }); + }); + + describe('GET: `/customers/:id`', () => { + it('Should response not found in case the given customer id was not exists on the storage.', async () => { + const res = await request() + .get('/api/customers/123') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send(); + + expect(res.status).equals(404); + expect(res.body.errors).include.something.deep.equals({ + type: 'CUSTOMER.NOT.FOUND', code: 200, + }); + }); + }); + + describe('GET: `customers`', () => { + it('Should response customers items', async () => { + await tenantFactory.create('customer'); + await tenantFactory.create('customer'); + + const res = await request() + .get('/api/customers') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send(); + + expect(res.body.customers.results.length).equals(2); + }); + }); + + describe('DELETE: `/customers/:id`', () => { + it('Should response not found in case the given customer id was not exists on the storage.', async () => { + const res = await request() + .delete('/api/customers/123') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send(); + + expect(res.status).equals(404); + expect(res.body.errors).include.something.deep.equals({ + type: 'CUSTOMER.NOT.FOUND', code: 200, + }); + }); + + it('Should delete the given customer from the storage.', async () => { + const customer = await tenantFactory.create('customer'); + const res = await request() + .delete(`/api/customers/${customer.id}`) + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send(); + + expect(res.status).equals(200); + + const foundCustomer = await Customer.tenant().query().where('id', customer.id); + expect(foundCustomer.length).equals(0); + }) + }); + + describe('POST: `/customers/:id`', () => { + it('Should response customer not found', async () => { + const res = await request() + .post('/api/customers/123') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send({ + customer_type: 'business', + display_name: 'Ahmed Bouhuolia, Bigcapital', + }); + + expect(res.status).equals(404); + expect(res.body.errors).include.something.deep.equals({ + type: 'CUSTOMER.NOT.FOUND', code: 200, + }); + }); + + it('Should update details of the given customer.', async () => { + const customer = await tenantFactory.create('customer'); + const res = await request() + .post(`/api/customers/${customer.id}`) + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send({ + customer_type: 'business', + display_name: 'Ahmed Bouhuolia, Bigcapital', + }); + + expect(res.status).equals(200); + const foundCustomer = await Customer.tenant().query().where('id', res.body.id); + + expect(foundCustomer.length).equals(1); + expect(foundCustomer[0].customerType).equals('business'); + expect(foundCustomer[0].displayName).equals('Ahmed Bouhuolia, Bigcapital'); + }) + }); +}); diff --git a/server/tests/routes/expenses.test.js b/server/tests/routes/expenses.test.js index 2562b2fcf..a6588a3cd 100644 --- a/server/tests/routes/expenses.test.js +++ b/server/tests/routes/expenses.test.js @@ -277,7 +277,7 @@ describe('routes: /expenses/', () => { }); }); - describe.only('DELETE: `/expenses/:id`', () => { + describe('DELETE: `/expenses/:id`', () => { it('Should response unauthorized if the user was not logged in.', async () => { const res = await request() .delete('/api/expenses') @@ -319,7 +319,7 @@ describe('routes: /expenses/', () => { expect(storedExpenseCategories.length).equals(0); }); - it.only('Should delete all journal entries that associated to the given expense.', async () => { + it('Should delete all journal entries that associated to the given expense.', async () => { const expense = await tenantFactory.create('expense'); const trans = { reference_id: expense.id, reference_type: 'Expense' };