controllers/crudaid.js

/**
 * @module controllers/crudaid
 * @author jesse reichler <mouser@donationcoder.com>
 * @copyright 6/5/19
 * @description
 * This module defines CrudAid class, which provides support functions for crud (Create, Update, Delete, List) actions on model data in the database
 */

"use strict";


// modules
const fs = require("fs");
const path = require("path");

// modules
const hbs = require("hbs");


// requirement service locator
const jrequire = require("../helpers/jrequire");

// helpers
const JrContext = require("../helpers/jrcontext");
const jrhText = require("../helpers/jrh_text");
const jrhMisc = require("../helpers/jrh_misc");
const jrhGrid = require("../helpers/jrh_grid");
const jrdebug = require("../helpers/jrdebug");
const jrhExpress = require("../helpers/jrh_express");

// controllers
const arserver = jrequire("arserver");
// const aclAid = jrequire("aclaid");

// constants
const appdef = jrequire("appdef");




/**
 * Povides support functions for crud (Create, Update, Delete, List) actions on model data in the database
 *
 * @class CrudAid
 */
class CrudAid {

	//---------------------------------------------------------------------------
	// constructor
	constructor() {
	}
	//---------------------------------------------------------------------------




	//---------------------------------------------------------------------------
	/**
	 * Setup a router for a model's crud access (add/edit/view/list/delete)
	 *
	 * @param {*} router
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @memberof CrudAid
	 */
	setupRouter(router, modelClass, baseCrudUrl) {
		// this is called during server setup, for each route that we want to provide crud route support on
		// note that we use const variables with different names here so that we can precalc the view files ONCE
		// and use that calc'd path each time the request is made without having to recompute it


		const extraViewData = {};

		//---------------------------------------------------------------------------
		// list
		const viewFilePathList = this.calcViewFile("list", modelClass);
		router.get("/", async (req, res, next) => await this.handleListGet(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathList, extraViewData));
		// post for bulk operations
		router.post("/", async (req, res, next) => await this.handleListPost(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathList, extraViewData));
		//---------------------------------------------------------------------------

		//---------------------------------------------------------------------------
		// add (get)
		const viewFilePathAdd = this.calcViewFile("addedit", modelClass);
		router.get("/add/:id?", async (req, res, next) => await this.handleAddGet(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathAdd, extraViewData));
		// add (post submit)
		router.post("/add/:ignoredid?", async (req, res, next) => await this.handleAddPost(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathAdd, extraViewData));
		//---------------------------------------------------------------------------

		//---------------------------------------------------------------------------
		// edit (get)
		const viewFilePathEdit = this.calcViewFile("addedit", modelClass);
		router.get("/edit/:id", async (req, res, next) => await this.handleEditGet(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathEdit));
		// edit (post submit)
		router.post("/edit/:ignoredid?", async (req, res, next) => await this.handleEditPost(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathEdit, extraViewData));
		//---------------------------------------------------------------------------

		//---------------------------------------------------------------------------
		// view (get)
		const viewFilePathView = this.calcViewFile("viewdelete", modelClass);
		router.get("/view/:id", async (req, res, next) => await this.handleViewGet(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathView, extraViewData));
		//---------------------------------------------------------------------------

		//---------------------------------------------------------------------------
		// delete (get)
		const viewFilePathDelete = this.calcViewFile("viewdelete", modelClass);
		router.get("/delete/:id", async (req, res, next) => await this.handleChangeModeGet(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathDelete, extraViewData, "delete"));
		// delete (post submit)
		router.post("/delete/:ignoredid?", async (req, res, next) => await this.handleChangeModePost(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathDelete, extraViewData, "delete"));
		//---------------------------------------------------------------------------

		//---------------------------------------------------------------------------
		// PermDelete (get)
		const viewFilePathPermDelete = this.calcViewFile("viewdelete", modelClass);
		router.get("/permdelete/:id", async (req, res, next) => await this.handleChangeModeGet(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathPermDelete, extraViewData, "permdelete"));
		// PermDelete (post submit)
		router.post("/permdelete/:ignoredid?", async (req, res, next) => await this.handleChangeModePost(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathPermDelete, extraViewData, "permdelete"));
		//---------------------------------------------------------------------------

		//---------------------------------------------------------------------------
		// UNdelete (get)
		const viewFilePathUnDelete = this.calcViewFile("viewdelete", modelClass);
		router.get("/undelete/:id", async (req, res, next) => await this.handleChangeModeGet(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathUnDelete, extraViewData, "undelete"));
		// UNdelete (post submit)
		router.post("/undelete/:ignoredid?", async (req, res, next) => await this.handleChangeModePost(JrContext.makeNew(req, res, next), modelClass, baseCrudUrl, viewFilePathUnDelete, extraViewData, "undelete"));
		//---------------------------------------------------------------------------


	}
	//---------------------------------------------------------------------------








