server/services/configuration_service/services/database_configuration_service/service.js

'use strict';

/* eslint-disable security/detect-object-injection */

/**
 * Module dependencies, required for ALL Twyr' modules
 * @ignore
 */

/**
 * Module dependencies, required for this module
 * @ignore
 */
const TwyrBaseService = require('twyr-base-service').TwyrBaseService;
const TwyrSrvcError = require('twyr-service-error').TwyrServiceError;

/**
 * @class   DatabaseConfigurationService
 * @extends {TwyrBaseService}
 * @classdesc The Twyr Web Application Server PostgreSQL-based configuration sub-service.
 *
 * @description
 * Allows the rest of the codebase to CRUD their configurations from the PostgreSQL database.
 *
 */
class DatabaseConfigurationService extends TwyrBaseService {
	// #region Constructor
	constructor(parent) {
		super(parent);
		this.$cacheMap = {};
	}
	// #endregion

	// #region setup/teardown code
	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof DatabaseConfigurationService
	 * @name     _setup
	 *
	 * @returns  {boolean} Boolean true/false.
	 *
	 * @summary  Sets up the listener to watch for changes on the fly.
	 */
	async _setup() {
		try {
			await super._setup();

			if(!this.$parent.$config.subservices)
				return null;

			if(!this.$parent.$config.subservices[this.name])
				return null;

			const promises = require('bluebird');

			this.$config = this.$parent.$config.subservices[this.name];
			const thisConfig = JSON.parse(JSON.stringify(this.$config));

			thisConfig.debug = (thisConfig.debug === true);

			if(thisConfig.connection) thisConfig.connection.port = Number(thisConfig.connection.port);

			if(thisConfig.pool) {
				thisConfig.pool.min = Number(thisConfig.pool.min);
				thisConfig.pool.max = Number(thisConfig.pool.max);

				thisConfig.pool['afterCreate'] = function(rawConnection, done) {
					const pgError = require('pg-error');

					rawConnection.connection.parseE = pgError.parse;
					rawConnection.connection.parseN = pgError.parse;

					rawConnection.connection.on('PgError', function(err) {
						switch (err.severity) {
							case 'ERROR':
							case 'FATAL':
							case 'PANIC':
								this.emit('error', err);
								break;

							default:
								this.emit('notice', err);
								break;
						}
					});

					promises.promisifyAll(rawConnection.connection);
					done();
				};
			}

			const pg = require('pg');

			this.$database = promises.promisifyAll(new pg.Client(thisConfig.connection));
			await this.$database.connectAsync();

			this.$database.on('notice', this._databaseNotice.bind(this));
			this.$database.on('notification', this._databaseNotification.bind(this));

			let rootModule = this.$parent;
			while(rootModule.$parent) rootModule = rootModule.$parent;

			await this.$database.queryAsync(`LISTEN "${rootModule.$application}!Config!Changed"`);
			await this.$database.queryAsync(`LISTEN "${rootModule.$application}!State!Changed"`);

			await this._reloadAllConfig();
			return null;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::_setup error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof DatabaseConfigurationService
	 * @name     _teardown
	 *
	 * @returns  {boolean} Boolean true/false.
	 *
	 * @summary  Shutdown the database connection.
	 */
	async _teardown() {
		try {
			if(!this.$database)
				return null;

			let rootModule = this.$parent;
			while(rootModule.$parent) rootModule = rootModule.$parent;

			await this.$database.queryAsync(`UNLISTEN "${rootModule.$application}!Config!Changed"`);
			await this.$database.queryAsync(`UNLISTEN "${rootModule.$application}!State!Changed"`);

			this.$database.end();
			delete this.$database;

			await super._teardown();
			return null;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::_teardown error`, err);
		}
	}
	// #endregion

	// #region API
	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof DatabaseConfigurationService
	 * @name     loadConfiguration
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires configuration.
	 *
	 * @returns  {Object} - The twyrModule's database-based configuration.
	 *
	 * @summary  Retrieves the configuration of the twyrModule requesting for it.
	 */
	async loadConfiguration(twyrModule) {
		try {
			if(!this.$database)
				return {};

			const twyrModulePath = this.$parent._getPathForModule(twyrModule);
			const cachedModule = this._getCachedModule(twyrModulePath);
			if(cachedModule) {
				twyrModule.displayName = cachedModule.displayName;
				return cachedModule.configuration;
			}

			return {};
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::loadConfiguration::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof DatabaseConfigurationService
	 * @name     saveConfiguration
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires configuration.
	 * @param    {Object} config - The {@link TwyrBaseModule}'s' configuration that should be persisted.
	 *
	 * @returns  {Object} - The twyrModule configuration.
	 *
	 * @summary  Saves the configuration of the twyrModule requesting for it.
	 */
	async saveConfiguration(twyrModule, config) {
		try {
			if(!this.$database)
				return {};

			const twyrModulePath = this.$parent._getPathForModule(twyrModule);
			const cachedModule = this._getCachedModule(twyrModulePath);
			if(!cachedModule) return {};

			const deepEqual = require('deep-equal');
			if(deepEqual(cachedModule.configuration, config))
				return config;

			cachedModule.configuration = config;

			await this.$database.queryAsync('UPDATE modules SET configuration = $1 WHERE module_id = $2;', [config, cachedModule.module_id]);
			return config;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::saveConfiguration::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof DatabaseConfigurationService
	 * @name     getModuleState
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires its state.
	 *
	 * @returns  {boolean} - The twyrModule state (enabled / disabled).
	 *
	 * @summary  Retrieves and returns the module state.
	 */
	async getModuleState(twyrModule) {
		try {
			const twyrModulePath = this.$parent._getPathForModule(twyrModule);
			const cachedModule = this._getCachedModule(twyrModulePath);
			return cachedModule ? cachedModule.enabled : true;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::getModuleState::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof DatabaseConfigurationService
	 * @name     setModuleState
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires its state.
	 * @param    {boolean} enabled - State of the module.
	 *
	 * @returns  {null} - The module state.
	 *
	 * @summary  Sets the module state in the database.
	 */
	async setModuleState(twyrModule, enabled) {
		try {
			const twyrModulePath = this.$parent._getPathForModule(twyrModule);
			const cachedModule = this._getCachedModule(twyrModulePath);
			if(!cachedModule) return enabled;

			if(cachedModule.enabled === enabled)
				return enabled;

			cachedModule.enabled = enabled;
			await this.$database.queryAsync('UPDATE modules SET enabled = $1 WHERE module_id = $2', [enabled, cachedModule.module_id]);

			return enabled;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::setModuleState::${twyrModule.name} error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof DatabaseConfigurationService
	 * @name     getModuleId
	 *
	 * @param    {TwyrBaseModule} twyrModule - Instance of the {@link TwyrBaseModule} that requires its state.
	 *
	 * @returns  {null} - The database ID of the twyrModule.
	 *
	 * @summary  Retrieves the configuration of the twyrModule requesting for it.
	 */
	async getModuleId(twyrModule) {
		try {
			const twyrModulePath = this.$parent._getPathForModule(twyrModule);
			const cachedModule = this._getCachedModule(twyrModulePath);

			return cachedModule ? cachedModule.module_id : null;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::getModuleId::${twyrModule.name} error`, err);
		}
	}

	// #region Private Methods
	/**
	 * @async
	 * @function
	 * @instance
	 * @private
	 * @memberof DatabaseConfigurationService
	 * @name     _processConfigChange
	 *
	 * @param    {string} configUpdateModule - The path of the module for which the configuration changed.
	 * @param    {Object} config - The updated configuration.
	 *
	 * @returns  {null} - Nothing.
	 *
	 * @summary  Persists the configuration to the database.
	 */
	async _processConfigChange(configUpdateModule, config) {
		try {
			const cachedModule = this._getCachedModule(configUpdateModule);
			if(!cachedModule) return;

			const deepEqual = require('deep-equal');
			if(deepEqual(cachedModule.configuration, config))
				return;

			cachedModule.configuration = config;
			await this.$database.queryAsync('UPDATE modules SET configuration = $1 WHERE module_id = $2;', [config, cachedModule.module_id]);

			return;
		}
		catch(err) {
			console.error(`Process changed configuration to database error: ${err.message}\n${err.stack}`);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @private
	 * @memberof DatabaseConfigurationService
	 * @name     _processStateChange
	 *
	 * @param    {string} configUpdateModule - The path of the module for which the state changed.
	 * @param    {boolean} state - The updated state.
	 *
	 * @returns  {null} - Nothing.
	 *
	 * @summary  Persists the state to the database.
	 */
	async _processStateChange(configUpdateModule, state) {
		try {
			const cachedModule = this._getCachedModule(configUpdateModule);
			if(!cachedModule) return;

			if(cachedModule.enabled === state)
				return;

			cachedModule.enabled = state;
			await this.$database.queryAsync('UPDATE modules SET enabled = $1 WHERE module_id = $2;', [state, cachedModule.module_id]);

			return;
		}
		catch(err) {
			console.error(`Process changed state to database error: ${err.message}\n${err.stack}`);
		}
	}

	async _reloadAllConfig() {
		try {
			let serverModule = this.$parent;
			while(serverModule.$parent) serverModule = serverModule.$parent;

			const serverId = await this.$database.queryAsync('SELECT module_id FROM modules WHERE name = $1 AND parent_module_id IS NULL', [serverModule.$application]);
			let moduleConfigs = null;

			if(serverId.rows.length) {
				moduleConfigs = await this.$database.queryAsync('SELECT A.*, B.display_name, B.configuration, B.enabled FROM fn_get_module_descendants($1) A INNER JOIN modules B ON (A.module_id = B.module_id)', [serverId.rows[0]['module_id']]);
				moduleConfigs = moduleConfigs.rows;
			}

			this.$cachedMap = {};

			const configTree = this._reorderConfigsToTree(moduleConfigs, null);
			this._convertTreeToPaths(this.$cachedMap, '', configTree);

			return null;
		}
		catch(err) {
			console.error(`Reload configurations from database error: ${err.message}\n${err.stack}`);
			return null;
		}
	}

	_reorderConfigsToTree(moduleConfigs, parent) {
		const inflection = require('inflection');

		const filteredConfigs = moduleConfigs.filter((moduleConfig) => {
			return (moduleConfig.parent_module_id === parent);
		});

		const tree = {};
		filteredConfigs.forEach((filteredConfig) => {
			const relevantConfig = {
				'module_id': filteredConfig.module_id,
				'name': filteredConfig.name,
				'displayName': filteredConfig.display_name,
				'configuration': filteredConfig.configuration,
				'enabled': filteredConfig.enabled
			};

			const subModuleConfigs = this._reorderConfigsToTree(moduleConfigs, relevantConfig.module_id);
			Object.keys(subModuleConfigs).forEach((moduleType) => {
				relevantConfig[moduleType] = subModuleConfigs[moduleType];
			});

			if(filteredConfig.type === 'server') {
				tree['server'] = relevantConfig;
			}
			else {
				if(!tree[`${filteredConfig.type}s`]) tree[`${filteredConfig.type}s`] = {};
				tree[`${filteredConfig.type}s`][inflection.underscore(filteredConfig.name)] = relevantConfig;
			}
		});

		return tree;
	}

	_convertTreeToPaths(cachedMap, prefix, configTree) {
		const path = require('path');
		const twyrModuleTypes = ['components', 'features', 'middlewares', 'services', 'templates'];

		Object.keys(configTree).forEach((key) => {
			let currentPrefix = path.join(prefix, key);
			if(currentPrefix.startsWith('/')) currentPrefix = currentPrefix.substring(1);

			if(cachedMap[currentPrefix])
				return;

			if((twyrModuleTypes.indexOf(key) < 0) && !configTree[key]['configuration'])
				return;

			if(configTree[key]['configuration'])
				cachedMap[currentPrefix] = {
					'module_id': configTree[key]['module_id'],
					'name': configTree[key]['name'],
					'displayName': configTree[key]['display_name'],
					'configuration': configTree[key]['configuration'],
					'enabled': configTree[key]['enabled']
				};

			this._convertTreeToPaths(cachedMap, currentPrefix, configTree[key]);
		});
	}

	_getCachedModule(twyrModulePath) {
		try {
			return this.$cachedMap[twyrModulePath];
		}
		catch(err) {
			console.error(`Get cached module error: ${err.message}\n${err.stack}`);
			return null;
		}
	}

	_databaseNotification(data) {
		if(twyrEnv === 'development' || twyrEnv === 'test') console.log(`${this.name}::_databaseNotification: ${JSON.stringify(data, null, '\t')}`);

		let rootModule = this.$parent;
		while(rootModule.$parent) rootModule = rootModule.$parent;

		if(data.channel === `${rootModule.$application}ConfigChange`) {
			this._databaseConfigurationChange(data.payload);
			return null;
		}

		if(data.channel === `${rootModule.$application}StateChange`) {
			this._databaseStateChange(data.payload);
			return null;
		}

		return null;
	}

	async _databaseConfigurationChange(moduleId) {
		try {
			const deepEqual = require('deep-equal');

			let twyrModulePath = null;
			Object.keys(this.$cachedMap).forEach((cachedKey) => {
				if(this.$cachedMap[cachedKey]['module_id'] === moduleId)
					twyrModulePath = cachedKey;
			});

			if(!twyrModulePath) return null;

			const result = await this.$database.queryAsync('SELECT configuration FROM modules WHERE module_id = $1', [moduleId]);
			if(!result.rows.length) return null;

			if(deepEqual(this.$cachedMap[twyrModulePath].configuration, result.rows[0].configuration))
				return null;

			this.$cachedMap[twyrModulePath].configuration = result.rows[0].configuration;
			this.$parent.emit('update-config', this.name, twyrModulePath, this.$cachedMap[twyrModulePath].configuration);

			return null;
		}
		catch(err) {
			console.error(`Process updated config in database error: ${err.message}\n${err.stack}`);
			return null;
		}
	}

	async _databaseStateChange(moduleId) {
		try {
			let twyrModulePath = null;
			Object.keys(this.$cachedMap).forEach((cachedKey) => {
				if(this.$cachedMap[cachedKey]['module_id'] === moduleId)
					twyrModulePath = cachedKey;
			});

			if(!twyrModulePath) return null;

			const result = await this.$database.queryAsync('SELECT enabled FROM modules WHERE module_id = $1', [moduleId]);
			if(!result.rows.length) return null;

			if(this.$cachedMap[twyrModulePath].enabled === result.rows[0].enabled)
				return null;

			this.$cachedMap[twyrModulePath].enabled = result.rows[0].enabled;

			this.$parent.emit('update-state', this.name, twyrModulePath, this.$cachedMap[twyrModulePath].enabled);
			return null;
		}
		catch(err) {
			console.error(`Process updated state in database error: ${err.message}\n${err.stack}`);
			return null;
		}
	}

	_databaseQuery(queryData) {
		if((twyrEnv === 'development' || twyrEnv === 'test') && this.$config.debug) console.debug(`${this.name}::_databaseQuery: ${JSON.stringify(queryData, undefined, '\t')}`);
	}

	_databaseNotice() {
		if((twyrEnv === 'development' || twyrEnv === 'test') && this.$config.debug) console.info(`${this.name}::_databaseNotice: ${JSON.stringify(arguments, undefined, '\t')}`);
	}

	_databaseQueryError(error, query) {
		const queryLog = { 'sql': query.sql, 'bindings': query.bindings, 'options': query.options };
		this.$dependencies.LoggerService.error(`${this.name}::_databaseQueryError:\nQuery: ${JSON.stringify(queryLog, null, '\t')}\nError: ${JSON.stringify(error, null, '\t')}`);
	}
	// #endregion

	// #region Properties
	/**
	 * @override
	 */
	get basePath() {
		return __dirname;
	}
	// #endregion
}

exports.service = DatabaseConfigurationService;