/*
* Contains program Server class
*
* Copyright (C) 2017, 2018 R-T Specialty, LLC.
*
* This file is part of the Liza Data Collection Framework.
*
* liza is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* @todo like Client and Ui, this mammoth did not evolve well and has too
* many responsibilities; refactor
*/
const { Class } = require( 'easejs' );
const { EventEmitter } = require( 'events' );
const fs = require( 'fs' );
const util = require( 'util' );
const {
bucket: {
bucket_filter,
QuoteDataBucket,
BucketSiblingDescriptor,
diff: {
StdBucketDiffContext,
GroupedBucketDiffContext,
StdBucketDiffResult,
GroupedBucketDiffResult,
StdBucketDiff,
},
},
field: {
FieldClassMatcher,
},
server: {
request: {
DataProcessor,
},
encsvc: {
QuoteDataBucketCipher,
},
},
util: {
ShallowArrayDiff,
},
} = require( '..' );
module.exports = Class( 'Server' )
.extend( EventEmitter,
{
'private response': null,
/**
* Dao
* @type {MongoServerDao}
*/
'private dao': null,
/**
* Stores references to program objects
* @type {Object}
*/
'private programs': {},
'private quoteFillHooks': [],
/**
* Logger to use
* @type {PriorityLog}
*/
'private logger': null,
/**
* Default bucket data for various programs
* @type {Object}
*/
'private _defaultBuckets': {},
/**
* Encryption service client
* @type {EncryptionService}
*/
'private _encService': null,
/**
* Holds bucket ciphers for each program
* @type {Object.}
*/
'private _bucketCiphers': {},
/**
* Step and program cache
*
* @type {Store}
*/
'private _cache': null,
/**
* Client-provided data processor
* @type {DataProcessor}
*/
'private _dataProcessor': null,
/**
* Initializes program
* @type {ProgramInit}
*/
'private _progInit': null,
'public __construct': function(
response, dao, logger, encsvc, data_processor, init
)
{
if ( !Class.isA( DataProcessor, data_processor ) )
{
throw TypeError( "Expected DataProcessor" );
}
this.response = response;
this.dao = dao;
this.logger = logger;
this._encService = encsvc;
this._dataProcessor = data_processor;
this._progInit = init;
},
'public init': function( cache, rater )
{
this._cache = cache;
this._initDb();
this.reload( rater );
},
'public reload': function( rater )
{
var _self = this;
rater.init(
// log
function( msg )
{
_self.logger.log( _self.logger.PRIORITY_IMPORTANT, msg );
},
// error
function( msg, stack )
{
stack = stack || '';
_self.logger.log( _self.logger.PRIORITY_ERROR,
"%s\n-!%s",
msg,
stack.replace( /\n/g, '\n-!' )
);
}
);
},
/**
* Initializes the database (attempt DAO connection)
*
* @return undefined
*/
'private _initDb': function()
{
var server = this;
// error listeners
this.dao.on( 'connectError', function( err )
{
server.logger.log( server.logger.PRIORITY_ERROR,
"Database connection failure: (%s) %s",
err.errno || '-',
err.message || ''
);
// attempt to reconnect every 5 seconds
setTimeout( function()
{
server.logger.log( server.logger.PRIORITY_DB,
"[Server] Attempting to reconnect to database..."
);
server.dao.connect();
}, 5000 );
}).on( 'saveQuoteError', function( err, quote )
{
server.logger.log( server.logger.PRIORITY_ERROR,
"Failed to save quote %d: %s",
quote.getId(),
err.message || ''
);
}).on( 'seqError', function( err )
{
server.logger.log( server.logger.PRIORITY_ERROR,
"Sequence error: %s",
err
);
}).on( 'seqInit', function( seq )
{
server.logger.log( server.logger.PRIORITY_DB,
"Initialized default sequence: %s",
seq
);
}).on( 'ready', function()
{
server.logger.log( server.logger.PRIORITY_DB,
"[Server] Connected to database; DAO ready"
);
});
server.logger.log( server.logger.PRIORITY_DB,
"[Server] Connecting to database..."
);
this.dao.init();
},
sendResponse: function( request, quote, data, action )
{
request.end( this.response.from( quote, data, action ) );
return this;
},
sendError: function( request, message, action, btn_caption )
{
request.end( this.response.error( message, action, btn_caption ) );
return this;
},
/**
* Initializes a quote with any existing quote data
*
* @return Server self to allow for method chaining
*/
initQuote: function( quote, program, request, callback, error_callback )
{
var server = this,
quote_id = quote.getId(),
session = request.getSession(),
agent_id = session.agentId(),
agent_name = session.agentName();
// get the data for this quote
this.dao.pullQuote( quote_id, function( quote_data )
{
var new_quote = false;
if ( !( quote_data ) )
{
quote_data = {};
new_quote = true;
// ensure it's a valid quote id
server.dao.getMinQuoteId( function( min_id )
{
// don't allow before the min quote id
if ( quote_id < min_id )
{
error_callback.call( server );
return;
}
server.dao.getMaxQuoteId( function( max_id )
{
if ( quote_id > max_id )
{
// we has a problem
error_callback.call( server );
return;
}
// we're good
server._getDefaultBucket( program, quote_data )
.then( default_bucket =>
init_finish( program, default_bucket )
);
});
});
}
else
{
// quote is not new; just continue
server.getProgram( quote_data.programId )
.then( function( quote_program )
{
server._getDefaultBucket( quote_program, quote_data )
.then( default_bucket =>
init_finish( quote_program, default_bucket )
);
} );
}
function init_finish( quote_program, default_bucket )
{
// fill in the quote data (with reasonable defaults if the quote
// does not yet exist); IMPORTANT: do not set pver to the
// current version here; the quote will be repaired if it is not
// set
quote
.setData( default_bucket )
.setMetadata( quote_data.meta || {} )
.setQuickSaveData( quote_data.quicksave || {} )
.setAgentId( quote_data.agentId || agent_id )
.setAgentName( quote_data.agentName || agent_name )
.setAgentEntityId( data.agentEntityId || "" )
.setInitialRatedDate( data.initialRatedDate || 0 )
.setStartDate(
quote_data.getStartDate
|| Math.round( new Date().getTime() / 1000 )
)
.setImported( quote_data.importedInd || false )
.setBound( quote_data.boundInd || false )
.needsImport( quote_data.importDirty || false )
.setCurrentStepId(
quote_data.currentStepId
|| quote_program.getFirstStepId()
)
.setTopVisitedStepId(
quote_data.topVisitedStepId
|| quote_program.getFirstStepId()
)
// it is important that we set this to top visited to
// ensure that (a) they cannot init a quote and skip the
// first step and (b) that older quotes without this field
// are properly initialized
.setTopSavedStepId(
quote_data.topSavedStepId
|| ( quote.getTopVisitedStepId() - 1 )
)
.setProgram( quote_program )
.setProgramVersion( quote_data.pver || '' )
.setExplicitLock(
( quote_data.explicitLock || '' ),
( quote_data.explicitLockStepId || 0 )
)
.setError( quote_data.error || '' )
.setCreditScoreRef( quote_data.creditScoreRef || 0 )
.setLastPremiumDate( quote_data.lastPremDate || 0 )
.setRatedDate( quote_data.initialRatedDate || 0 )
.on( 'stepChange', function( step_id )
{
// save the quote state (we don't care if it succeeds or
// fails because (a) failures will be automatically
// logged and (b) we may not be dealing with a request,
// so we may not be able to send a response to the
// client)
server.dao.saveQuoteState( quote );
});
// if no data was returned, then the quote doesn't exist in the
// database
if ( new_quote )
{
// initialize it
server.dao.saveQuote( quote, null, null, {
agentId: agent_id,
agentName: agent_name,
agentEntityId: session.agentEntityId(),
startDate: quote.getStartDate(),
programId: quote.getProgramId(),
initialRatedDate: 0,
importedInd: ( quote.isImported() ) ? 1 : 0,
boundInd: ( quote.isBound() ) ? 1 : 0,
importDirty: 0,
syncInd: 0,
boundInd: 0,
notifyInd: 0,
syncDate: 0,
lastPremDate: 0,
internal: ( session.isInternal() ) ? 1: 0,
pver: program.version,
explicitLock: quote.getExplicitLockReason(),
explicitLockStepId: quote.getExplicitLockStep(),
} );
}
callback.call( server );
}
});
return this;
},
'private _checkQuotePver': function( quote, program, callback )
{
// note that if program.version is not set, then something is likely
// wrong with the build that generates it: always clean in this case to
// be safe
if ( program.version
&& ( quote.getProgramVersion() === program.version )
)
{
callback( false, false );
return;
}
var _self = this;
this.logger.log( this.logger.PRIORITY_INFO,
'Quote %s program version change (%s -> %s); will be scanned.',
quote.getId(),
quote.getProgramVersion(),
program.version
);
// TODO: thread
// service any other requests first, and then proceed to cleaning
process.nextTick( function()
{
var nwait = 0,
msg = [],
handled = false;
// by default, clear is undefined; event handlers should call the
// appropriate function to state whether the quote has been properly
// upgraded; if no handlers indicate success, or if any indiciate
// failure, then disallow servicing the quote
var clear = undefined,
event = {
good: function()
{
// if undefined, then we're good, otherwise keep the
// existing value (we cannot override bad)
clear = ( clear === undefined ) ? true : clear;
},
bad: function( s )
{
// bad trumps all
clear = false;
msg.push( s );
},
wait: function()
{
nwait++;
return c;
}
};
// trigger the event and let someone (hopefully) take care of this
try
{
_self.emit( 'quotePverUpdate', quote, program, event );
}
catch ( e )
{
// ruh roh...
event.bad( e.message );
nwait = 0;
// this is an unhandled exception, as far as we're concerned;
// re-throw so that we have a stack trace, but do so after we're
// done processing
process.nextTick( function()
{
throw e;
} );
}
function c()
{
// do nothing until we're done waiting
if ( --nwait > 0 )
{
return;
}
if ( clear === true )
{
// clear for version update
quote.setProgramVersion( program.version );
}
else
{
// default message
if ( msg.length === 0 )
{
msg.push( ''+clear );
}
// see comments for clear var above
_self.logger.log( _self.logger.PRIORITY_ERROR,
'Quote %s scan failed (' + msg.join( '; ' ) + ')',
quote.getId()
);
}
if ( !handled )
{
handled = true;
callback( !clear, true );
}
}
// if nothing has requested that we wait, then continue immediately
if ( !handled && ( nwait === 0 ) )
{
handled = true;
c();
}
} );
},
/**
* Generates default bucket data for the given program
*
* @return {Object} default bucket data
*/
'private _getDefaultBucket': function( program, quote_data )
{
return this._progInit.init( program, quote_data.data );
},
/**
* Sends a new quote initialization request to the client
*
* @param {HttpServerRequest} request
* @param {Function} quote_new function to create new quote
*
* @return {Server} self
*/
sendNewQuote: function( request, quote_new )
{
var server = this,
session = request.getSession();
function donew( quote_id )
{
var quote = quote_new( quote_id );
server.sendResponse( request, quote, { valid: false } );
}
// should we override the quote id?
var rqn;
if ( ( rqn = session.getReturnQuoteNumber() ) > 0 )
{
donew( rqn );
// we don't need to wait for this to finish, since the next request
// won't be for a new quote
session.clearReturnQuoteNumber();
}
else
{
// get the next available quote id
this.dao.getNextQuoteId( donew );
}
return this;
},
sendInit: function( request, quote, program, quote_new, prev )
{
var _self = this,
args = arguments;
this._checkQuotePver( quote, program, function( err, mod )
{
if ( err )
{
// return as fatal
_self.sendError( request, "Quote sanitization failed" );
return;
}
// save the quote updates (but only if it was modified)
if ( mod )
{
_self.dao.saveQuote( quote,
function()
{
_self._processInit.apply( _self, args );
},
function()
{
_self.sendError( request,
"Quote sanitization failed to commit"
);
}
);
}
else
{
_self._processInit.apply( _self, args );
}
} );
return this;
},
/**
* Sends /init response
*
* @param UserRequest request
* @param Quote quote
* @param {Program} program
* @param {Function} quote_new function to create new quote
*
* @return Server self to allow for method chaining
*
* @todo generate quote # rather than prompting
*/
_processInit: function( request, quote, program, quote_new, prev )
{
var actions = null,
valid = true,
program_id = program.getId(),
session = request.getSession(),
internal = session.isInternal();
// if no quote id was given, simply prompt for one for now
if ( quote.getId() == 0 )
{
this.sendNewQuote( request, quote_new );
return this;
}
else if ( quote.getProgramId() !== program_id )
{
// invalid program; change the program id
actions = [ {
action: 'setProgram',
id: quote.getProgramId(),
quoteId: quote.getId(),
} ];
valid = false;
}
else if ( quote.hasError() )
{
this.sendError( request, quote.getError() );
return this;
}
// ensure that the agent id matches the quote's agent (unless internal)
else if ( ( internal === false )
&& ( request.getSession().agentId() != quote.getAgentId() )
)
{
// todo: generate a new quote #
actions = [ { action: 'quotePrompt' } ];
valid = false;
}
// don't return any quote data if invalid - we don't want people spying
// on the data!
if ( valid === false )
{
this.sendResponse( request, quote, {
valid: false,
}, actions );
return this;
}
var data = quote.getBucket().getData() || {};
// if we're not internal, filter out the internal questions from the
// data array to ensure that they can't spy on our internal data
if ( request.getSession().isInternal() === false )
{
for ( id in program.internal )
{
delete data[ id ];
}
}
var bucket = quote.getBucket(),
lock = quote.getExplicitLockReason(),
lock_step = quote.getExplicitLockStep(),
_self = this;
if ( valid && !lock && prev )
{
actions = [ {
action: 'warning',
message: (
'Somebody else is currently viewing this quote; it ' +
'has been locked and will be read-only until the ' +
'other person is finished. Please try again later.' +
( ( internal )
? '
N.B.: You are an internal user, so you ' +
'may unlock the quote above, but be warned that ' +
'concurrent writes may have negative affects on ' +
'the integrity of the quote.' +
'
' +
'Currently viewing: ' + prev
: ''
)
)
} ];
lock = 'concurrent-access';
}
// decrypt bucket contents, if necessary, and return
this._getBucketCipher( program ).decrypt( bucket, function()
{
_self.sendResponse( request, quote, {
valid: valid,
data: bucket.getData() || {},
programId: program_id,
currentStepId: quote.getCurrentStepId(),
topVisitedStepId: quote.getTopVisitedStepId(),
imported: quote.isImported(),
bound: quote.isBound(),
needsImport: quote.needsImport(),
explicitLock: lock,
explicitLockStepId: lock_step,
agentId: quote.getAgentId(),
agentName: quote.getAgentName(),
agentEntityId: ( internal ) ? quote.getAgentEntityId() : 0,
startDate: quote.getStartDate(),
initialRatedDate: quote.getInitialRatedDate(),
quicksave: quote.getQuickSaveData(),
// set to undefined if not internal so it's not included in the
// JSON response
internal: ( ( request.getSession().isInternal() === true )
? true
: undefined
),
}, actions );
} );
return this;
},
/**
* Sends a step to the client
*
* @param UserRequest request
* @param Quote quote
* @param Integer program
* @param Integer step_id id of the step
*
* @return void
*/
sendStep: function( request, quote, program, step_id, session )
{
var cur_id = quote.getCurrentStepId(),
saved_id = quote.getTopSavedStepId(),
program_id = program.id;
if ( program.steps[ step_id ] === undefined )
{
this.sendError( request,
"Invalid step request; step " + step_id + " does not exist.",
[ { action: 'gostep', id: cur_id } ]
);
return;
}
var type = program.steps[ step_id ].type;
// is this a management step? if so, we must be internal
if ( ( type === 'manage' ) && ( !session.isInternal() ) )
{
// we're not internal, so let's send them back to the first step
this.sendResponse( request, quote, {}, [
{ action: 'gostep', id: program.getFirstStepId() }
] );
}
// are they permitted to navigate to this step?
if ( step_id > ( quote.getTopVisitedStepId() + 1 ) )
{
// knock them back to the next step they're able to save
var tostep_id = ( quote.getTopSavedStepId() + 1 );
this.logger.log( this.logger.PRIORITY_ERROR,
"Quote " + quote.getId() + " has not yet reached step " +
step_id + "; forcing to step " + tostep_id
);
this.sendError( request,
"Failed to navigate to step: you have not yet reached " +
"the requested step.",
[ { action: 'gostep', id: tostep_id } ]
);
return;
}
// perform forward-validations *on the current step* to ensure that they
// cannot leave the quote and then return, requesting a future step (if
// permitted), thereby evading client-side forward-validations
if ( step_id > cur_id )
{
const validated = this._forwardValidate(
quote, program, cur_id, session
);
if ( !validated )
{
this.sendError( request,
"The previous step contains errors; please correct them " +
"before continuing.",
[ { action: 'gostep', id: cur_id } ]
);
return;
}
}
var server = this;
this._cache.get( 'step_html' )
.then( prog => prog.get( program_id ) )
.then( shtml => shtml.get( step_id ) )
.then( data =>
{
// send the step HTML to the client
server.sendResponse( request, quote, {
html: data,
} );
} )
.catch( err =>
{
server.logger.log( server.logger.PRIORITY_ERROR,
"Failed to load program '%s' step %d: %s",
program_id,
step_id,
err.message
);
server.sendError( request,
'The step you requested could not be loaded.'
);
throw err;
} );
return this;
},
/**
* Step HTML cache miss function
*
* Load step HTML from disk. This is intended to be used as a
* miss function.
*
* TODO: Extract method
*
* @param {string} program_id program containing step
* @param {number} step_id step to load
*
* @return {Promise}
*/
'public loadStepHtml': function( program_id, step_id )
{
var step_filename =
process.env.LV_ROOT_PATH + '/src/_gen/views/scripts/quote/' +
program_id + '/steps/' + step_id + '.phtml';
return new Promise( function( resolve, reject )
{
fs.readFile( step_filename, 'utf-8', function( err, data )
{
// we had a problem with the step
if ( err !== null )
{
reject( err );
return;
}
resolve( data );
});
} );
},
/**
* Perform forward-validations for a given quote and step
*
* This check is necessary to ensure that the client-side events are not
* bypassed, which is realatively simple to do. For example, one could leave
* the quote and return at a future step (so long as the operation is
* otherwise permitted), preventing the `forward' event from triggering on
* the client (as it is a relative event).
*
* @param {Quote} quote quote to forward-validate
* @param {Program} program program to validate against
* @param {number} step_id id of current step (before navigation)
* @param {UserSession} session user session
*
* @return {boolean} validation success/failure
*/
'private _forwardValidate': function( quote, program, step_id, session )
{
var success = false,
_self = this;
// TODO: we need cmatch data to pass to `forward'
return true;
quote.visitData( function( bucket )
{
try
{
// WARNING: must set immediately before running assertions,
// ensuring that stack doesn't clear
program.isInternal = session.isInternal();
// forward event returns an object containing failures
success = ( program.forward( step_id, bucket, {} ) === null );
}
catch ( e )
{
// this should never happen, but in case it does, we need to
// make sure the user isn't left hanging with no response from
// the server; return gracefully after logging the error
_self.logger_log(
_self.logger.PRIORITY_ERROR,
'Forward-validation error (%s): WEB#%s, step %s',
program.id,
quote.getId(),
step_id
);
}
} );
// N.B.: defaults to false above
return success;
},
visitStep: function( step_id, request, quote )
{
// update the quote step, if valid
if ( step_id <= ( quote.getTopVisitedStepId() + 1 ) )
{
quote.setCurrentStepId( step_id );
}
this.sendResponse( request, quote, {} );
return this;
},
sendProgramJs: function( request, program_id )
{
var server = this;
this._cache.get( 'program_js' )
.then( progjs => progjs.get( program_id ) )
.then( data =>
{
// send the JS to the client
request.setContentType( 'text/javascript' ).end( data );
} )
.catch( err =>
{
server.logger.log( server.logger.PRIORITY_ERROR,
"Failed to load program '%s' JS: %s",
program_id,
err
);
server.sendError( request,
'Unable to retrieve program data'
);
throw err;
} );
return this;
},
/**
* Program JS cache miss function
*
* Loads program JS from disk. This is intended to be used as a
* miss function.
*
* TODO: Extract method
*
* @param {string} program_id program to load
*
* @return {Promise}
*/
'public loadProgramFiles': function( program_id )
{
var root_path = process.env.LV_ROOT_PATH + '/src/_gen/scripts/program/' + program_id,
js_filename = root_path + '/Program.js',
inc_filename = root_path + '/include.js',
retjs = '';
return new Promise( function( resolve, reject )
{
// read both files
fs.readFile( js_filename, 'utf8', function( err, data )
{
if ( err !== null )
{
reject( err );
return;
}
// wrap in closure
data = "(function(require,module){" +
"var exports=module.exports={};" +
data +
"\n})(require,modules['program/" + program_id +
"/Program']={});"
retjs = data;
// read include file
fs.readFile( inc_filename, 'utf8', function( err, data )
{
if ( err === null )
{
retjs += data;
}
// we have all of our data; return the result
resolve( retjs );
} );
} );
} );
},
/**
* Handles a quote data post
*
* This function is called when an HTTP POST is made to save quote data.
*
* @param Integer step_id id of the step
* @param UserRequest request request object
* @param Quote quote instance of quote to operate on
* @param Program program program associated with the quote
*
* @param {UserSession} session user session
*
* @return undefined
*/
handlePost: function( step_id, request, quote, program, session )
{
var server = this;
// do not allow quote modification if locked unless logged in as an
// internal user (FS#5772) and the program is unlockable
if ( (
quote.isLocked()
|| ( step_id < quote.getExplicitLockStep() )
)
&& !( session.isInternal() && program.unlockable )
)
{
server.logger.log( server.logger.PRIORITY_INFO,
"Cannot save imported quote: %s",
quote.getId()
);
server.sendError( request,
"This quote has been locked and can no longer be modified."
);
return this;
}
// are they getting too far ahead of themselves?
if ( step_id > ( quote.getTopSavedStepId() + 1 ) )
{
// knock back to next step that they're able to save
var tostep_id = ( quote.getTopSavedStepId() + 1 );
this.logger.log( this.logger.PRIORITY_ERROR,
"Quote " + quote.getId() + " cannot yet save step " +
step_id + "; forcing to step " + tostep_id
);
this.sendError( request,
"Unable to save step: you have not yet reached " +
"the requested step.",
[ { action: 'gostep', id: tostep_id, title: 'Go Back' } ]
);
// prohibit save
return this;
}
request.getPostData( function( post_data )
{
// fill the quote with the posted data
if ( post_data.data )
{
try
{
var parsed_data = JSON.parse( post_data.data );
var bucket = quote.getBucket();
const { filtered, dapis, meta_clear } =
server._dataProcessor.processDiff(
parsed_data, request, program, bucket
);
server._monitorMetadataPromise( quote, dapis, meta_clear );
}
catch ( err )
{
server.logger.log( server.logger.PRIORITY_ERROR,
"Invalid POST data string (%s): %s",
err,
post_data.data
);
server.sendError( request,
'There was an error saving your data. Please ' +
'try again.',
[ { action: 'gostep', id: step_id, title: 'Go Back' } ]
);
return this;
}
}
// save the quote
server._doQuoteSave( step_id, request, quote, program );
});
return this;
},
'private _monitorMetadataPromise'( quote, dapis, meta_clear )
{
quote.getMetabucket().setValues( meta_clear );
dapis.map( promise => promise
.then( ( { field, index, data } ) =>
this.dao.saveQuoteMeta(
quote,
data,
null,
e => { throw e; }
)
)
.catch( e =>
this.logger.log(
this.logger.PRIORITY_ERROR,
"Failed to save metadata (quote id %d): %s",
quote.getId(),
e.message
)
)
);
},
'private _doQuoteSave': function( step_id, request, quote, program, c )
{
var server = this;
// whenever they save, we want to make sure we invalidate the premium,
// unless this is a rating step
if ( ( program.rateSteps || [] )[ step_id ] !== true )
{
quote.setLastPremiumDate( 0 );
}
server.quoteFill( quote, step_id, request.getSession(),
// success
function()
{
// encrypt bucket
var bucket = quote.getBucket();
server._getBucketCipher( program ).encrypt( bucket, function()
{
// as a precaution to prevent navigation burps, update the
// step if it's greater than the previous
if ( step_id > quote.getTopVisitedStepId() )
{
quote.setCurrentStepId( step_id );
}
if ( step_id > quote.getTopSavedStepId() )
{
// only updated by saveQuoteState
quote.setTopSavedStepId( step_id );
server.dao.saveQuoteState( quote );
}
server.dao.saveQuote( quote,
// quote was saved successfully
function()
{
server._postSubmit(
request, quote, step_id, program,
request.getSession().isInternal()
);
c && c( true );
},
// failed to save the quote
function()
{
// todo: option to allow them to try again
server.sendError( request,
'There was a problem saving your quote. ' +
'The previous step was not saved!'
);
c && c( false );
}
);
} );
},
// failure
function( failures )
{
// todo: detailed logging (this shouldn't happen)
server.logger.log( server.logger.PRIORITY_ERROR,
"Server-side quote data validation failure for " +
"quote #%s, program %s, step %d:\n%s",
quote.getId(),
program.id,
step_id,
util.inspect( server._formatValidationFailures( failures ) )
);
server.sendError( request,
'There was a problem with the data you entered. ' +
'Please click "Go Back" below to go back to the ' +
'previous step and correct the errors.',
[
{ action: 'gostep', id: step_id },
{ action: 'invalidate', errors: failures },
],
'Go Back'
);
c && c( false );
}
);
},
/**
* Format validation failures for encoded display
*
* That is, output data in a format that is useful for JSON-encoded display.
*
* @param {Object} failures failure array per key
*
* @return {Object} formatted object
*/
'private _formatValidationFailures'( failures )
{
return Object.keys( failures ).reduce( ( results, id ) =>
{
results[ id ] = failures[ id ].map( failure => failure.toString() );
return results;
}, {} );
},
'private _postSubmit': function( request, quote, step_id, program, internal )
{
var server = this,
actions = [],
bucket = null;
// XXX
quote.visitData( function( b )
{
bucket = b;
} );
var result = program.postSubmit(
step_id,
bucket,
function( event, question_id, value )
{
switch ( event )
{
// kick back to the given step, if they're already past it
case 'kickBack':
var step_id = +value;
// clear any fields scheduled to be cleared on kickback
var retdata = server._kbclear( program, quote );
if ( quote.getTopVisitedStepId() > step_id )
{
quote.setTopVisitedStepId( step_id );
// knock them back to the step if they're currently
// further
if ( quote.getCurrentStepId() > step_id )
{
quote.setCurrentStepId( step_id );
actions.push( {
action: 'gostep',
id: step_id,
} );
}
}
server.dao.mergeBucket( quote, retdata, function()
{
server.dao.saveQuoteState( quote, function()
{
// if we're not internal, strip any potential
// internal data from the response
// XXX: maybe we should do this in
// sendResponse() to ensure consistency
if ( internal === false )
{
for ( id in program.internal )
{
delete retdata[ id ];
}
}
// don't send the response until the state
// is saved; we don't want a race condition
// if they're speeding through steps!
finish( retdata );
} );
} );
break;
default:
server.logger.log( server.logger.PRIORITY_ERROR,
"Unknown postSubmit event: %s",
event
);
finish();
return;
}
function finish( data )
{
data = data || {};
server.sendResponse( request, quote, data, actions );
}
}
);
// if there's no events, then just respond with a generic OK
if ( result === false )
{
server.sendResponse( request, quote );
}
},
'private _kbclear': function( program, quote )
{
var set = {};
for ( var field in program.kbclear )
{
var data = quote.getDataByName( field ),
val = ( program.defaults[ field ] || '' );
for ( var i in data )
{
data[ i ] = val;
}
set[ field ] = data;
}
quote.setData( set );
// return the fields that have changed
return set;
},
quoteFill: function( data, step_id, session, success, failure )
{
if ( data instanceof Function )
{
this.quoteFillHooks.push( data );
return this;
}
var abort = false,
failures = {};
var event = {
abort: function( failure_data )
{
failures = failure_data;
abort = true;
},
};
var len = this.quoteFillHooks.length;
for ( var i = 0; i < len; i++ )
{
this.quoteFillHooks[i].call( event, data, step_id, session );
// if we aborted, there's no need to continue
if ( abort )
{
break;
}
}
// only call the callback if we did not abort
if ( abort )
{
failure.call( this, failures );
}
else
{
success.call( this );
}
return this;
},
/**
* Lazily loads and returns the requested Program object
*
* @param String program_id id of the program to retrieve
*
* @return Server self to allow for method chaining
*/
'public getProgram': function( program_id )
{
var _self = this;
return this._cache.get( 'program' )
.then( pcache => pcache.get( program_id ) )
.catch( function( e )
{
// looks like it doesn't exist
_self.logger.log( _self.logger.PRIORITY_ERROR,
"Program class '%s' could not be loaded: %s",
program_id,
e.message
);
throw e;
} );
},
/**
* Program object cache miss function
*
* Instantiates program. This is intended to be used as a miss
* function.
*
* TODO: Extract method
*
* @param {string} program_id program to instantiate
*
* @return {Promise}
*/
'public loadProgram': function( program_id )
{
var server = this;
return new Promise( function( resolve, reject )
{
try
{
const program_path = 'program/' + program_id + '/Program';
// node caches modules; make sure it's cleared
delete require.cache[
require.resolve( program_path )
];
// attempt to load the program class
const program_module = require( program_path );
const program = program_module();
// hook ourselves
server.quoteFill( function( quote, step_id, session )
{
var _self = this;
// only perform quote validation if the quote is
// using this program
if ( quote.getProgramId() !== program_id )
{
return;
}
// todo: unnecessary dependency
var bucket_quote = quote.getBucket().getData(),
bucket_tmp = QuoteDataBucket(),
data_tmp = {};
// this actually takes only 1ms, even with a reasonably
// sized bucket (tested with snowmobile) - both the copy and
// setValues()
for ( item in bucket_quote )
{
if ( !Array.isArray( bucket_quote[ item ] ) )
{
// this is a problem (FS#5849)
bucket_quote[ item ] = [];
server.logger.log( server.logger.PRIORITY_ERROR,
"Bucket item '%s' not an array for " +
"quote id %s in program %s; set to empty",
item, quote.getId(), program_id
);
}
data_tmp[ item ] = bucket_quote[ item ].slice( 0 );
}
bucket_tmp.setValues( data_tmp );
// Run all initialization stuff (e.g. calculated
// values) on the bucket to prepare for
// assertions. It's important to note that we
// duplicate the bucket to ensure that none of the
// calculated values are saved (the ones we want
// to save are already in there).
program.initQuote( bucket_tmp );
var classdata = program.classify( bucket_tmp.getData() );
// XXX
FieldClassMatcher( program.whens )
.match( classdata, function( cmatch )
{
// WARNING: must set immediately before running
// assertions, ensuring that stack doesn't clear
program.isInternal = session.isInternal();
var failures = program.submit( step_id,
bucket_tmp,
cmatch
);
// if there's any failures, abort the operation
if ( failures !== null )
{
server.logger.log( server.logger.PRIORITY_ERROR,
"Server-side validation failure"
);
_self.abort( failures );
}
} );
} );
resolve( program );
}
catch ( e )
{
reject( e );
}
} );
},
'private _getBucketCipher': function( program )
{
var _self = this;
return this._bucketCiphers[ program.id ] || ( function()
{
// create a new bucket cipher
var c = _self._bucketCiphers[ program.id ] = QuoteDataBucketCipher(
_self._encService,
program.secureFields.slice(0) || []
);
c.on( 'encrecover', function( field, length )
{
_self.logger.log( _self.logger.PRIORITY_ERROR,
"Invalid encrypted field data (%s of length %d); cleared.",
field,
length
);
} );
return c;
} )();
},
/**
* Handle quick save request
*
* @param {UserRequest} request user request
* @param {Quote} quote quote to save
* @param {Program} program quote program
*
* @return {Server} self
*/
'public handleQuickSave': function( request, quote, program )
{
var _self = this;
// do not allow quote modification if locked unless logged in as an
// internal user (FS#5772) and the program is unlockable
if ( quote.isImported() )
{
//return this;
}
request.getPostData( function( post_data )
{
// sanitize, permitting nulls (since the diff will have them)
try
{
var data = JSON.parse( post_data.data );
var filtered = _self._dataProcessor.sanitizeDiff(
data, request, program, true
);
}
catch ( e )
{
_self.logger.log( server.logger.PRIORITY_ERROR,
"Invalid quicksave data string (%s): %s",
e.message,
post_data.data
);
return;
}
var secure = program.secureFields,
i = secure.length;
// strip out secure fields (we can encrypt them later; this is just
// a quick solution to prevent sensitive data in plain text)
while ( i-- )
{
delete filtered[ secure[ i ] ];
}
// attempt to save the diff
_self.dao.quickSaveQuote( quote, filtered, function( err )
{
if ( !err )
{
return;
}
_self.logger.log( server.logger.PRIORITY_DB,
"[Quick Save] Quick Save Failed: " + err
);
} );
} );
// just send the response immediately, as they do not need feedback if
// the quick-save fails (it is for debugging/backup in the event of a
// problem, so we need only concern ourselves if there is an issue)
this.sendEmptyReply( request, quote );
return this;
},
'public createRevision': function( request, quote )
{
var _self = this;
this.dao.createRevision( quote, function( err )
{
if ( err )
{
_self.logger.log( _self.logger.PRIORITY_DB,
"[mkrev] failed to create revision: " + err
);
_self.sendError( request, 'Failed to create revision.' );
return;
}
_self.logger.log( _self.logger.PRIORITY_INFO,
"[mkrev] created new revision for quote %s",
quote.getId()
);
_self.sendEmptyReply( request, quote );
} );
},
// TODO: currently only diffs against current revision (that is, the live
// bucket)
'public diffRevisionGroup': function( request, program, quote, gid, revid )
{
var _self = this,
progid = quote.getProgramId();
// this really should not happen...unless we delete a program, I suppose
if ( program === undefined )
{
this.sendError( request,
"Quote program id '" + progid + "' unknown"
);
return;
}
// get all fields linked to this group---not just the exclusive fields
var gfields = program.groupFields[ gid ];
if ( gfields === undefined )
{
this.sendError( request,
"Unknown group '" + gid + "' for program '" + progid + "'"
);
return;
}
// do we have leaders?
var lead_data = request.getGetData().leaders;
if ( !lead_data )
{
this.sendError( request,
"No leaders provided for group '" + gid + "'; available " +
"fields are: " + gfields.join( ', ' )
);
return;
}
var leaders = lead_data.split( ',' );
this.dao.getRevision( quote, revid, function( revdata )
{
if ( !revdata )
{
_self.sendError( request,
"Revision " + revid + " not found for quote " +
quote.getId()
);
return;
}
var revbucket = QuoteDataBucket().setValues( revdata.data );
// XXX: tightly coupled; temporary impl
try
{
var desc = BucketSiblingDescriptor()
.defineGroup( gid, gfields )
.markGroupLeaders( gid, leaders );
var diff = StdBucketDiff(
ShallowArrayDiff(),
function( context, changes )
{
return GroupedBucketDiffResult(
StdBucketDiffResult( context, changes ),
context
);
}
)
.diff(
GroupedBucketDiffContext(
StdBucketDiffContext(
quote.getBucket(),
revbucket
),
desc,
gid
)
);
}
catch ( e )
{
_self.sendError( request,
"An error occurred during processing: " +
e.message
);
throw e;
}
_self.sendResponse( request, quote, {
map: diff.createIndexMap(),
diff: diff.describeChangedValues(),
} );
} );
},
'public sendEmptyReply': function( request, quote )
{
this.sendResponse( request, quote, {} );
}
} );