	//---------------------------------------------------------------------------
	// These functions do the actual work of crud routes

	/**
	 * Route invokes this on a list get route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on route handled
	 * @memberof CrudAid
	 */
	async handleListGet(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		// acl test
		if (!await arserver.aclRequireModelAccessRenderErrorPageOrRedirect(jrContext, user, modelClass, appdef.DefAclActionList)) {
			return true;
		}

		// present the list
		return await this.doPresentListForm(jrContext, user, modelClass, baseCrudUrl, viewFileSet, extraViewData);
	}


	/**
	 * Route invokes this on a list post route, which happens when user performs bulk actions on list view
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on route handled
	 * @memberof CrudAid
	 */
	async handleListPost(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// this is called for bulk action

		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		// acl test
		if (!await arserver.aclRequireModelAccessRenderErrorPageOrRedirect(jrContext, user, modelClass, appdef.DefAclActionList)) {
			return true;
		}
		// check required csrf token
		arserver.testCsrf(jrContext);
		if (!jrContext.isError()) {
			// get bulk action options
			const formbody = jrContext.req.body;
			const bulkAction = formbody.bulkaction;
			// get all checked checkboxes
			const checkboxIdList = jrhExpress.reqPrefixedCheckboxItemIds(formbody, "checkboxid_");

			// do the bulk action and add result to session
			await this.doBulkAction(jrContext, user, modelClass, bulkAction, checkboxIdList);
		}

		// add result (error or result of bulk action) to session? NO because we include when we present the form, and this addToSession is only for when we redirect, etc.
		// jrContext.addToThisSession();

		// present the list
		return await this.doPresentListForm(jrContext, user, modelClass, baseCrudUrl, viewFileSet, extraViewData);
	}




