diff --git a/server/src/database/factories/index.js b/server/src/database/factories/index.js index dff209519..5069fdc94 100644 --- a/server/src/database/factories/index.js +++ b/server/src/database/factories/index.js @@ -300,5 +300,11 @@ export default (tenantDb) => { }; }); + factory.define('vendor', 'vendors', async () => { + return { + customer_type: 'business', + }; + }); + return factory; } diff --git a/server/src/database/migrations/20200608192614_create_vendors_table.js b/server/src/database/migrations/20200608192614_create_vendors_table.js new file mode 100644 index 000000000..0cdf8541d --- /dev/null +++ b/server/src/database/migrations/20200608192614_create_vendors_table.js @@ -0,0 +1,42 @@ + +exports.up = function(knex) { + return knex.schema.createTable('vendors', 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('vendors'); +}; diff --git a/server/src/database/seeds/seed_resources.js b/server/src/database/seeds/seed_resources.js index e9c2ec45c..546da4568 100644 --- a/server/src/database/seeds/seed_resources.js +++ b/server/src/database/seeds/seed_resources.js @@ -11,6 +11,7 @@ exports.seed = (knex) => { { id: 4, name: 'manual_journals' }, { id: 5, name: 'items_categories' }, { id: 6, name: 'customers' }, + { id: 7, name: 'vendors' }, ]); }); }; diff --git a/server/src/http/controllers/Vendors.js b/server/src/http/controllers/Vendors.js new file mode 100644 index 000000000..a942c0127 --- /dev/null +++ b/server/src/http/controllers/Vendors.js @@ -0,0 +1,377 @@ +import express from 'express'; +import { + check, + param, + 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('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(); + + router.post('/', + this.newVendor.validation, + asyncMiddleware(this.newVendor.handler)); + + router.post('/:id', + this.editVendor.validation, + asyncMiddleware(this.editVendor.handler)); + + router.delete('/:id', + this.deleteVendor.validation, + asyncMiddleware(this.deleteVendor.handler)); + + router.get('/', + this.listVendors.validation, + asyncMiddleware(this.listVendors.handler)); + + router.get('/:id', + this.getVendor.validation, + asyncMiddleware(this.getVendor.handler)); + + return router; + }, + + /** + * Retrieve vendors list with pagination and custom view metadata. + */ + listVendors: { + 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, Vendor } = req.models; + const errorReasons = []; + + const vendorsResource = await Resource.query() + .where('name', 'vendors') + .withGraphFetched('fields') + .first(); + + if (!vendorsResource) { + return res.status(400).send({ + errors: [{ type: 'VENDORS.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', vendorsResource.id); + builder.withGraphFetched('roles.field'); + builder.withGraphFetched('columns'); + builder.first(); + }); + const resourceFieldsKeys = vendorsResource.fields.map((c) => c.key); + const dynamicFilter = new DynamicFilter(Vendor.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), + vendorsResource.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 }); + } + // Vendors query. + const vendors = await Vendor.query().onBuild((builder) => { + dynamicFilter.buildQuery()(builder); + }).pagination(filter.page - 1, filter.page_size); + + return res.status(200).send({ + vendors, + ...(view) ? { + customViewId: view.id, + } : {}, + }); + } + }, + + /** + * Submit a new vendor details. + */ + newVendor: { + validation: [ + ...validatioRoles, + ], + async handler(req, res) { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const { Vendor } = req.models; + const form = { ...req.body }; + + const vendor = await Vendor.query().insertAndFetch({ + ...pick(form, [ + '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: vendor.id }); + }, + }, + + /** + * Edit details of the given vendor id. + */ + editVendor: { + 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 { Vendor } = req.models; + const vendor = await Vendor.query().where('id', id).first(); + + if (!vendor) { + return res.status(404).send({ + errors: [{ type: 'VENDOR.NOT.FOUND', code: 200 }], + }); + } + + await Vendor.query().where('id', id).patch({ + ...pick(form, [ + '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 vendor id. + */ + getVendor: { + 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 { Vendor } = req.models; + const { id } = req.params; + const vendor = await Vendor.query().where('id', id).first(); + + if (!vendor) { + return res.status(404).send({ + errors: [{ type: 'VENDOR.NOT.FOUND', code: 200 }], + }); + } + return res.status(200).send({ vendor }); + }, + }, + + /** + * Delete the given vendor. + */ + deleteVendor: { + 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 { Vendor } = req.models; + const { id } = req.params; + const vendor = await Vendor.query().where('id', id).first(); + + if (!vendor) { + return res.status(404).send({ + errors: [{ type: 'VENDOR.NOT.FOUND', code: 200 }], + }); + } + await Vendor.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 2e1cd0d95..e6d5d4e95 100644 --- a/server/src/http/index.js +++ b/server/src/http/index.js @@ -19,6 +19,7 @@ import Options from '@/http/controllers/Options'; // import BudgetReports from '@/http/controllers/BudgetReports'; import Currencies from '@/http/controllers/Currencies'; import Customers from '@/http/controllers/Customers'; +import Vendors from '@/http/controllers/Vendors'; // import Suppliers from '@/http/controllers/Suppliers'; // import Bills from '@/http/controllers/Bills'; // import CurrencyAdjustment from './controllers/CurrencyAdjustment'; @@ -57,6 +58,7 @@ export default (app) => { dashboard.use('/api/options', Options.router()); // app.use('/api/budget_reports', BudgetReports.router()); dashboard.use('/api/customers', Customers.router()); + dashboard.use('/api/vendors', Vendors.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/Vendor.js b/server/src/models/Vendor.js new file mode 100644 index 000000000..9be8756ac --- /dev/null +++ b/server/src/models/Vendor.js @@ -0,0 +1,11 @@ +import { Model } from 'objection'; +import TenantModel from '@/models/TenantModel'; + +export default class Vendor extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'vendors'; + } +} diff --git a/server/tests/routes/vendors.test.js b/server/tests/routes/vendors.test.js new file mode 100644 index 000000000..0a69a3e1d --- /dev/null +++ b/server/tests/routes/vendors.test.js @@ -0,0 +1,193 @@ +import { + request, + expect, +} from '~/testInit'; +import Currency from '@/models/Currency'; +import { + tenantWebsite, + tenantFactory, + loginRes +} from '~/dbInit'; +import Vendor from '@/models/Vendor'; + +describe.only('route: `/vendors`', () => { + describe('POST: `/vendors`', () => { + it('Should response unauthorized in case the user was not logged in.', async () => { + const res = await request() + .post('/api/vendors') + .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/vendors') + .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 store the vendor data to the storage.', async () => { + const res = await request() + .post('/api/vendors') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send({ + 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 foundVendor = await Vendor.tenant().query().where('id', res.body.id); + + expect(foundVendor[0].firstName).equals('Ahmed'); + expect(foundVendor[0].lastName).equals('Bouhuolia'); + expect(foundVendor[0].companyName).equals('Bigcapital'); + expect(foundVendor[0].displayName).equals('Ahmed Bouhuolia, Bigcapital'); + + expect(foundVendor[0].email).equals('a.bouhuolia@live.com'); + + expect(foundVendor[0].workPhone).equals('0927918381'); + expect(foundVendor[0].personalPhone).equals('0925173379'); + + expect(foundVendor[0].billingAddressCity).equals('Tripoli'); + expect(foundVendor[0].billingAddressCountry).equals('Libya'); + expect(foundVendor[0].billingAddressEmail).equals('a.bouhuolia@live.com'); + expect(foundVendor[0].billingAddressState).equals('State Tripoli'); + expect(foundVendor[0].billingAddressZipcode).equals('21892'); + + expect(foundVendor[0].shippingAddressCity).equals('Tripoli'); + expect(foundVendor[0].shippingAddressCountry).equals('Libya'); + expect(foundVendor[0].shippingAddressEmail).equals('a.bouhuolia@live.com'); + expect(foundVendor[0].shippingAddressState).equals('State Tripoli'); + expect(foundVendor[0].shippingAddressZipcode).equals('21892'); + }); + }); + + describe('GET: `/vendors/:id`', () => { + it('Should response not found in case the given vendor id was not exists on the storage.', async () => { + const res = await request() + .get('/api/vendors/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: 'VENDOR.NOT.FOUND', code: 200, + }); + }); + }); + + describe('GET: `vendors`', () => { + it('Should response vendors items', async () => { + await tenantFactory.create('vendor'); + await tenantFactory.create('vendor'); + + const res = await request() + .get('/api/vendors') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send(); + + expect(res.body.vendors.results.length).equals(2); + }); + }); + + describe('DELETE: `/vendors/:id`', () => { + it('Should response not found in case the given vendor id was not exists on the storage.', async () => { + const res = await request() + .delete('/api/vendors/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: 'VENDOR.NOT.FOUND', code: 200, + }); + }); + + it('Should delete the given vendor from the storage.', async () => { + const vendor = await tenantFactory.create('vendor'); + const res = await request() + .delete(`/api/vendors/${vendor.id}`) + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send(); + + expect(res.status).equals(200); + + const foundVendor = await Vendor.tenant().query().where('id', vendor.id); + expect(foundVendor.length).equals(0); + }) + }); + + describe('POST: `/vendors/:id`', () => { + it('Should response vendor not found', async () => { + const res = await request() + .post('/api/vendors/123') + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send({ + display_name: 'Ahmed Bouhuolia, Bigcapital', + }); + + expect(res.status).equals(404); + expect(res.body.errors).include.something.deep.equals({ + type: 'VENDOR.NOT.FOUND', code: 200, + }); + }); + + it('Should update details of the given vendor.', async () => { + const vendor = await tenantFactory.create('vendor'); + const res = await request() + .post(`/api/vendors/${vendor.id}`) + .set('x-access-token', loginRes.body.token) + .set('organization-id', tenantWebsite.organizationId) + .send({ + display_name: 'Ahmed Bouhuolia, Bigcapital', + }); + + expect(res.status).equals(200); + const foundVendor = await Vendor.tenant().query().where('id', res.body.id); + + expect(foundVendor.length).equals(1); + expect(foundVendor[0].displayName).equals('Ahmed Bouhuolia, Bigcapital'); + }) + }); +});