diff --git a/server/src/collection/ResourceFieldMetadataCollection.js b/server/src/collection/ResourceFieldMetadataCollection.js new file mode 100644 index 000000000..fc48fd9b8 --- /dev/null +++ b/server/src/collection/ResourceFieldMetadataCollection.js @@ -0,0 +1,14 @@ +import MetableCollection from '@/lib/Metable/MetableCollection'; +import ResourceFieldMetadata from '@/models/ResourceFieldMetadata'; + +export default class ResourceFieldMetadataCollection extends MetableCollection { + /** + * Constructor method. + */ + constructor() { + super(); + + this.setModel(ResourceFieldMetadata); + this.extraColumns = ['resource_id', 'resource_item_id']; + } +} diff --git a/server/src/http/controllers/Expenses.js b/server/src/http/controllers/Expenses.js index 195bf0db2..c1d3d5b17 100644 --- a/server/src/http/controllers/Expenses.js +++ b/server/src/http/controllers/Expenses.js @@ -6,7 +6,7 @@ import { validationResult, } from 'express-validator'; import moment from 'moment'; -import { difference, chain } from 'lodash'; +import { difference, chain, omit } from 'lodash'; import asyncMiddleware from '@/http/middleware/asyncMiddleware'; import Expense from '@/models/Expense'; import Account from '@/models/Account'; @@ -50,6 +50,10 @@ export default { this.listExpenses.validation, asyncMiddleware(this.listExpenses.handler)); + router.get('/:id', + this.getExpense.validation, + asyncMiddleware(this.getExpense.handler)); + return router; }, @@ -81,11 +85,12 @@ export default { const form = { date: new Date(), published: false, + custom_fields: [], ...req.body, }; // Convert the date to the general format. form.date = moment(form.date).format('YYYY-MM-DD'); - +s const errorReasons = []; const paymentAccount = await Account.query() .findById(form.payment_account_id).first(); @@ -98,18 +103,19 @@ export default { if (!expenseAccount) { errorReasons.push({ type: 'EXPENSE.ACCOUNT.NOT.FOUND', code: 200 }); } - const customFields = new ResourceCustomFieldRepository('Expense'); + const customFields = new ResourceCustomFieldRepository(Expense); await customFields.load(); - customFields.fillCustomFields(form.custom_fields); - if (customFields.validateExistCustomFields()) { errorReasons.push({ type: 'CUSTOM.FIELDS.SLUGS.NOT.EXISTS', code: 400 }); } if (errorReasons.length > 0) { return res.status(400).send({ errors: errorReasons }); } - const expenseTransaction = await Expense.query().insertAndFetch({ ...form }); + const expenseTransaction = await Expense.query().insertAndFetch({ + ...omit(form, ['custom_fields']), + }); + customFields.fillCustomFields(expenseTransaction.id, form.custom_fields); const journalEntries = new JournalPoster(); const creditEntry = new JournalEntry({ @@ -433,4 +439,43 @@ export default { } }, }, + + /** + * Retrieve details of the given expense id. + */ + getExpense: { + 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 { id } = req.params; + const expenseTransaction = await Expense.query().findById(id); + + if (!expenseTransaction) { + return res.status(404).send({ + errors: [{ type: 'EXPENSE.TRANSACTION.NOT.FOUND', code: 100 }], + }); + } + + const expenseCFMetadataRepo = new ResourceCustomFieldRepository(Expense); + await expenseCFMetadataRepo.load(); + await expenseCFMetadataRepo.fetchCustomFieldsMetadata(expenseTransaction.id); + + const expenseCusFieldsMetadata = expenseCFMetadataRepo.getMetadata(expenseTransaction.id); + + return res.status(200).send({ + ...expenseTransaction, + custom_fields: [ + ...expenseCusFieldsMetadata.toArray(), + ], + }); + }, + }, }; diff --git a/server/src/lib/Metable/MetableCollection.js b/server/src/lib/Metable/MetableCollection.js index f00296d18..7e2e0eda0 100644 --- a/server/src/lib/Metable/MetableCollection.js +++ b/server/src/lib/Metable/MetableCollection.js @@ -6,6 +6,11 @@ export default class MetableCollection { this.VALUE_COLUMN = 'value'; this.TYPE_COLUMN = 'type'; this.model = null; + this.extraColumns = []; + + this.extraQuery = (query, meta) => { + query.where('key', meta[this.KEY_COLUMN]); + }; } /** @@ -59,9 +64,14 @@ export default class MetableCollection { * @param {*} group */ removeAllMeta(group = 'default') { - this.metadata.forEach(meta => { - meta.markAsDeleted = true; - }); + this.metadata = this.metadata.map((meta) => ({ + ...meta, + markAsDeleted: true, + })); + } + + setExtraQuery(callback) { + this.extraQuery = callback; } /** @@ -100,18 +110,35 @@ export default class MetableCollection { const opers = []; if (deleted.length > 0) { - const deleteOper = this.model.query() - .whereIn('key', deleted.map((meta) => meta.key)).delete(); - - opers.push(deleteOper); + deleted.forEach((meta) => { + const deleteOper = this.model.query().beforeRun((query, result) => { + this.extraQuery(query, meta); + return result; + }).delete(); + opers.push(deleteOper); + }); } inserted.forEach((meta) => { const insertOper = this.model.query().insert({ [this.KEY_COLUMN]: meta.key, [this.VALUE_COLUMN]: meta.value, + ...this.extraColumns.reduce((obj, column) => { + if (typeof meta[column] !== 'undefined') { + obj[column] = meta[column]; + } + return obj; + }, {}), }); opers.push(insertOper); }); + updated.forEach((meta) => { + const updateOper = this.model.query().onBuild((query) => { + this.extraQuery(query, meta); + }).patch({ + [this.VALUE_COLUMN]: meta.value, + }); + opers.push(updateOper); + }); await Promise.all(opers); } @@ -198,6 +225,11 @@ export default class MetableCollection { this.metadata.push(meta); } + + toArray() { + return this.metadata; + } + /** * Static method to load metadata to the collection. * @param {Array} meta diff --git a/server/src/models/Model.js b/server/src/models/Model.js index e39786ecb..8d8f569d6 100644 --- a/server/src/models/Model.js +++ b/server/src/models/Model.js @@ -1,7 +1,6 @@ import { Model } from 'objection'; export default class ModelBase extends Model { - static get collection() { return Array; } diff --git a/server/src/models/Option.js b/server/src/models/Option.js index 1fd414cc8..295ec635a 100644 --- a/server/src/models/Option.js +++ b/server/src/models/Option.js @@ -10,6 +10,19 @@ export default class Option extends mixin(BaseModel, [mixin]) { return 'options'; } + /** + * Override the model query. + * @param {...any} args - + */ + static query(...args) { + return super.query(...args).runAfter((result) => { + if (result instanceof MetableCollection) { + result.setModel(Option); + } + return result; + }); + } + static get collection() { return MetableCollection; } diff --git a/server/src/models/ResourceFieldMetadata.js b/server/src/models/ResourceFieldMetadata.js index 640b87816..30ea3cb1f 100644 --- a/server/src/models/ResourceFieldMetadata.js +++ b/server/src/models/ResourceFieldMetadata.js @@ -1,7 +1,7 @@ import { Model } from 'objection'; import path from 'path'; import BaseModel from '@/models/Model'; -import MetableCollection from '@/lib/Metable/MetableCollection'; +import ResourceFieldMetadataCollection from '@/collection/ResourceFieldMetadataCollection'; export default class ResourceFieldMetadata extends BaseModel { /** @@ -15,7 +15,7 @@ export default class ResourceFieldMetadata extends BaseModel { * Override the resource field metadata collection. */ static get collection() { - return MetableCollection; + return ResourceFieldMetadataCollection; } /** diff --git a/server/src/services/CustomFields/ResourceCustomFieldRepository.js b/server/src/services/CustomFields/ResourceCustomFieldRepository.js index 6ee00c792..73cca8b73 100644 --- a/server/src/services/CustomFields/ResourceCustomFieldRepository.js +++ b/server/src/services/CustomFields/ResourceCustomFieldRepository.js @@ -1,23 +1,24 @@ import Resource from '@/models/Resource'; import ResourceField from '@/models/ResourceField'; import ResourceFieldMetadata from '@/models/ResourceFieldMetadata'; -import ModelBase from '@/models/Model'; +import ResourceFieldMetadataCollection from '@/collection/ResourceFieldMetadataCollection'; export default class ResourceCustomFieldRepository { - + /** + * Class constructor. + */ constructor(model) { if (typeof model === 'function') { this.resourceName = model.name; } else if (typeof model === 'string') { this.resourceName = model; } - // Custom fields of the given resource. this.customFields = []; - this.filledCustomFields = []; + this.filledCustomFields = {}; // metadata of custom fields of the given resource. - this.metadata = {}; + this.fieldsMetadata = {}; this.resource = {}; } @@ -36,7 +37,7 @@ export default class ResourceCustomFieldRepository { .where('resource_id', this.resource.id) .where('resource_item_id', id); - this.metadata[id] = metadata; + this.fieldsMetadata[id] = metadata; } /** @@ -86,21 +87,49 @@ export default class ResourceCustomFieldRepository { * @param {Integer} itemId - */ getMetadata(itemId) { - return this.metadata[itemId] || this.metadata; + return this.fieldsMetadata[itemId] || this.fieldsMetadata; } + /** + * Fill metadata of the custom fields that associated to the resource. + * @param {Inter} id - Resource item id. + * @param {Array} attributes - + */ fillCustomFields(id, attributes) { if (typeof this.filledCustomFields[id] === 'undefined') { this.filledCustomFields[id] = []; } attributes.forEach((attr) => { this.filledCustomFields[id].push(attr); - this.fieldsMetadata[id].setMeta(attr.key, attr.value); + + if (!this.fieldsMetadata[id]) { + this.fieldsMetadata[id] = new ResourceFieldMetadataCollection(); + } + this.fieldsMetadata[id].setMeta(attr.key, attr.value, { + resource_id: this.resource.id, + resource_item_id: id, + }); }); } - saveCustomFields(id) { - this.fieldsMetadata.saveMeta(); + /** + * Saves the instered, updated and deleted custom fields metadata. + * @param {Integer} id - Optional resource item id. + */ + async saveCustomFields(id) { + if (id) { + if (typeof this.fieldsMetadata[id] === 'undefined') { + throw new Error('There is no resource item with the given id.'); + } + await this.fieldsMetadata[id].saveMeta(); + } else { + const opers = []; + this.fieldsMetadata.forEach((metadata) => { + const oper = metadata.saveMeta(); + opers.push(oper); + }); + await Promise.all(opers); + } } /** @@ -115,9 +144,11 @@ export default class ResourceCustomFieldRepository { } async load() { - await Promise.all([ - this.loadResource(), - this.loadResourceCustomFields(), - ]); + await this.loadResource(); + await this.loadResourceCustomFields(); } -} \ No newline at end of file + + static forgeMetadataCollection() { + + } +} diff --git a/server/tests/lib/Metable/MetableCollection.test.js b/server/tests/lib/MetableCollection.test.js similarity index 73% rename from server/tests/lib/Metable/MetableCollection.test.js rename to server/tests/lib/MetableCollection.test.js index 6eb3c5849..a3abba9dd 100644 --- a/server/tests/lib/Metable/MetableCollection.test.js +++ b/server/tests/lib/MetableCollection.test.js @@ -1,13 +1,13 @@ import Option from '@/models/Option'; import MetadataCollection from '@/lib/Metable/MetableCollection'; +import ResourceFieldMetadata from '@/models/ResourceFieldMetadata'; import { create, expect } from '~/testInit'; describe('MetableCollection', () => { describe('findMeta', () => { it('Should retrieve the found meta object.', async () => { const option = await create('option'); - const options = await Option.query(); - const metadataCollection = MetadataCollection.from(options); + const metadataCollection = await Option.query(); const foundMeta = metadataCollection.findMeta(option.key); expect(foundMeta).to.be.an('object'); @@ -17,9 +17,7 @@ describe('MetableCollection', () => { describe('allMetadata', () => { it('Should retrieve all exists metadata entries.', async () => { const option = await create('option'); - const options = await Option.query(); - const metadataCollection = MetadataCollection.from(options); - + const metadataCollection = await Option.query(); const foundMetadata = metadataCollection.allMetadata(); expect(foundMetadata.length).equals(1); @@ -29,21 +27,17 @@ describe('MetableCollection', () => { describe('getMeta', () => { it('Should retrieve the found meta value.', async () => { const option = await create('option'); - const options = await Option.query(); - const metadataCollection = MetadataCollection.from(options); + const metadataCollection = await Option.query(); const foundMeta = metadataCollection.getMeta(option.key); - expect(foundMeta).equals(option.value); }); it('Should retrieve the default meta value in case the meta key was not exist.', async () => { const option = await create('option'); - const options = await Option.query(); - const metadataCollection = MetadataCollection.from(options); + const metadataCollection = await Option.query(); const foundMeta = metadataCollection.getMeta('not-found', true); - expect(foundMeta).equals(true); }); }); @@ -60,6 +54,8 @@ describe('MetableCollection', () => { describe('removeAllMeta()', () => { it('Should remove all metadata from the stack.', async () => { const metadataCollection = new MetadataCollection(); + + metadataCollection.setModel(Option); metadataCollection.setMeta('key', 'value'); metadataCollection.setMeta('key2', 'value2'); @@ -73,24 +69,28 @@ describe('MetableCollection', () => { describe('saveMeta', () => { it('Should save inserted new metadata.', async () => { const metadataCollection = new MetadataCollection(); + + metadataCollection.setModel(Option); metadataCollection.setMeta('key', 'value'); metadataCollection.setModel(Option); await metadataCollection.saveMeta(); const storedMetadata = await Option.query(); - expect(storedMetadata.length).equals(1); + expect(storedMetadata.metadata.length).equals(1); }); it('Should save updated the exist metadata.', async () => { const option = await create('option'); const metadataCollection = new MetadataCollection(); + + metadataCollection.setModel(Option); metadataCollection.setMeta(option.key, 'value'); metadataCollection.setModel(Option); await metadataCollection.saveMeta(); - const storedMetadata = Option.query().where('key', option.key).first(); + const storedMetadata = await Option.query().where('key', option.key).first(); expect(storedMetadata.value).equals('value'); }); @@ -108,5 +108,22 @@ describe('MetableCollection', () => { const storedMetadata = await Option.query(); expect(storedMetadata.length).equals(0); }); + + it('Should save instered new metadata with extra columns.', async () => { + const resource = await create('resource'); + + const metadataCollection = new MetadataCollection(); + metadataCollection.extraColumns = ['resource_id']; + + metadataCollection.setModel(ResourceFieldMetadata); + metadataCollection.setMeta('key', 'value', { resource_id: resource.id }); + + await metadataCollection.saveMeta(); + + const storedMetadata = await ResourceFieldMetadata.query().first(); + + expect(storedMetadata.metadata.length).equals(1); + expect(storedMetadata.metadata[0].resourceId).equals(resource.id); + }); }); }); diff --git a/server/tests/lib/ResourceCustomFieldRepository.test.js b/server/tests/lib/ResourceCustomFieldRepository.test.js index d8dcf2d20..42072249a 100644 --- a/server/tests/lib/ResourceCustomFieldRepository.test.js +++ b/server/tests/lib/ResourceCustomFieldRepository.test.js @@ -1,5 +1,4 @@ import { - request, expect, create, login, @@ -26,20 +25,31 @@ describe('ResourceCustomFieldRepository', () => { }); }); - describe('fetchCustomFields', () => { + describe('loadResource()', () => { + it('Should fetches the resource name.', async () => { + const resource = await create('resource', { name: 'Expense' }); + const customFieldsRepo = new ResourceCustomFieldRepository(Expense); + await customFieldsRepo.loadResource(); + + expect(customFieldsRepo.resource.name).equals('Expense'); + }); + }); + + describe('loadResourceCustomFields()', () => { it('Should fetches all custom fields that associated with the resource.', async () => { const resource = await create('resource', { name: 'Expense' }); const resourceField = await create('resource_field', { resource_id: resource.id }); const resourceField2 = await create('resource_field', { resource_id: resource.id }); const customFieldsRepo = new ResourceCustomFieldRepository(Expense); - await customFieldsRepo.fetchCustomFields(); + await customFieldsRepo.loadResource(); + await customFieldsRepo.loadResourceCustomFields(); expect(customFieldsRepo.customFields.length).equals(2); }); }); - describe.only('fetchCustomFieldsMetadata', () => { + describe('fetchCustomFieldsMetadata', () => { it('Should fetches all custom fields metadata that associated to the resource and resource item.', async () => { const resource = await create('resource', { name: 'Expense' }); const resourceField = await create('resource_field', { resource_id: resource.id }); @@ -52,46 +62,64 @@ describe('ResourceCustomFieldRepository', () => { }); const customFieldsRepo = new ResourceCustomFieldRepository(Expense); - await customFieldsRepo.fetchCustomFields(); + await customFieldsRepo.load(); await customFieldsRepo.fetchCustomFieldsMetadata(expense.id); expect(customFieldsRepo.metadata[expense.id].metadata.length).equals(1); + expect(customFieldsRepo.metadata[expense.id].metadata[0].key).equals(fieldMetadata.key); + expect(customFieldsRepo.metadata[expense.id].metadata[0].value).equals(fieldMetadata.value); }); }); describe('fillCustomFields', () => { - - }); - - describe('saveCustomFields', () => { - it('Should save the given custom fields metadata to the resource item.', () => { - const resource = await create('resource'); - const resourceField = await create('resource_field', { resource_field: resource.id }); + it('Should fill custom fields metadata attributes to metadata object.', async () => { + const resource = await create('resource', { name: 'Expense' }); + const resourceField = await create('resource_field', { resource_id: resource.id }); const expense = await create('expense'); - const fieldMetadata = await create('resource_custom_field_metadata', { - resource_id: resource.id, resource_item_id: expense.id, - }); const customFieldsRepo = new ResourceCustomFieldRepository(Expense); await customFieldsRepo.load(); + await customFieldsRepo.fetchCustomFieldsMetadata(expense.id); + + customFieldsRepo.fillCustomFields(expense.id, [ + { + key: resourceField.key, + value: 'Hello World', + }, + ]); + expect(customFieldsRepo.fieldsMetadata[expense.id].metadata.length).equals(1); + expect(customFieldsRepo.filledCustomFields[expense.id].length).equals(1); + }); + }); + + describe('saveCustomFields', () => { + it('Should save the given custom fields metadata to the resource item.', async () => { + const resource = await create('resource', { name: 'Expense' }); + const resourceField = await create('resource_field', { resource_id: resource.id }); + + const expense = await create('expense'); + const fieldMetadata = await create('resource_custom_field_metadata', { + key: resourceField.slug, + resource_id: resource.id, + resource_item_id: expense.id, + }); + const customFieldsRepo = new ResourceCustomFieldRepository(Expense); + await customFieldsRepo.load(); + await customFieldsRepo.fetchCustomFieldsMetadata(expense.id); customFieldsRepo.fillCustomFields(expense.id, [ { key: resourceField.slug, value: 'Hello World' }, ]); - await customFieldsRepo.saveCustomFields(); + await customFieldsRepo.saveCustomFields(expense.id); - const updateResourceFieldData = await ResourceFieldMetadata.query() - .where('resource_id', resource.id) - .where('resource_item_id', expense.id) - .first(); - - expect(updateResourceFieldData.value).equals('Hello World'); + const updateResourceFieldData = await ResourceFieldMetadata.query(); + expect(updateResourceFieldData.metadata[0].value).equals('Hello World'); }); }); - // describe('validateExistCustomFields', () => { + describe('validateExistCustomFields', () => { - // }); + }); }); \ No newline at end of file diff --git a/server/tests/routes/expenses.test.js b/server/tests/routes/expenses.test.js index 114853609..f29ab7bd4 100644 --- a/server/tests/routes/expenses.test.js +++ b/server/tests/routes/expenses.test.js @@ -6,6 +6,7 @@ import { } from '~/testInit'; import AccountTransaction from '@/models/AccountTransaction'; import Expense from '@/models/Expense'; +import ResourceFieldMetadata from '@/models/ResourceFieldMetadata'; let loginRes; let expenseType; @@ -143,16 +144,60 @@ describe('routes: /expenses/', () => { }); const expenseTransaction = await Expense.query().where('id', res.body.id); - expect(expenseTransaction.amount).equals(100); }); - it('Should response bad request in case custom field slug was not exists in the storage.', () => { + it('Should response bad request in case custom field slug was not exists in the storage.', async () => { + const res = await request() + .post('/api/expenses') + .set('x-access-token', loginRes.body.token) + .send({ + expense_account_id: expenseAccount.id, + payment_account_id: cashAccount.id, + amount: 100, + custom_options: [ + { + key: 'random_key', + value: 'Value here', + }, + ], + }); + expect(res.status).equals(400); }); - it('Should save expense custom fields to the storage.', () => { + it('Should save expense custom fields to the storage.', async () => { + const resource = await create('resource', { name: 'Expense' }); + const resourceField = await create('resource_field', { + resource_id: resource.id, + slug: 'custom_field_1', + }); + const res = await request() + .post('/api/expenses') + .set('x-access-token', loginRes.body.token) + .send({ + expense_account_id: expenseAccount.id, + payment_account_id: cashAccount.id, + amount: 100, + custom_fields: [ + { + key: 'custom_field_1', + value: 'Value here', + }, + ], + }); + + const storedResourceItemMetadata = await ResourceFieldMetadata.query() + .where('resource_id', resource.id) + .where('resource_item_id', res.body.id); + + expect(storedResourceItemMetadata.metadata.length).equals(1); + expect(storedResourceItemMetadata.metadata[0].resourceId).equals(resource.id); + expect(storedResourceItemMetadata.metadata[0].resourceItemId).equals(res.body.id); + + expect(storedResourceItemMetadata.metadata[0].key).equals('custom_field_1'); + expect(storedResourceItemMetadata.metadata[0].value).equals('Value here'); }); }); @@ -403,16 +448,43 @@ describe('routes: /expenses/', () => { describe('GET: `/expenses/:id`', () => { it('Should response view not found in case the custom view id was not exist.', async () => { + const expense = await create('expense'); + const res = await request() - .get('/api/expenses') + .get(`/api/expenses/${expense.id}123`) .set('x-access-token', loginRes.body.token) .send(); - console.log(res.status); + expect(res.status).equals(404); + expect(res.body.errors).include.something.deep.equals({ + type: 'EXPENSE.TRANSACTION.NOT.FOUND', code: 100, + }); }); - it('Should retrieve custom fields metadata.', () => { - + it('Should retrieve custom fields metadata.', async () => { + const expense = await create('expense'); + const resource = await create('resource', { name: 'Expense' }); + const resourceField = await create('resource_field', { + resource_id: resource.id, + slug: 'custom_field_1', + }); + + const resourceFieldMetadata = await create('resource_custom_field_metadata', { + resource_id: resource.id, + resource_item_id: expense.id, + key: 'custom_field_1', + }); + + const res = await request() + .get(`/api/expenses/${expense.id}`) + .set('x-access-token', loginRes.body.token) + .send(); + + expect(res.status).equals(200); + + expect(res.body.custom_fields.length).equals(1); + expect(res.body.custom_fields[0].key).equals('custom_field_1'); + expect(res.body.custom_fields[0].value).equals(resourceFieldMetadata.value); }); }); });