	/**
	 * Shared function for handling list route (get and post)
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} user
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async doPresentListForm(jrContext, user, modelClass, baseCrudUrl, viewFileSet, extraViewData) {

		// ATTN: We might set these differently based on who is logged in and looking at the list
		// and it should be a per-modelClass thing..
		// ATTN: testing here some manual items:
		const protectedFields = ["passwordHashed"];
		let hiddenFields = [];

		// parse view file set
		const { viewFile, isGeneric } = viewFileSet;

		// hidden fields for list view
		const hiddenFiledsSchema = await modelClass.calcHiddenSchemaKeysForView(jrContext, "list");
		hiddenFields = jrhMisc.mergeArraysDedupe(hiddenFields, hiddenFiledsSchema);

		// make helper data
		const helperData = await modelClass.calcCrudListHelperData(jrContext, user, baseCrudUrl, protectedFields, hiddenFields);

		// generic main html for page (add form)
		let genericMainHtml;
		if (isGeneric) {
			genericMainHtml = await this.buildGenericMainHtmlList(jrContext, helperData);
			genericMainHtml = new hbs.SafeString(genericMainHtml);
		}

		// render
		const jrResult = jrContext.mergeSessionMessages();
		jrContext.res.render(viewFile, {
			headline: modelClass.getNiceName() + " List",
			jrResult,
			csrfToken: arserver.makeCsrf(jrContext),
			helperData,
			genericMainHtml,
			baseCrudUrl,
			extraViewData,
		});

		return true;
	}
	//---------------------------------------------------------------------------























	//---------------------------------------------------------------------------
	/**
	 * Handles get add route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async handleAddGet(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);
		let reqbody;

		// acl test to add
		if (!await arserver.aclRequireModelAccessRenderErrorPageOrRedirect(jrContext, user, modelClass, appdef.DefAclActionAdd)) {
			return true;
		}

		// present form
		await this.doPresentAddForm(jrContext, reqbody, user, modelClass, baseCrudUrl, viewFileSet, extraViewData);
		return true;
	}


	/**
	 * Handles post add route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async handleAddPost(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// user posts form for adding submission

		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		const formTypeStr = "add";
		const flagRepresentAfterSuccess = false;
		let reqbody = jrContext.req.body;

		// acl test
		if (!await arserver.aclRequireModelAccessRenderErrorPageOrRedirect(jrContext, user, modelClass, appdef.DefAclActionAdd)) {
			return true;
		}


		// load existing object by id if provided, throw errors if id missing (or provided to add formtype)
		// in the ADD case, this should just return a new blank object or complain if user specified an id
		const obj = await modelClass.validateAddEditFormIdMakeObj(jrContext, formTypeStr);
		// add creator
		obj.creator = user.getIdAsString();


		if (!jrContext.isError()) {
			// check required csrf token
			arserver.testCsrf(jrContext);
		}

		if (!jrContext.isError()) {
			// now save add changes
			// form fields that we dont complain about finding even though they arent for the form object
			const ignoreFields = this.getCrudEditFormIgnoreFields();

			// process

			const saveFields = modelClass.getSaveFields("crudAdd");
			let savedobj = await modelClass.validateSave(jrContext, {}, true, user, jrContext.req.body, saveFields, null, ignoreFields, obj, modelClass.getShouldBeOwned());
			if (!jrContext.isError()) {
				// success! drop down with new blank form, or alternatively, we could redirect to a VIEW obj._id page
				jrContext.pushSuccess(modelClass.getNiceName() + " added on " + jrhMisc.getNiceNowString() + ".");

				// log the action
				arserver.logr(jrContext, "crud.create", "created " + savedobj.getLogIdString());

				if (jrContext.isError()) {
					// we had an error saving user; this is serious because it leaves an orphaned object
					let errmsg = "There was an error saving the new owner role for " + user.getLogIdString() + " after creation of new object " + savedobj.getLogIdString() + ": " + jrContext.getErrorsAsString() + ".";
					// so first things first lets delete the object
					// we do a REAL delete here (as opposed to a virtual one) since the object was just added in failure
					// NOTE: we create a new context with no errors in it for this, so we can better check if this operations errors
					const jrContextFollowup = JrContext.makeNew(jrContext.req, jrContext.res, jrContext.next);
					await savedobj.doChangeMode(jrContextFollowup, appdef.DefMdbRealDelete);
					if (jrContextFollowup.isError()) {
						// yikes we couldn't even delete the object
						errmsg += "  In addition, the newly created object could not be rolled back and deleted: " + jrContextFollowup.getErrorsAsString();
					} else {
						// at least we rolled back the object
						errmsg += "  But the newly created object was successfully rolled back and deleted.";
					}
					// now log error
					arserver.logr(jrContext, appdef.DefLogTypeErrorCriticalDb, errmsg);

					// clear object
					savedobj = null;
				}


				if (!jrContext.isError()) {
					if (!baseCrudUrl) {
						// just return to caller saying they should take over
						return false;
					}

					if (flagRepresentAfterSuccess) {
						// success, so clear reqbody and drop down so they can add another
						reqbody = {};
					} else {
						jrContext.addToThisSession();
						jrContext.res.redirect(baseCrudUrl + "/view/" + savedobj.getIdAsString());
						return true;
					}
				}
			}
		}

		if (jrContext.isError()) {
			// error, so we need to fetch object for edit refinement..
		}

		// re-present form
		await this.doPresentAddForm(jrContext, reqbody, user, modelClass, baseCrudUrl, viewFileSet, extraViewData);
		return true;
	}


	/**
	 * Shared function for handling add request (get and post)
	 *
	 * @param {*} req
	 * @param {*} reqbody
	 * @param {*} jrResult
	 * @param {*} res
	 * @param {*} user
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @memberof CrudAid
	 */
	async doPresentAddForm(jrContext, reqbody, user, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// any helper data
		const helperData = await modelClass.calcCrudEditHelperData(user);

		// parse view file set
		const { viewFile, isGeneric } = viewFileSet;

		// generic main html for page (add form)
		let genericMainHtml;
		if (isGeneric) {
			const obj = null;
			genericMainHtml = await this.buildGenericMainHtmlAddEditView(jrContext, null, "add", modelClass, obj, reqbody, helperData);
			genericMainHtml = new hbs.SafeString(genericMainHtml);
		}

		// cancel button goes where?
		const cancelUrl = baseCrudUrl;

		// re-present form for another add?
		jrContext.res.render(viewFile, {
			headline: "Add " + modelClass.getNiceName(),
			jrResult: jrContext.mergeSessionMessages(),
			csrfToken: arserver.makeCsrf(jrContext),
			reqbody,
			helperData,
			genericMainHtml,
			baseCrudUrl,
			cancelUrl,
			crudAdd: true,
			extraViewData,
			id: null,
		});

	}
	//---------------------------------------------------------------------------




