framework/twyr-base-feature.js

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

'use strict';

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

/**
 * Module dependencies, required for this module
 * @ignore
 */
const TwyrBaseModule = require('./twyr-base-module').TwyrBaseModule;
const TwyrFeatureError = require('./twyr-feature-error').TwyrFeatureError;

/**
 * @class   TwyrBaseFeature
 * @extends {TwyrBaseModule}
 * @classdesc The Twyr Web Application Server Base Class for all Features.
 *
 * @param   {TwyrBaseModule} [parent] - The parent module, if any.
 * @param   {TwyrModuleLoader} [loader] - The loader to be used for managing modules' lifecycle, if any.
 *
 * @description
 * Serves as the "base class" for all other features in the Twyr Web Application Server.
 *
 */
class TwyrBaseFeature extends TwyrBaseModule {
	// #region Constructor
	constructor(parent, loader) {
		super(parent, null);

		const TwyrFeatureLoader = require('./twyr-feature-loader').TwyrFeatureLoader;
		const actualLoader = (loader instanceof TwyrFeatureLoader) ? loader : new TwyrFeatureLoader(this);

		this.$loader = actualLoader;

		const inflection = require('inflection');
		const Router = require('koa-router');

		const inflectedName = inflection.transform(this.name, ['foreign_key', 'dasherize']).replace('-id', '');
		this.$router = new Router({ 'prefix': `/${inflectedName}` });

		// The RBAC Koa middleware
		// this.$router.use(this._doesUserHavePermission.bind(this));

		// The ABAC Koa middleware
		// this.$router.use(this._canUserAccessThisResource.bind(this));
	}
	// #endregion