	//---------------------------------------------------------------------------
	/**
	 * 	 * Handles get edit route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async handleEditGet(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		// get id from get param
		const id = jrContext.req.params.id;

		// validate and get id, this will also do an ACL test
		const obj = await modelClass.validateGetObjByIdDoAclRenderErrorPageOrRedirect(jrContext, user, id, appdef.DefAclActionEdit);
		if (jrContext.isError()) {
			return false;
		}

		// present form
		return await this.doPresentEditForm(jrContext, null, obj, id, user, modelClass, baseCrudUrl, viewFileSet, extraViewData);
	}


	/**
	 * Handles post edit route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on handle
	 * @memberof CrudAid
	 */
	async handleEditPost(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// user posts form for adding submission

		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		// ATTN: in post of edit, we ignore the id passed in param and get it from post body
		const formTypeStr = "edit";
		const flagRepresentAfterSuccess = false;
		let reqbody = jrContext.req.body;

		// get id from post, ignore url param
		const id = jrContext.req.body._editId;
		const ignoreFields = this.getCrudEditFormIgnoreFields();

		// acl test
		if (!await arserver.aclRequireModelAccessRenderErrorPageOrRedirect(jrContext, user, modelClass, appdef.DefAclActionEdit, id)) {
			return false;
		}
		// form fields that we dont complain about finding even though they arent for the form object

		// load existing object by id if provided, throw errors if id missing (or provided to add formtype)
		const obj = await modelClass.validateAddEditFormIdMakeObj(jrContext, formTypeStr);

		if (!jrContext.isError()) {
			// check required csrf token
			arserver.testCsrf(jrContext);
		}

		if (!jrContext.isError()) {
			// now save edit changes
			const saveFields = modelClass.getSaveFields("crudEdit");
			const savedobj = await modelClass.validateSave(jrContext, {}, true, user, jrContext.req.body, saveFields, null, ignoreFields, obj, modelClass.getShouldBeOwned());

			if (!jrContext.isError()) {
				// success! drop down with new blank form, or alternatively, we could redirect to a VIEW obj._id page

				// log the action
				const idLabel = savedobj.getLogIdString();
				arserver.logr(jrContext, "crud.edit", "edited " + idLabel);

				// success message
				jrContext.pushSuccess(modelClass.getNiceName() + " saved on " + jrhMisc.getNiceNowString() + ".");
				if (!baseCrudUrl) {
					// just return to caller saying they should take over
					jrContext.addToThisSession();
					// ATTN: todo -- check if this return leads to failure to render/redirect any page?
					return false;
				}

				if (flagRepresentAfterSuccess) {
					reqbody = null;
				} else {
					jrContext.addToThisSession();
					jrContext.res.redirect(baseCrudUrl + "/view/" + savedobj.getIdAsString());
					return true;
				}
			}
		}

		// re-present form
		return await this.doPresentEditForm(jrContext, reqbody, obj, id, user, modelClass, baseCrudUrl, viewFileSet, extraViewData);
	}


	/**
	 * Common presentation of edit form
	 *
	 * @param {*} req
	 * @param {*} reqbody
	 * @param {*} jrResult
	 * @param {*} obj
	 * @param {*} id
	 * @param {*} res
	 * @param {*} user
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async doPresentEditForm(jrContext, reqbody, obj, id, user, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// any helper data
		const helperData = await modelClass.calcCrudEditHelperData(user, id);

		// parse view file set
		const { viewFile, isGeneric } = viewFileSet;

		// generic main html for page (edit form)
		let genericMainHtml;
		if (isGeneric) {
			genericMainHtml = await this.buildGenericMainHtmlAddEditView(jrContext, id, "edit", modelClass, obj, reqbody, helperData);
			genericMainHtml = new hbs.SafeString(genericMainHtml);
		}

		//
		const flagOfferDelete = modelClass.getDefaultDeleteDisableModeIsVirtual() && (obj.disabled === 0 || (obj.disabled !== appdef.DefMdbVirtDelete));
		const flagOfferUnDelete = obj.disabled === appdef.DefMdbVirtDelete;
		const flagOfferPermDelete = true;

		// cancel button goes where?
		const cancelUrl = baseCrudUrl + "/view/" + id;


		// render
		jrContext.res.render(viewFile, {
			headline: "Edit " + modelClass.getNiceName() + " #" + id,
			jrResult: jrContext.mergeSessionMessages(),
			csrfToken: arserver.makeCsrf(jrContext),
			reqbody,
			helperData,
			genericMainHtml,
			baseCrudUrl,
			cancelUrl,
			extraViewData,
			flagOfferDelete,
			flagOfferPermDelete,
			flagOfferUnDelete,
			id,
		});

		return true;
	}
	//---------------------------------------------------------------------------









	//---------------------------------------------------------------------------
	/**
	 * Handle get view route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async handleViewGet(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData) {
		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		// get id from get param
		const id = jrContext.req.params.id;

		// get obj AND perform acl test
		const obj = await modelClass.validateGetObjByIdDoAclRenderErrorPageOrRedirect(jrContext, user, id, appdef.DefAclActionView);
		if (jrContext.isError()) {
			return true;
		}

		// any helper data
		const helperData = await modelClass.calcCrudViewHelperData(jrContext, id, obj);

		// parse view file set
		const { viewFile, isGeneric } = viewFileSet;

		// generic main html for page (view form)
		let genericMainHtml;
		if (isGeneric) {
			const reqbody = null;
			genericMainHtml = await this.buildGenericMainHtmlAddEditView(jrContext, id, "view", modelClass, obj, reqbody, helperData);
			genericMainHtml = new hbs.SafeString(genericMainHtml);
		}

		const flagOfferDelete = modelClass.getDefaultDeleteDisableModeIsVirtual() && (obj.disabled === 0 || (obj.disabled !== appdef.DefMdbVirtDelete));
		const flagOfferUnDelete = obj.disabled === appdef.DefMdbVirtDelete;
		const flagOfferPermDelete = true;

		// render
		jrContext.res.render(viewFile, {
			headline: "View " + modelClass.getNiceName() + " #" + id,
			jrResult: jrContext.mergeSessionMessages(),
			obj,
			helperData,
			genericMainHtml,
			reqmode: "view",
			flagOfferDelete,
			flagOfferPermDelete,
			flagOfferUnDelete,
			baseCrudUrl,
			extraViewData,
		});

		return true;
	}




	/**
	 * Helper function that validated the change mode string and returns the ACL action associated with it or throws error
	 *
	 * @param {*} reqmode
	 * @returns the acl action string or null on error; error pushed to jrResult
	 * @memberof CrudAid
	 */
	getAclActionForChangeReqMode(jrContext, modelClass, reqmode) {
		if (reqmode === "virtdelete") {
			if (modelClass.supportsVirtualDelete()) {
				return appdef.DefAclActionDelete;
			}
			jrContext.pushError("Virtual delete not supported for model class " + modelClass.getNiceName());
			return null;
		}
		if (reqmode === "delete") {
			// this will either be a virtual delete or a permanent delete
			// return appdef.DefAclActionDelete;
			return modelClass.getDefaultDeleteDisableModeAsAclAction();
		}
		if (reqmode === "permdelete") {
			return appdef.DefAclActionPermDelete;
		}
		if (reqmode === "undelete") {
			return appdef.DefAclActionUnDelete;
		}
		// error
		jrContext.pushError("Unknown reqmode in getAclActionForChangeReqMode: " + reqmode);
		return null;
	}



	/**
	 * Given a change mode acl action return the database mode change value it represents
	 *
	 * @param {*} aclAction
	 * @param {*} jrResult
	 * @returns appdef.DefMdbVirtDelete or appdef.DefMdbRealDelete or appdef.DefMdbEnable
	 * @memberof CrudAid
	 */
	convertAclChangeModeActionToDeleteDatabaseStateValue(jrContext, aclAction) {
		// what kind of delete do we want, virtual or real?
		if (aclAction === appdef.DefAclActionDelete) {
			return appdef.DefMdbVirtDelete;
		}
		if (aclAction === appdef.DefAclActionPermDelete) {
			return appdef.DefMdbRealDelete;
		}
		if (aclAction === appdef.DefAclActionUnDelete) {
			return appdef.DefMdbEnable;
		}
		jrContext.pushError("Unknown aclAction in convertAclChangeModeActionToDeleteDatabaseStateValue: " + aclAction);
		return null;
	}