	// #region Lifecycle hooks
	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     _setup
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Adds the Koa Router routes.
	 */
	async _setup() {
		try {
			await super._setup();
			await this._addRoutes();

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

	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     _teardown
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Destroys the Koa Router routes.
	 */
	async _teardown() {
		try {
			await this._deleteRoutes();
			await super._teardown();

			return null;
		}
		catch(err) {
			throw new TwyrFeatureError(`${this.name}::_teardown error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     _subModuleReconfigure
	 *
	 * @param    {TwyrBaseModule} subModule - The sub-module that changed configuration.
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Lets the module know that one of its subModules changed configuration.
	 */
	async _subModuleReconfigure(subModule) {
		await super._subModuleReconfigure(subModule);

		const subModuleType = Object.getPrototypeOf(Object.getPrototypeOf(subModule)).name;
		if((subModuleType !== 'TwyrBaseComponent') && (subModuleType !== 'TwyrBaseFeature'))
			return null;

		await this._deleteRoutes();
		await this._addRoutes();

		await this.$parent._subModuleReconfigure(this);
		return null;
	}
	// #endregion

	// #region API - to be overriden by derived classes, only if required
	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     getClientsideAssets
	 *
	 * @param    {Object} ctxt - Koa context.
	 * @param    {Object} tenantFeatures - Sub-features allowed for this Tenant.
	 *
	 * @returns  {Object} Containing the compiled Ember.js client-side assets (specifically the route map, but could be others, as well).
	 *
	 * @summary  Returns the route map that is used by the Ember.js Router on the browser.
	 */
	async getClientsideAssets(ctxt, tenantFeatures) {
		const assets = this.EmberAssets;
		if(!assets) return null;

		const inflection = require('inflection');

		const featureNames = Object.keys(tenantFeatures || {});
		const clientsideAssets = {
			'RouteMap': JSON.parse(JSON.stringify(assets['RouteMap']))
		};

		for(let idx = 0; idx < featureNames.length; idx++) {
			const featureName = featureNames[idx];
			const feature = this.$features[featureName];

			const featureClientsideAssets = await feature.getClientsideAssets(ctxt, tenantFeatures[featureName]);
			if(!featureClientsideAssets) continue;

			Object.keys(featureClientsideAssets).forEach((featureClientsideAssetName) => {
				if(featureClientsideAssetName === 'RouteMap') {
					const inflectedFeatureName = inflection.transform(featureName, ['foreign_key', 'dasherize']).replace('-id', '');
					clientsideAssets['RouteMap'][inflectedFeatureName] = {
						'path': `/${inflectedFeatureName}`,
						'routes': featureClientsideAssets['RouteMap']
					};

					return;
				}

				if(!clientsideAssets[featureClientsideAssetName]) clientsideAssets[featureClientsideAssetName] = [];
				clientsideAssets[featureClientsideAssetName].push(featureClientsideAssets[featureClientsideAssetName]);
			});
		}

		Object.keys(clientsideAssets).forEach((clientsideAssetName) => {
			if(clientsideAssetName === 'RouteMap') return;
			clientsideAssets[clientsideAssetName] = clientsideAssets[clientsideAssetName].join('\n');
		});

		return clientsideAssets;
	}
	// #endregion

	// #region Protected methods - need to be overriden by derived classes
	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     getDashboardDisplayDetails
	 *
	 * @param    {Object} ctxt - Koa context.
	 *
	 * @returns  {Object} Dashboard display stuff for this Feature.
	 *
	 * @summary  Derived classes should return details, or null - depending on whether the user has the required permission(s).
	 */
	async getDashboardDisplayDetails(ctxt) { // eslint-disable-line no-unused-vars
		const inflection = require('inflection');

		const id = await this.$dependencies.ConfigurationService.getModuleID(this);
		const inflectedFeatureName = inflection.transform(this.name, ['foreign_key', 'dasherize']).replace('-id', '');

		return {
			'id': id,
			'type': 'dashboard/feature',

			'attributes': {
				'name': inflection.transform(this.name, ['tableize', 'singularize', 'titleize']),
				'type': 'feature',
				'route': inflectedFeatureName,
				'description': this.name,

				// eslint-disable-next-line no-inline-comments
				'icon_type': 'fa', // Other choices are paper, mdi, img, custom
				'icon_path': 'laptop-code'
			}
		};
	}

	/**
	 * @function
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     _rbac
	 *
	 * @param    {string} permission - The permission to check for.
	 *
	 * @returns  {undefined} Koa middleware that can be injected into a route.
	 *
	 * @summary  Derived classes should call next, or throw a {TwyrFeatureError} - depending on whether the user has the required permission(s).
	 */
	_rbac(permission) {
		return async function(ctxt, next) {
			// console.log(`Requested Permission: ${permission},\nUser Permissions: ${JSON.stringify(ctxt.state.user.permissions, null, '\t')}`);
			if(ctxt.state.user && ctxt.state.user.permissions) {
				if(permission === 'registered') {
					if(next) await next();
					return;
				}

				const doesUserHavePermission = ctxt.state.user.permissions.filter((userPerm) => {
					return (userPerm.name === permission);
				}).length;

				if(doesUserHavePermission) {
					if(next) await next();
					return;
				}
			}

			throw new TwyrFeatureError('User doesn\'t have the required permissions');
		};
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     _abac
	 *
	 * @param    {Object} ctxt - Koa context.
	 * @param    {callback} next - Callback to pass the request on to the next route in the chain.
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Derived classes should call next, or throw a {TwyrFeatureError} - depending on whether the user can access this particular resource.
	 */
	async _abac(ctxt, next) {
		await next();
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     _addRoutes
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Adds routes to the Koa Router.
	 */
	async _addRoutes() {
		if(twyrEnv === 'development' || twyrEnv === 'test') console.log(`${this.name}::_addRoutes`);

		// Add in the sub-components routes
		Object.keys(this.$components || {}).forEach((componentName) => {
			const componentRouter = this.$components[componentName].Router;
			this.$router.use(componentRouter.routes());
		});

		// Add in the sub-features routes
		Object.keys(this.$features || {}).forEach((featureName) => {
			const featureRouter = this.$features[featureName].Router;
			this.$router.use(featureRouter.routes());
		});

		return null;
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseFeature
	 * @name     _deleteRoutes
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Removes all the routes from the Koa Router.
	 */
	async _deleteRoutes() {
		if(twyrEnv === 'development' || twyrEnv === 'test') console.log(`${this.name}::_deleteRoutes`);

		// NOTICE: Undocumented koa-router data structure.
		// Be careful upgrading :-)
		if(this.$router) this.$router.stack.length = 0;
		return null;
	}
	// #endregion

	// #region Properties
	/**
	 * @member   {Object} Router
	 * @instance
	 * @memberof TwyrBaseFeature
	 *
	 * @readonly
	 */
	get Router() {
		return this.$router;
	}

	/**
	 * @member   {Object} EmberAssets
	 * @instance
	 * @memberof TwyrBaseFeature
	 *
	 * @readonly
	 */
	get EmberAssets() {
		// if(twyrEnv === 'development' || twyrEnv === 'test') console.log(`${this.name}::EmberAssets`);

		return {
			'RouteMap': {
				'index': {
					'path': '/',
					'routes': {}
				}
			}
		};
	}

	/**
	 * @override
	 */
	get dependencies() {
		return ['*'].concat(super.dependencies);
	}

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

exports.TwyrBaseFeature = TwyrBaseFeature;