	/**
	 * Handle get chagemode route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @param {*} reqmode
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async handleChangeModeGet(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData, reqmode) {
		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		// get id from get param
		const id = jrContext.req.params.id;
		let obj;

		// which acl permission to check for
		const aclAction = this.getAclActionForChangeReqMode(jrContext, modelClass, reqmode);

		// get object AND perform ACL test
		if (!jrContext.isError()) {
			obj = await modelClass.validateGetObjByIdDoAclRenderErrorPageOrRedirect(jrContext, user, id, aclAction);
		}

		return await this.doPresentChangeModeForm(jrContext, id, obj, modelClass, baseCrudUrl, viewFileSet, extraViewData, reqmode);
	}



	/**
	 * 	 * Handle post chagemode route
	 *
	 * @param {*} req
	 * @param {*} res
	 * @param {*} next
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @param {*} reqmode
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async handleChangeModePost(jrContext, modelClass, baseCrudUrl, viewFileSet, extraViewData, reqmode) {

		// get logged in user
		const user = await arserver.lookupLoggedInUser(jrContext);

		// get id from post, ignore url param
		const id = jrContext.req.body._editId;
		let obj, newmode;

		// ATTN: change this to drop down re-present rather than error
		// check required csrf token
		arserver.testCsrf(jrContext);

		if (!jrContext.isError()) {
			// which acl permission to check for
			const aclAction = this.getAclActionForChangeReqMode(jrContext, modelClass, reqmode);

			if (!jrContext.isError()) {
				// get object AND perform ACL test
				obj = await modelClass.validateGetObjByIdDoAclRenderErrorPageOrRedirect(jrContext, user, id, aclAction);
			}

			if (!jrContext.isError()) {
				// process delete
				newmode = this.convertAclChangeModeActionToDeleteDatabaseStateValue(jrContext, aclAction);
			}

			if (!jrContext.isError()) {
				// do the actual mode change (delete / virtual or real)
				await obj.doChangeMode(jrContext, newmode);
			}
		}


		// on success redirect to listview
		if (!jrContext.isError()) {
			// success (push message to top since helper deleted may have been pushed on earlier)
			const objIdString = obj.getIdAsString();
			jrContext.pushSuccess(modelClass.getNiceName() + " #" + objIdString + " has been " + appdef.DefStateModeLabels[newmode] + ".", true);

			// log the action
			const logIdString = obj.getLogIdString();
			arserver.logr(jrContext, "crud." + reqmode, appdef.DefStateModeLabels[newmode] + " " + logIdString);

			// redirect
			jrContext.addToThisSession();
			jrContext.res.redirect(baseCrudUrl);
			return true;
		}

		// error, re-present form
		return await this.doPresentChangeModeForm(jrContext, id, obj, modelClass, baseCrudUrl, viewFileSet, extraViewData, reqmode);
	}



	/**
	 * Present the form that lets user change the mode (deleted, etc.)
	 *
	 * @param {*} id
	 * @param {*} obj
	 * @param {*} req
	 * @param {*} res
	 * @param {*} modelClass
	 * @param {*} baseCrudUrl
	 * @param {*} viewFileSet
	 * @param {*} extraViewData
	 * @param {*} reqmode
	 * @returns true on handled
	 * @memberof CrudAid
	 */
	async doPresentChangeModeForm(jrContext, id, obj, modelClass, baseCrudUrl, viewFileSet, extraViewData, reqmode) {

		// any helper data
		const helperData = await modelClass.calcCrudViewHelperData(jrContext, id, obj);

		// parse view file set
		const { viewFile, isGeneric } = viewFileSet;

		// generic main html for page (delete form)
		let genericMainHtml;
		if (isGeneric) {
			const reqbody = null;
			genericMainHtml = await this.buildGenericMainHtmlAddEditView(jrContext, id, "view", modelClass, obj, reqbody, helperData);
			genericMainHtml = new hbs.SafeString(genericMainHtml);
		}

		// cancel button goes where?
		const cancelUrl = baseCrudUrl + "/view/" + id;

		//
		const flagConfirmDelete = (reqmode === "delete");
		const flagConfirmPermDelete = (reqmode === "permdelete");
		const flagConfirmUnDelete = (reqmode === "undelete");

		// render
		jrContext.res.render(viewFile, {
			headline: "Confirmation required.\n" + jrhText.capitalizeFirstLetter(reqmode) + " " + modelClass.getNiceName() + " #" + id + "?",
			jrResult: jrContext.mergeSessionMessages(),
			csrfToken: arserver.makeCsrf(jrContext),
			obj,
			genericMainHtml,
			flagConfirmDelete,
			flagConfirmPermDelete,
			flagConfirmUnDelete,
			baseCrudUrl,
			cancelUrl,
			extraViewData,
		});

		return true;
	}

	//---------------------------------------------------------------------------




















	//---------------------------------------------------------------------------
	/**
	 * Helper for parsing crud forms
	 *
	 * @returns	array list of fields that we dont need to complain about and just ignore when they are found in an edit form submission
	 * @memberof CrudAid
	 */
	getCrudEditFormIgnoreFields() {
		return ["_csrf", "_editId", "emailBypassVerify"];
	}
	//---------------------------------------------------------------------------







	//---------------------------------------------------------------------------
	/**
	 * Helper function that calculates the view file to be used for different routes
	 * Checks first for model specific view, then defaults to crud generic if the specific one not found.
	 *
	 * @param {*} subview
	 * @param {*} modelClass
	 * @returns object with .viewfile and .isGeneric fields
	 * @memberof CrudAid
	 */
	calcViewFile(subview, modelClass) {
		const fname = path.join("crud", subview);
		const fnameModelSpecific = path.join("crud", modelClass.getCollectionName() + "_" + subview);
		const fnameModelGeneric = path.join("crud", "generic_" + subview);
		// try to find model specific version
		const fpath = path.join(arserver.getViewPath(), fnameModelSpecific + arserver.getViewExt());
		if (fs.existsSync(fpath)) {
			return {
				viewFile: fnameModelSpecific,
				isGeneric: false,
			};
		}
		return {
			viewFile: fnameModelGeneric,
			isGeneric: true,
		};
	}
	//---------------------------------------------------------------------------





















	//---------------------------------------------------------------------------
	/**
	 * NEW
	 * Generate the main html for add/edit/view crudSubType view
	 *
	 * @param {*} crudSubType
	 * @param {*} modelClass
	 * @param {*} req
	 * @param {*} obj
	 * @param {*} helperData
	 * @param {*} jrResult
	 * @returns html string
	 * @memberof CrudAid
	 */
	async buildGenericMainHtmlAddEditView(jrContext, id, crudSubType, modelClass, obj, editData, helperData) {
		let rethtml = "";

		// start table
		rethtml += `
		<div class="table-responsive">
		<table class="table table-striped w-auto table-bordered">
		`;

		// add id
		if (id) {
			rethtml += `<input type="hidden" id="_editId" name="_editId" value="${id}">`;
		}

		// schema for obj
		const modelSchema = modelClass.getSchemaDefinition();
		const schemaKeys = Object.keys(modelSchema);
		let schemaType;
		let val, valHtml, label, valueFunction, hideList, readOnlyList, choices;
		let visibleFunction, isVisible, isReadOnly;
		let extra;
		let err;

		await jrhMisc.asyncAwaitForEachFunctionCall(schemaKeys, async (fieldName) => {

			// type of this field
			schemaType = modelClass.getBaseSchemaType(fieldName);

			// hidden?
			hideList = modelClass.getSchemaFieldVal(fieldName, "hide", undefined);
			if ((hideList === true) || jrhMisc.isInAnyArray(crudSubType, hideList)) {
				return;
			}
			// dynamic visibility function
			visibleFunction = modelClass.getSchemaFieldVal(fieldName, "visibleFunction");
			if (visibleFunction) {
				// ok we have a custom function to call
				isVisible = await visibleFunction(jrContext, crudSubType, fieldName, obj, editData, helperData);
				if (!isVisible) {
					return;
				}
			}

			// label
			label = modelClass.getSchemaFieldVal(fieldName, "label", fieldName);

			// html value to display (may be an input element if in edit crudSubType)
			valHtml = await modelClass.renderFieldValueHtml(jrContext, obj, editData, fieldName, crudSubType, helperData);

			// add any error
			err = jrContext.getFieldError(fieldName, "");

			// render it
			rethtml += `
			<tr>
        		<td><strong>${label}</strong></td>
					  <td>${valHtml}`;

			if (err) {
				rethtml += ` <span class="jrErrorInline">${err}</span> `;
			}

			rethtml += ` </td>
			</tr>
			`;
		});

		// end table
		rethtml += `
		</table>
		</div>
		`;

		return rethtml;
	}
	//---------------------------------------------------------------------------





	//---------------------------------------------------------------------------
	/**
	 * Generate the main html for LIST view crudSubType view
	 *
	 * @param {*} modelClass
	 * @param {*} req
	 * @param {*} obj
	 * @param {*} jrResult
	 * @param {*} crudSubType
	 * @param {*} helperData
	 * @returns html string
	 * @memberof CrudAid
	 */
	async buildGenericMainHtmlList(jrContext, helperData) {
		const csrfToken = arserver.makeCsrf(jrContext);
		const rehtml = await jrhGrid.jrGridList(jrContext, helperData, csrfToken);
		return rehtml;
	}
	//---------------------------------------------------------------------------










	//---------------------------------------------------------------------------
	/**
	 * Before a bulk action (deleting, etc).
	 *
	 * @param {*} user
	 * @param {*} modelClass
	 * @param {*} bulkAction
	 * @param {*} idList
	 * @returns jrResult
	 * @memberof CrudAid
	 */
	async doBulkAction(jrContext, user, modelClass, bulkAction, idList) {

		if (bulkAction === "disable") {
			// do they have permission to delete all in the list
			const permission = appdef.DefAclActionDisable;
			const objectType = modelClass.getAclName();
			if (!await user.aclHasPermissionOnAll(jrContext, permission, objectType, idList)) {
				jrContext.pushError("Permission denied; you do not have permission to " + bulkAction + " these items.");
				return;
			}

			// they have permission!

			// what kind of delete should we do? real or virtual?
			const mode = appdef.DefMdbDisable;
			await modelClass.doChangeModeByIdList(jrContext, idList, mode, false);
			return;
		}

		if (bulkAction === "enable") {
			// do they have permission to delete all in the list
			const permission = appdef.DefAclActionEnable;
			const objectType = modelClass.getAclName();
			if (!await user.aclHasPermissionOnAll(jrContext, permission, objectType, idList)) {
				jrContext.pushError("Permission denied; you do not have permission to " + bulkAction + " these items.");
				return;
			}

			// they have permission!

			// what kind of delete should we do? real or virtual?
			const mode = appdef.DefMdbEnable;
			await modelClass.doChangeModeByIdList(jrContext, idList, mode, false);
			return;
		}


		if (bulkAction === "delete") {
			// do they have permission to delete all in the list
			const permission = appdef.DefAclActionDelete;
			const objectType = modelClass.getAclName();
			if (!await user.aclHasPermissionOnAll(jrContext, permission, objectType, idList)) {
				jrContext.pushError("Permission denied; you do not have permission to " + bulkAction + " these items.");
				return;
			}

			// they have permission!

			// what kind of delete should we do? real or virtual?
			const mode = modelClass.getDefaultDeleteDisableMode();

			await modelClass.doChangeModeByIdList(jrContext, idList, mode, false);
			return;
		}

		if (bulkAction === "permdelete") {
			// do they have permission to delete all in the list
			const permission = appdef.DefAclActionPermDelete;
			const objectType = modelClass.getAclName();
			if (!await user.aclHasPermissionOnAll(jrContext, permission, objectType, idList)) {
				jrContext.pushError("Permission denied; you do not have permission to " + bulkAction + " these items.");
				return;
			}

			// they have permission!

			// real permanent delete
			const mode = appdef.DefMdbRealDelete;

			await modelClass.doChangeModeByIdList(jrContext, idList, mode, false);
			return;
		}

		if (bulkAction === "undelete") {
			// do they have permission to undelete all in the list
			const permission = appdef.DefAclActionUnDelete;
			const objectType = modelClass.getAclName();
			if (!await user.aclHasPermissionOnAll(jrContext, permission, objectType, idList)) {
				jrContext.pushError("Permission denied; you do not have permission to " + bulkAction + " these items.");
				return;
			}

			// they have permission!

			// undelete means make enabled
			const mode = appdef.DefMdbEnable;

			await modelClass.doChangeModeByIdList(jrContext, idList, mode, false);
			return;
		}


		// dont know this bulk action
		jrContext.pushError("Internal error - unknown bulk operation [" + bulkAction + "]");
	}
	//---------------------------------------------------------------------------










}





// export the class as the sole export
module.exports = new CrudAid();