This is a preview of the Reports functionality.
It allows defining report templates and then create reports based on the templates. A template defines: - parameters - to be set in the report (currently only selection of campaigns, in the future to be extended to selection of lists/segments, and selection from pre-defined options) - data retrieval / processing code (in Javascript) - rendering template (in Handlebars) This main functionality is accompanied by a few minor tweaks here and there. Worth notice is the ability to use server-side ajax table s for multi-selection of campaigns. This is meant for reports that compare data across multiple campaigns. This could possibly be even used for some poor man's A/B testing. Note that the execution of custom JavaScript in the data retrieval / processing code and definition of custom Handlebars templates is a security issue. This should however be OK in the general case once proper user management with granular permissions is in. This is because definition of a report template is anyway such an expert task that it would normally be performed only by admin. Instantiation of reports based on report templates can be then done by any user because this should no longer be any security problem.
This commit is contained in:
parent
2afeb74e68
commit
6ba04d7ff4
31 changed files with 1737 additions and 13 deletions
29
app.js
29
app.js
|
@ -40,6 +40,8 @@ let blacklist = require('./routes/blacklist');
|
||||||
let editorapi = require('./routes/editorapi');
|
let editorapi = require('./routes/editorapi');
|
||||||
let grapejs = require('./routes/grapejs');
|
let grapejs = require('./routes/grapejs');
|
||||||
let mosaico = require('./routes/mosaico');
|
let mosaico = require('./routes/mosaico');
|
||||||
|
let reports = require('./routes/reports');
|
||||||
|
let reportsTemplates = require('./routes/report-templates');
|
||||||
|
|
||||||
let app = express();
|
let app = express();
|
||||||
|
|
||||||
|
@ -57,6 +59,8 @@ app.disable('x-powered-by');
|
||||||
|
|
||||||
hbs.registerPartials(__dirname + '/views/partials');
|
hbs.registerPartials(__dirname + '/views/partials');
|
||||||
hbs.registerPartials(__dirname + '/views/subscription/partials/');
|
hbs.registerPartials(__dirname + '/views/subscription/partials/');
|
||||||
|
hbs.registerPartials(__dirname + '/views/report-templates/partials/');
|
||||||
|
hbs.registerPartials(__dirname + '/views/reports/partials/');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We need this helper to make sure that we consume flash messages only
|
* We need this helper to make sure that we consume flash messages only
|
||||||
|
@ -119,6 +123,29 @@ hbs.registerHelper('translate', function (context, options) { // eslint-disable-
|
||||||
return new hbs.handlebars.SafeString(result);
|
return new hbs.handlebars.SafeString(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Credits to http://chrismontrois.net/2016/01/30/handlebars-switch/
|
||||||
|
|
||||||
|
{{#switch letter}}
|
||||||
|
{{#case "a"}}
|
||||||
|
A is for alpaca
|
||||||
|
{{/case}}
|
||||||
|
{{#case "b"}}
|
||||||
|
B is for bluebird
|
||||||
|
{{/case}}
|
||||||
|
{{/switch}}
|
||||||
|
*/
|
||||||
|
hbs.registerHelper("switch", function(value, options) {
|
||||||
|
this._switch_value_ = value;
|
||||||
|
var html = options.fn(this); // Process the body of the switch block
|
||||||
|
delete this._switch_value_;
|
||||||
|
return html;
|
||||||
|
});
|
||||||
|
hbs.registerHelper("case", function(value, options) {
|
||||||
|
if (value == this._switch_value_) {
|
||||||
|
return options.fn(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
|
app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
|
||||||
|
|
||||||
|
@ -221,6 +248,8 @@ app.use('/api', api);
|
||||||
app.use('/editorapi', editorapi);
|
app.use('/editorapi', editorapi);
|
||||||
app.use('/grapejs', grapejs);
|
app.use('/grapejs', grapejs);
|
||||||
app.use('/mosaico', mosaico);
|
app.use('/mosaico', mosaico);
|
||||||
|
app.use('/reports', reports);
|
||||||
|
app.use('/report-templates', reportsTemplates);
|
||||||
|
|
||||||
// catch 404 and forward to error handler
|
// catch 404 and forward to error handler
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|
|
@ -41,6 +41,10 @@ module.exports.filter = (request, parent, callback) => {
|
||||||
tableHelpers.filter('campaigns', ['*'], request, ['#', 'name', 'description', 'status', 'created'], ['name'], 'created DESC', queryData, callback);
|
tableHelpers.filter('campaigns', ['*'], request, ['#', 'name', 'description', 'status', 'created'], ['name'], 'created DESC', queryData, callback);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports.filterQuicklist = (request, callback) => {
|
||||||
|
tableHelpers.filter('campaigns', ['id', 'name', 'description', 'created'], request, ['#', 'name', 'description', 'created'], ['name'], 'name ASC', null, callback);
|
||||||
|
};
|
||||||
|
|
||||||
module.exports.filterClickedSubscribers = (campaign, linkId, request, columns, callback) => {
|
module.exports.filterClickedSubscribers = (campaign, linkId, request, columns, callback) => {
|
||||||
let queryData = {
|
let queryData = {
|
||||||
where: 'campaign_tracker__' + campaign.id + '.list=? AND campaign_tracker__' + campaign.id + '.link=?',
|
where: 'campaign_tracker__' + campaign.id + '.list=? AND campaign_tracker__' + campaign.id + '.link=?',
|
||||||
|
|
161
lib/models/report-templates.js
Normal file
161
lib/models/report-templates.js
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../db');
|
||||||
|
const tableHelpers = require('../table-helpers');
|
||||||
|
const tools = require('../tools');
|
||||||
|
const _ = require('../translate')._;
|
||||||
|
|
||||||
|
const allowedKeys = ['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs'];
|
||||||
|
|
||||||
|
module.exports.list = (start, limit, callback) => {
|
||||||
|
tableHelpers.list('report_templates', ['*'], 'name', start, limit, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.quicklist = callback => {
|
||||||
|
tableHelpers.quicklist('report_templates', ['id', 'name'], 'name', callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.filter = (request, callback) => {
|
||||||
|
tableHelpers.filter('report_templates', ['*'], request, ['#', 'name', 'description', 'created'], ['name'], 'created DESC', null, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.get = (id, callback) => {
|
||||||
|
id = Number(id) || 0;
|
||||||
|
|
||||||
|
if (id < 1) {
|
||||||
|
return callback(new Error(_('Missing report template ID')));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.getConnection((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.query('SELECT * FROM report_templates WHERE id=?', [id], (err, rows) => {
|
||||||
|
connection.release();
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
return callback(null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = tools.convertKeys(rows[0]);
|
||||||
|
|
||||||
|
const userFields = template.userFields.trim();
|
||||||
|
if (userFields != '') {
|
||||||
|
try {
|
||||||
|
template.userFieldsObject = JSON.parse(userFields);
|
||||||
|
} catch (err) {
|
||||||
|
// This is to handle situation when for some reason we get corrupted JSON in the DB.
|
||||||
|
template.userFieldsObject = {};
|
||||||
|
template.userFields = "{}";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
template.userFieldsObject = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback(null, template);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.createOrUpdate = (createMode, data, callback) => {
|
||||||
|
data = data || {};
|
||||||
|
|
||||||
|
const id = 'id' in data ? Number(data.id) : 0;
|
||||||
|
|
||||||
|
if (!createMode && id < 1) {
|
||||||
|
return callback(new Error(_('Missing report template ID')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = tools.convertKeys(data);
|
||||||
|
const name = (template.name || '').toString().trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return callback(new Error(_('Report template name must be set')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = ['name'];
|
||||||
|
const values = [name];
|
||||||
|
|
||||||
|
Object.keys(template).forEach(key => {
|
||||||
|
let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim();
|
||||||
|
key = tools.toDbKey(key);
|
||||||
|
|
||||||
|
if (key === 'description') {
|
||||||
|
value = tools.purifyHTML(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'user_fields') {
|
||||||
|
value = value.trim();
|
||||||
|
|
||||||
|
if (value != '') {
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
} catch (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) {
|
||||||
|
keys.push(key);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.getConnection((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query;
|
||||||
|
|
||||||
|
if (createMode) {
|
||||||
|
query = 'INSERT INTO report_templates (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
|
||||||
|
} else {
|
||||||
|
query = 'UPDATE report_templates SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1';
|
||||||
|
values.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.query(query, values, (err, result) => {
|
||||||
|
connection.release();
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createMode) {
|
||||||
|
return callback(null, result && result.insertId || false);
|
||||||
|
} else {
|
||||||
|
return callback(null, result && result.affectedRows || false)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.delete = (id, callback) => {
|
||||||
|
id = Number(id) || 0;
|
||||||
|
|
||||||
|
if (id < 1) {
|
||||||
|
return callback(new Error(_('Missing report template ID')));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.getConnection((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.query('DELETE FROM report_templates WHERE id=? LIMIT 1', [id], (err, result) => {
|
||||||
|
connection.release();
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const affected = result && result.affectedRows || 0;
|
||||||
|
return callback(err, affected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
222
lib/models/reports.js
Normal file
222
lib/models/reports.js
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../db');
|
||||||
|
const tableHelpers = require('../table-helpers');
|
||||||
|
const fields = require('./fields');
|
||||||
|
const reportTemplates = require('./report-templates');
|
||||||
|
const tools = require('../tools');
|
||||||
|
const _ = require('../translate')._;
|
||||||
|
const log = require('npmlog');
|
||||||
|
|
||||||
|
const allowedKeys = ['name', 'description', 'report_template', 'params'];
|
||||||
|
|
||||||
|
module.exports.list = (start, limit, callback) => {
|
||||||
|
tableHelpers.list('reports', ['*'], 'name', start, limit, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.filter = (request, callback) => {
|
||||||
|
tableHelpers.filter('reports JOIN report_templates ON reports.report_template = report_templates.id',
|
||||||
|
['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name' ],
|
||||||
|
request, ['#', 'name', 'report_templates.name', 'description', 'created'], ['name'], 'created DESC', null, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.get = (id, callback) => {
|
||||||
|
id = Number(id) || 0;
|
||||||
|
|
||||||
|
if (id < 1) {
|
||||||
|
return callback(new Error(_('Missing report ID')));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.getConnection((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.query('SELECT * FROM reports WHERE id=?', [id], (err, rows) => {
|
||||||
|
connection.release();
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
return callback(null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = tools.convertKeys(rows[0]);
|
||||||
|
|
||||||
|
const params = template.params.trim();
|
||||||
|
if (params != '') {
|
||||||
|
try {
|
||||||
|
template.paramsObject = JSON.parse(params);
|
||||||
|
} catch (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
template.params = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback(null, template);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.createOrUpdate = (createMode, data, callback) => {
|
||||||
|
data = data || {};
|
||||||
|
|
||||||
|
const id = 'id' in data ? Number(data.id) : 0;
|
||||||
|
|
||||||
|
if (!createMode && id < 1) {
|
||||||
|
return callback(new Error(_('Missing report ID')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = tools.convertKeys(data);
|
||||||
|
const name = (template.name || '').toString().trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return callback(new Error(_('Report name must be set')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportTemplateId = Number(template.reportTemplate);
|
||||||
|
reportTemplates.get(reportTemplateId, (err, reportTemplate) => {
|
||||||
|
if (err) {
|
||||||
|
callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = data.paramsObject;
|
||||||
|
for (const spec of reportTemplate.userFieldsObject) {
|
||||||
|
if (params[spec.id].length < spec.minOccurences) {
|
||||||
|
return callback(new Error(_('At least ' + spec.minOccurences + ' rows in "' + spec.name + '" have to be selected.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params[spec.id].length > spec.maxOccurences) {
|
||||||
|
return callback(new Error(_('At most ' + spec.minOccurences + ' rows in "' + spec.name + '" can be selected.')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = ['name', 'params'];
|
||||||
|
const values = [name, JSON.stringify(params)];
|
||||||
|
|
||||||
|
|
||||||
|
Object.keys(template).forEach(key => {
|
||||||
|
let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim();
|
||||||
|
key = tools.toDbKey(key);
|
||||||
|
|
||||||
|
if (key === 'description') {
|
||||||
|
value = tools.purifyHTML(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) {
|
||||||
|
keys.push(key);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.getConnection((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query;
|
||||||
|
|
||||||
|
if (createMode) {
|
||||||
|
query = 'INSERT INTO reports (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
|
||||||
|
} else {
|
||||||
|
query = 'UPDATE reports SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1';
|
||||||
|
values.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.query(query, values, (err, result) => {
|
||||||
|
connection.release();
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createMode) {
|
||||||
|
return callback(null, result && result.insertId || false);
|
||||||
|
} else {
|
||||||
|
return callback(null, result && result.affectedRows || false)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.delete = (id, callback) => {
|
||||||
|
id = Number(id) || 0;
|
||||||
|
|
||||||
|
if (id < 1) {
|
||||||
|
return callback(new Error(_('Missing report ID')));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.getConnection((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.query('DELETE FROM reports WHERE id=? LIMIT 1', [id], (err, result) => {
|
||||||
|
connection.release();
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const affected = result && result.affectedRows || 0;
|
||||||
|
return callback(err, affected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const campaignFieldsMapping = {
|
||||||
|
'tracker_count': 'tracker.count',
|
||||||
|
'country': 'tracker.country',
|
||||||
|
'device_type': 'tracker.device_type',
|
||||||
|
'status': 'campaign.status',
|
||||||
|
'first_name': 'subscribers.first_name',
|
||||||
|
'last_name': 'subscribers.last_name',
|
||||||
|
'email': 'subscribers.email'
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.getCampaignResults = (campaign, select, clause, callback) => {
|
||||||
|
db.getConnection((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.list(campaign.list, (err, fieldList) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsMapping = fieldList.reduce((map, field) => {
|
||||||
|
map[customFieldName(field.key)] = 'subscribers.' + field.column;
|
||||||
|
return map;
|
||||||
|
}, Object.assign({}, campaignFieldsMapping));
|
||||||
|
|
||||||
|
let selFields = [];
|
||||||
|
for (let idx = 0; idx < select.length; idx++) {
|
||||||
|
const item = select[idx];
|
||||||
|
if (item in fieldsMapping) {
|
||||||
|
selFields.push(fieldsMapping[item] + ' AS ' + item);
|
||||||
|
} else if (item == '*') {
|
||||||
|
selFields = selFields.concat(Object.keys(fieldsMapping).map(item => fieldsMapping[item] + ' AS ' + item));
|
||||||
|
} else {
|
||||||
|
selFields.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = 'SELECT ' + selFields.join(', ') + ' FROM `subscription__' + campaign.list + '` subscribers INNER JOIN `campaign__' + campaign.id + '` campaign on subscribers.id=campaign.subscription LEFT JOIN `campaign_tracker__' + campaign.id + '` tracker on subscribers.id=tracker.subscriber ' + clause;
|
||||||
|
|
||||||
|
connection.query(query, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
connection.release();
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback(null, results);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function customFieldName(id) {
|
||||||
|
return id.replace(/MERGE_/, 'CUSTOM_').toLowerCase();
|
||||||
|
}
|
|
@ -95,8 +95,6 @@ module.exports.filter = (source, fields, request, columns, searchFields, default
|
||||||
let query = 'SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
let query = 'SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||||
let args = searchArgs.concat(queryData ? queryData.values : []).concat([Number(request.length) || 50, Number(request.start) || 0]);
|
let args = searchArgs.concat(queryData ? queryData.values : []).concat([Number(request.length) || 50, Number(request.start) || 0]);
|
||||||
|
|
||||||
log.info("tableHelpers", query);
|
|
||||||
|
|
||||||
connection.query(query, args, (err, rows) => {
|
connection.query(query, args, (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
connection.release();
|
connection.release();
|
||||||
|
|
|
@ -129,6 +129,10 @@ function updateMenu(res) {
|
||||||
title: _('Automation'),
|
title: _('Automation'),
|
||||||
url: '/triggers',
|
url: '/triggers',
|
||||||
key: 'triggers'
|
key: 'triggers'
|
||||||
|
}, {
|
||||||
|
title: _('Reports'),
|
||||||
|
url: '/reports',
|
||||||
|
key: 'reports'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"schemaVersion": 26
|
"schemaVersion": 27
|
||||||
}
|
}
|
||||||
|
|
1
public/ace/mode-handlebars.js
Normal file
1
public/ace/mode-handlebars.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/mode-javascript.js
Normal file
1
public/ace/mode-javascript.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/mode-json.js
Normal file
1
public/ace/mode-json.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/worker-javascript.js
Normal file
1
public/ace/worker-javascript.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/worker-json.js
Normal file
1
public/ace/worker-json.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -31,3 +31,11 @@ h2 .glyphicon {
|
||||||
h3 .glyphicon {
|
h3 .glyphicon {
|
||||||
font-size: .8em;
|
font-size: .8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tbody>tr.selected {
|
||||||
|
background-color: rgb(218, 231, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover>tbody>tr.selected:hover {
|
||||||
|
background-color: rgb(205, 212, 226);
|
||||||
|
}
|
|
@ -39,6 +39,12 @@ $('div[class*="code-editor-"]').each(function () {
|
||||||
editor.getSession().setUseWorker(false);
|
editor.getSession().setUseWorker(false);
|
||||||
} else if ($(this).hasClass('code-editor-css')) {
|
} else if ($(this).hasClass('code-editor-css')) {
|
||||||
mode = 'css';
|
mode = 'css';
|
||||||
|
} else if ($(this).hasClass('code-editor-javascript')) {
|
||||||
|
mode = 'javascript';
|
||||||
|
} else if ($(this).hasClass('code-editor-json')) {
|
||||||
|
mode = 'json';
|
||||||
|
} else if ($(this).hasClass('code-editor-handlebars')) {
|
||||||
|
mode = 'handlebars';
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.setTheme('ace/theme/chrome');
|
editor.setTheme('ace/theme/chrome');
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
var opts = {
|
||||||
scrollX: true,
|
scrollX: true,
|
||||||
order: [
|
order: [
|
||||||
[sortColumn, sortOrder]
|
[sortColumn, sortOrder]
|
||||||
|
@ -41,6 +41,55 @@
|
||||||
info: paging, /* This controls the "Showing 1 to 16 of 16 entries" */
|
info: paging, /* This controls the "Showing 1 to 16 of 16 entries" */
|
||||||
pageLength: 50
|
pageLength: 50
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ($(elem).hasClass('data-table-selectable') || $(elem).hasClass('data-table-multiselectable')) {
|
||||||
|
var isMulti = $(elem).hasClass('data-table-multiselectable');
|
||||||
|
|
||||||
|
var dataElem = $(elem).siblings("input").first();
|
||||||
|
|
||||||
|
opts.rowCallback = function( row, data ) {
|
||||||
|
var selected = dataElem.val() == '' ? [] : dataElem.val().split(',').map(function(item) { return Number(item); });
|
||||||
|
|
||||||
|
if (!isMulti && selected.length > 0) {
|
||||||
|
selected = [selected[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($.inArray(data.DT_RowId, selected) !== -1) {
|
||||||
|
$(row).addClass('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$(elem).on('click', 'tbody tr', function () {
|
||||||
|
var id = this.id;
|
||||||
|
var selected = dataElem.val() == '' ? [] : dataElem.val().split(',');
|
||||||
|
|
||||||
|
var index = $.inArray(id, selected);
|
||||||
|
|
||||||
|
if (isMulti) {
|
||||||
|
if ( index === -1 ) {
|
||||||
|
selected.push(id);
|
||||||
|
} else {
|
||||||
|
selected.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(this).toggleClass('selected');
|
||||||
|
} else {
|
||||||
|
for (var selIdx=0; selIdx < selected.length; selIdx++) {
|
||||||
|
if (selected[selIdx] != id) {
|
||||||
|
$('#' + selected[selIdx], elem).removeClass('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#' + id, elem).addClass('selected');
|
||||||
|
|
||||||
|
selected = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
dataElem.val(selected.join(','));
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('.data-table').each(function () {
|
$('.data-table').each(function () {
|
||||||
|
@ -69,6 +118,7 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$('.data-stats-pie-chart').each(function () {
|
$('.data-stats-pie-chart').each(function () {
|
||||||
|
|
|
@ -722,8 +722,8 @@ router.post('/clicked/ajax/:id/:linkId', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/selection/ajax', (req, res) => {
|
router.post('/quicklist/ajax', (req, res) => {
|
||||||
campaigns.filter(req.body, Number(req.query.parent) || false, (err, data, total, filteredTotal) => {
|
campaigns.filterQuicklist(req.body, (err, data, total, filteredTotal) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.json({
|
return res.json({
|
||||||
error: err.message || err,
|
error: err.message || err,
|
||||||
|
@ -735,13 +735,13 @@ router.post('/selection/ajax', (req, res) => {
|
||||||
draw: req.body.draw,
|
draw: req.body.draw,
|
||||||
recordsTotal: total,
|
recordsTotal: total,
|
||||||
recordsFiltered: filteredTotal,
|
recordsFiltered: filteredTotal,
|
||||||
data: data.map((row, i) => [
|
data: data.map((row, i) => ({
|
||||||
'',
|
"0": (Number(req.body.start) || 0) + 1 + i,
|
||||||
(Number(req.body.start) || 0) + 1 + i,
|
"1": '<span class="glyphicon glyphicon-inbox" aria-hidden="true"></span> <a href="/campaigns/view/' + row.id + '">' + htmlescape(row.name || '') + '</a>',
|
||||||
'<span class="glyphicon glyphicon-inbox" aria-hidden="true"></span> <a href="/campaigns/view/' + row.id + '">' + htmlescape(row.name || '') + '</a>',
|
"2": htmlescape(striptags(row.description) || ''),
|
||||||
htmlescape(striptags(row.description) || ''),
|
"3": '<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>',
|
||||||
'<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>']
|
"DT_RowId": row.id
|
||||||
)
|
}))
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
282
routes/report-templates.js
Normal file
282
routes/report-templates.js
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const passport = require('../lib/passport');
|
||||||
|
const router = new express.Router();
|
||||||
|
const _ = require('../lib/translate')._;
|
||||||
|
const reportTemplates = require('../lib/models/report-templates');
|
||||||
|
const tools = require('../lib/tools');
|
||||||
|
const util = require('util');
|
||||||
|
const htmlescape = require('escape-html');
|
||||||
|
const striptags = require('striptags');
|
||||||
|
|
||||||
|
const allowedMimeTypes = {
|
||||||
|
'text/html': 'HTML',
|
||||||
|
'text/csv': 'CSV'
|
||||||
|
};
|
||||||
|
|
||||||
|
router.all('/*', (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||||
|
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||||
|
}
|
||||||
|
res.setSelectedMenu('reports');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.render('report-templates/report-templates', {
|
||||||
|
title: _('Report Templates')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/ajax', (req, res) => {
|
||||||
|
reportTemplates.filter(req.body, (err, data, total, filteredTotal) => {
|
||||||
|
if (err) {
|
||||||
|
return res.json({
|
||||||
|
error: err.message || err,
|
||||||
|
data: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
draw: req.body.draw,
|
||||||
|
recordsTotal: total,
|
||||||
|
recordsFiltered: filteredTotal,
|
||||||
|
data: data.map((row, i) => [
|
||||||
|
(Number(req.body.start) || 0) + 1 + i,
|
||||||
|
htmlescape(row.name || ''),
|
||||||
|
htmlescape(striptags(row.description) || ''),
|
||||||
|
'<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>',
|
||||||
|
'<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span><a href="/report-templates/edit/' + row.id + '"> ' + _('Edit') + '</a>']
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/create', passport.csrfProtection, (req, res) => {
|
||||||
|
const data = tools.convertKeys(req.query, {
|
||||||
|
skip: ['layout']
|
||||||
|
});
|
||||||
|
|
||||||
|
const wizard = req.query['type'] || '';
|
||||||
|
|
||||||
|
if (wizard == 'subscribers-all') {
|
||||||
|
if (!('description' in data)) data.description = 'This sample shows how to generate a report listing all subscribers along with their statistics.';
|
||||||
|
|
||||||
|
if (!('mimeType' in data)) data.mimeType = 'text/html';
|
||||||
|
|
||||||
|
if (!('userFields' in data)) data.userFields =
|
||||||
|
'[\n' +
|
||||||
|
' {\n' +
|
||||||
|
' "id": "campaign",\n' +
|
||||||
|
' "name": "Campaign",\n' +
|
||||||
|
' "type": "campaign",\n' +
|
||||||
|
' "minOccurences": 1,\n' +
|
||||||
|
' "maxOccurences": 1\n' +
|
||||||
|
' }\n' +
|
||||||
|
']';
|
||||||
|
|
||||||
|
if (!('js' in data)) data.js =
|
||||||
|
'const reports = require("../lib/models/reports");\n' +
|
||||||
|
'\n' +
|
||||||
|
'reports.getCampaignResults(inputs.campaign, ["*"], "", (err, results) => {\n' +
|
||||||
|
' if (err) {\n' +
|
||||||
|
' return callback(err);\n' +
|
||||||
|
' }\n' +
|
||||||
|
'\n' +
|
||||||
|
' const data = {\n' +
|
||||||
|
' title: "Sample Report on " + inputs.campaign.name,\n' +
|
||||||
|
' results: results\n' +
|
||||||
|
' };\n' +
|
||||||
|
'\n' +
|
||||||
|
' return callback(null, data);\n' +
|
||||||
|
'});';
|
||||||
|
|
||||||
|
if (!('hbs' in data)) data.hbs =
|
||||||
|
'<h2>{{title}}</h2>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<div class="table-responsive">\n' +
|
||||||
|
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1,1" data-paging="false">\n' +
|
||||||
|
' <thead>\n' +
|
||||||
|
' <th>\n' +
|
||||||
|
' {{#translate}}Email{{/translate}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' <th>\n' +
|
||||||
|
' {{#translate}}Tracker Count{{/translate}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' </thead>\n' +
|
||||||
|
' {{#if results}}\n' +
|
||||||
|
' <tbody>\n' +
|
||||||
|
' {{#each results}}\n' +
|
||||||
|
' <tr>\n' +
|
||||||
|
' <th scope="row">\n' +
|
||||||
|
' {{email}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' <td style="width: 20%;">\n' +
|
||||||
|
' {{tracker_count}}\n' +
|
||||||
|
' </td>\n' +
|
||||||
|
' </tr>\n' +
|
||||||
|
' {{/each}}\n' +
|
||||||
|
' </tbody>\n' +
|
||||||
|
' {{/if}}\n' +
|
||||||
|
' </table>\n' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
} else if (wizard == 'subscribers-grouped') {
|
||||||
|
if (!('description' in data)) data.description = 'This sample shows how to generate a report where results are aggregated by some (typically custom) field. The sample assumes that the list associated with the campaign contains a custom field "Country" (which would be filled in via the subscription form).';
|
||||||
|
|
||||||
|
if (!('mimeType' in data)) data.mimeType = 'text/html';
|
||||||
|
|
||||||
|
if (!('userFields' in data)) data.userFields =
|
||||||
|
'[\n' +
|
||||||
|
' {\n' +
|
||||||
|
' "id": "campaign",\n' +
|
||||||
|
' "name": "Campaign",\n' +
|
||||||
|
' "type": "campaign",\n' +
|
||||||
|
' "minOccurences": 1,\n' +
|
||||||
|
' "maxOccurences": 1\n' +
|
||||||
|
' }\n' +
|
||||||
|
']';
|
||||||
|
|
||||||
|
if (!('js' in data)) data.js =
|
||||||
|
'const reports = require("../lib/models/reports");\n' +
|
||||||
|
'\n' +
|
||||||
|
'reports.getCampaignResults(inputs.campaign, ["custom_country", "count(*) AS countAll", "SUM(IF(tracker.count IS NULL, 0, 1)) AS countOpened"], "GROUP BY custom_country", (err, results) => {\n' +
|
||||||
|
' if (err) {\n' +
|
||||||
|
' return callback(err);\n' +
|
||||||
|
' }\n' +
|
||||||
|
'\n' +
|
||||||
|
' for (let row of results) {\n' +
|
||||||
|
' row["percentage"] = Math.round((row.countOpened / row.countAll) * 100);\n' +
|
||||||
|
' }\n' +
|
||||||
|
'\n' +
|
||||||
|
' let data = {\n' +
|
||||||
|
' title: "Sample Report on " + inputs.campaign.name,\n' +
|
||||||
|
' results: results\n' +
|
||||||
|
' };\n' +
|
||||||
|
'\n' +
|
||||||
|
' return callback(null, data);\n' +
|
||||||
|
'});';
|
||||||
|
|
||||||
|
if (!('hbs' in data)) data.hbs =
|
||||||
|
'<h2>{{title}}</h2>\n' +
|
||||||
|
'\n' +
|
||||||
|
'<div class="table-responsive">\n' +
|
||||||
|
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1,1,1" data-paging="false">\n' +
|
||||||
|
' <thead>\n' +
|
||||||
|
' <th>\n' +
|
||||||
|
' {{#translate}}Country{{/translate}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' <th>\n' +
|
||||||
|
' {{#translate}}Opened{{/translate}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' <th>\n' +
|
||||||
|
' {{#translate}}All{{/translate}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' <th>\n' +
|
||||||
|
' {{#translate}}Percentage{{/translate}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' </thead>\n' +
|
||||||
|
' {{#if results}}\n' +
|
||||||
|
' <tbody>\n' +
|
||||||
|
' {{#each results}}\n' +
|
||||||
|
' <tr>\n' +
|
||||||
|
' <th scope="row">\n' +
|
||||||
|
' {{custom_zone}}\n' +
|
||||||
|
' </th>\n' +
|
||||||
|
' <td style="width: 20%;">\n' +
|
||||||
|
' {{countOpened}}\n' +
|
||||||
|
' </td>\n' +
|
||||||
|
' <td style="width: 20%;">\n' +
|
||||||
|
' {{countAll}}\n' +
|
||||||
|
' </td>\n' +
|
||||||
|
' <td style="width: 20%;">\n' +
|
||||||
|
' {{percentage}}%\n' +
|
||||||
|
' </td>\n' +
|
||||||
|
' </tr>\n' +
|
||||||
|
' {{/each}}\n' +
|
||||||
|
' </tbody>\n' +
|
||||||
|
' {{/if}}\n' +
|
||||||
|
' </table>\n' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
data.csrfToken = req.csrfToken();
|
||||||
|
data.title = _('Create Report Template');
|
||||||
|
data.useEditor = true;
|
||||||
|
|
||||||
|
data.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({
|
||||||
|
key: key,
|
||||||
|
value: allowedMimeTypes[key],
|
||||||
|
selected: data.mimeType == key
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.render('report-templates/create', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||||
|
reportTemplates.createOrUpdate(true, req.body, (err, id) => {
|
||||||
|
if (err || !id) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not create report template'));
|
||||||
|
return res.redirect('/report-templates/create?' + tools.queryParams(req.body));
|
||||||
|
}
|
||||||
|
req.flash('success', util.format(_('Report template “%s” created'), req.body.name));
|
||||||
|
res.redirect('/report-templates');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/edit/:id', passport.csrfProtection, (req, res) => {
|
||||||
|
reportTemplates.get(req.params.id, (err, template) => {
|
||||||
|
if (err || !template) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not find report template with specified ID'));
|
||||||
|
return res.redirect('/report-templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
template.csrfToken = req.csrfToken();
|
||||||
|
template.title = _('Edit Report Template');
|
||||||
|
template.useEditor = true;
|
||||||
|
|
||||||
|
template.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({
|
||||||
|
key: key,
|
||||||
|
value: allowedMimeTypes[key],
|
||||||
|
selected: template.mimeType == key
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.render('report-templates/edit', template);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||||
|
reportTemplates.createOrUpdate(false, req.body, (err, updated) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err.message || err);
|
||||||
|
} else if (updated) {
|
||||||
|
req.flash('success', _('Report template updated'));
|
||||||
|
} else {
|
||||||
|
req.flash('info', _('Report template not updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body['submit'] == 'update-and-stay') {
|
||||||
|
return res.redirect('/report-templates/edit/' + req.body.id);
|
||||||
|
} else {
|
||||||
|
return res.redirect('/report-templates');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||||
|
reportTemplates.delete(req.body.id, (err, deleted) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err && err.message || err);
|
||||||
|
} else if (deleted) {
|
||||||
|
req.flash('success', _('Report template deleted'));
|
||||||
|
} else {
|
||||||
|
req.flash('info', _('Could not delete specified report template'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect('/report-templates');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
361
routes/reports.js
Normal file
361
routes/reports.js
Normal file
|
@ -0,0 +1,361 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const passport = require('../lib/passport');
|
||||||
|
const router = new express.Router();
|
||||||
|
const _ = require('../lib/translate')._;
|
||||||
|
const reportTemplates = require('../lib/models/report-templates');
|
||||||
|
const reports = require('../lib/models/reports');
|
||||||
|
const campaigns = require('../lib/models/campaigns');
|
||||||
|
const tools = require('../lib/tools');
|
||||||
|
const util = require('util');
|
||||||
|
const htmlescape = require('escape-html');
|
||||||
|
const striptags = require('striptags');
|
||||||
|
const hbs = require('hbs');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
router.all('/*', (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||||
|
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||||
|
}
|
||||||
|
res.setSelectedMenu('reports');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.render('reports/reports', {
|
||||||
|
title: _('Reports')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/ajax', (req, res) => {
|
||||||
|
reports.filter(req.body, (err, data, total, filteredTotal) => {
|
||||||
|
if (err) {
|
||||||
|
return res.json({
|
||||||
|
error: err.message || err,
|
||||||
|
data: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
draw: req.body.draw,
|
||||||
|
recordsTotal: total,
|
||||||
|
recordsFiltered: filteredTotal,
|
||||||
|
data: data.map((row, i) => [
|
||||||
|
(Number(req.body.start) || 0) + 1 + i,
|
||||||
|
htmlescape(row.name || ''),
|
||||||
|
htmlescape(row.reportTemplateName || ''),
|
||||||
|
htmlescape(striptags(row.description) || ''),
|
||||||
|
'<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>',
|
||||||
|
'<a href="/reports/view/' + row.id + '"><span class="glyphicon glyphicon-search" aria-hidden="true"></span></a> ' +
|
||||||
|
'<a href="/reports/edit/' + row.id + '"><span class="glyphicon glyphicon-wrench" aria-hidden="true"></span></a>']
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/create', passport.csrfProtection, (req, res) => {
|
||||||
|
const reqData = tools.convertKeys(req.query, {
|
||||||
|
skip: ['layout']
|
||||||
|
});
|
||||||
|
|
||||||
|
reqData.csrfToken = req.csrfToken();
|
||||||
|
reqData.title = _('Create Report');
|
||||||
|
reqData.useEditor = true;
|
||||||
|
|
||||||
|
reportTemplates.quicklist((err, items) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err.message || err);
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportTemplateId = Number(reqData.reportTemplate);
|
||||||
|
|
||||||
|
if (reportTemplateId) {
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.id === reportTemplateId) {
|
||||||
|
item.selected = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reqData.reportTemplates = items;
|
||||||
|
|
||||||
|
if (!reportTemplateId) {
|
||||||
|
res.render('reports/create-select-template', reqData);
|
||||||
|
} else {
|
||||||
|
addUserFields(reportTemplateId, reqData, null, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err.message || err);
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('reports/create', data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||||
|
const reqData = req.body;
|
||||||
|
const reportTemplateId = Number(reqData.reportTemplate);
|
||||||
|
|
||||||
|
addParamsObject(reportTemplateId, reqData, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not create report'));
|
||||||
|
return res.redirect('/reports/create?' + tools.queryParams(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.createOrUpdate(true, data, (err, id) => {
|
||||||
|
if (err || !id) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not create report'));
|
||||||
|
return res.redirect('/reports/create?' + tools.queryParams(data));
|
||||||
|
}
|
||||||
|
req.flash('success', util.format(_('Report “%s” created'), data.name));
|
||||||
|
res.redirect('/reports');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/edit/:id', passport.csrfProtection, (req, res) => {
|
||||||
|
const reqData = tools.convertKeys(req.query, {
|
||||||
|
skip: ['layout']
|
||||||
|
});
|
||||||
|
|
||||||
|
reports.get(req.params.id, (err, template) => {
|
||||||
|
if (err || !template) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not find report with specified ID'));
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
template.csrfToken = req.csrfToken();
|
||||||
|
template.title = _('Edit Report');
|
||||||
|
template.useEditor = true;
|
||||||
|
|
||||||
|
reportTemplates.quicklist((err, items) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err.message || err);
|
||||||
|
return res.redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportTemplateId = template.reportTemplate;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.id === reportTemplateId) {
|
||||||
|
item.selected = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
template.reportTemplates = items;
|
||||||
|
|
||||||
|
addUserFields(reportTemplateId, reqData, template, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err.message || err);
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('reports/edit', data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||||
|
const reqData = req.body;
|
||||||
|
const reportTemplateId = Number(reqData.reportTemplate);
|
||||||
|
|
||||||
|
addParamsObject(reportTemplateId, reqData, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not update report'));
|
||||||
|
return res.redirect('/reports/create?' + tools.queryParams(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.createOrUpdate(false, data, (err, updated) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not update report'));
|
||||||
|
return res.redirect('/reports/edit/' + data.id + '?' + tools.queryParams(data));
|
||||||
|
} else if (updated) {
|
||||||
|
req.flash('success', _('Report updated'));
|
||||||
|
} else {
|
||||||
|
req.flash('info', _('Report not updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect('/reports');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||||
|
reports.delete(req.body.id, (err, deleted) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err && err.message || err);
|
||||||
|
} else if (deleted) {
|
||||||
|
req.flash('success', _('Report deleted'));
|
||||||
|
} else {
|
||||||
|
req.flash('info', _('Could not delete specified report'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect('/reports');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/view/:id', passport.csrfProtection, (req, res) => {
|
||||||
|
reports.get(req.params.id, (err, template) => {
|
||||||
|
if (err || !template) {
|
||||||
|
req.flash('danger', err && err.message || err || _('Could not find report with specified ID'));
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
reportTemplates.get(template.reportTemplate, (err, reportTemplate) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveUserFields(reportTemplate.userFieldsObject, template.paramsObject, (err, inputs) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err.message || err);
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
require: require,
|
||||||
|
inputs: inputs,
|
||||||
|
callback: (err, outputs) => {
|
||||||
|
if (err) {
|
||||||
|
req.flash('danger', err.message || err);
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hbsTmpl = hbs.handlebars.compile(reportTemplate.hbs);
|
||||||
|
const report = hbsTmpl(outputs);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
csrfToken: req.csrfToken(),
|
||||||
|
report: new hbs.handlebars.SafeString(report),
|
||||||
|
title: outputs.title
|
||||||
|
};
|
||||||
|
|
||||||
|
res.render('reports/view', data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const script = new vm.Script(reportTemplate.js);
|
||||||
|
script.runInNewContext(sandbox, { displayErrors: true, timeout: 10000 });
|
||||||
|
} catch (err) {
|
||||||
|
req.flash('danger', 'Error in the report template script ... ' + err.stack.replace(/at ContextifyScript.Script.runInContext[\s\S]*/,''));
|
||||||
|
return res.redirect('/reports');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveCampaigns(ids, callback) {
|
||||||
|
const idsRemaining = ids.slice();
|
||||||
|
const resolved = [];
|
||||||
|
|
||||||
|
function doWork() {
|
||||||
|
if (idsRemaining.length == 0) {
|
||||||
|
return callback(null, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
campaigns.get(idsRemaining.shift(), false, (err, campaign) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.push(campaign);
|
||||||
|
return doWork();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setImmediate(doWork);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUserFields(userFields, params, callback) {
|
||||||
|
const userFieldsRemaining = userFields.slice();
|
||||||
|
const resolved = {};
|
||||||
|
|
||||||
|
function doWork() {
|
||||||
|
if (userFieldsRemaining.length == 0) {
|
||||||
|
return callback(null, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = userFieldsRemaining.shift();
|
||||||
|
if (spec.type == 'campaign') {
|
||||||
|
return resolveCampaigns(params[spec.id], (err, campaigns) => {
|
||||||
|
if (spec.minOccurences == 1 && spec.maxOccurences == 1) {
|
||||||
|
resolved[spec.id] = campaigns[0];
|
||||||
|
} else {
|
||||||
|
resolved[spec.id] = campaigns;
|
||||||
|
}
|
||||||
|
|
||||||
|
doWork();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback(new Error(_('Unknown user field type "' + spec.type + '".')));
|
||||||
|
}
|
||||||
|
|
||||||
|
setImmediate(doWork);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUserFields(reportTemplateId, reqData, template, callback) {
|
||||||
|
reportTemplates.get(reportTemplateId, (err, reportTemplate) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userFields = [];
|
||||||
|
|
||||||
|
for (const spec of reportTemplate.userFieldsObject) {
|
||||||
|
let value = '';
|
||||||
|
if ((spec.id + 'Selection') in reqData) {
|
||||||
|
value = reqData[spec.id + 'Selection'];
|
||||||
|
} else if (template && template.paramsObject && spec.id in template.paramsObject) {
|
||||||
|
value = template.paramsObject[spec.id].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
userFields.push({
|
||||||
|
'id': spec.id,
|
||||||
|
'name': spec.name,
|
||||||
|
'type': spec.type,
|
||||||
|
'value': value,
|
||||||
|
'isMulti': !(spec.minOccurences == 1 && spec.maxOccurences == 1)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = template ? template : reqData;
|
||||||
|
data.userFields = userFields;
|
||||||
|
|
||||||
|
callback(null, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addParamsObject(reportTemplateId, data, callback) {
|
||||||
|
reportTemplates.get(reportTemplateId, (err, reportTemplate) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramsObject = {};
|
||||||
|
|
||||||
|
for (const spec of reportTemplate.userFieldsObject) {
|
||||||
|
const sel = data[spec.id + 'Selection'];
|
||||||
|
|
||||||
|
if (!sel) {
|
||||||
|
paramsObject[spec.id] = [];
|
||||||
|
} else {
|
||||||
|
paramsObject[spec.id] = sel.split(',').map(item => Number(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.paramsObject = paramsObject;
|
||||||
|
|
||||||
|
callback(null, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
210
setup/install-centos7.sh
Normal file
210
setup/install-centos7.sh
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This installation script works on CentOS 7
|
||||||
|
# Run as root!
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root" 1>&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
yum -y install epel-release
|
||||||
|
|
||||||
|
curl --silent --location https://rpm.nodesource.com/setup_6.x | bash -
|
||||||
|
yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils
|
||||||
|
|
||||||
|
systemctl start mariadb
|
||||||
|
systemctl enable mariadb
|
||||||
|
|
||||||
|
systemctl start redis
|
||||||
|
systemctl enable redis
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC_IP=`curl -s https://api.ipify.org`
|
||||||
|
if [ ! -z "$PUBLIC_IP" ]; then
|
||||||
|
HOSTNAME=`dig +short -x $PUBLIC_IP | sed 's/\.$//'`
|
||||||
|
HOSTNAME="${HOSTNAME:-$PUBLIC_IP}"
|
||||||
|
fi
|
||||||
|
HOSTNAME="${HOSTNAME:-`hostname`}"
|
||||||
|
|
||||||
|
MYSQL_PASSWORD=`pwgen 12 -1`
|
||||||
|
DKIM_API_KEY=`pwgen 12 -1`
|
||||||
|
SMTP_PASS=`pwgen 12 -1`
|
||||||
|
|
||||||
|
# Setup MySQL user for Mailtrain
|
||||||
|
mysql -u root -e "CREATE USER 'mailtrain'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';"
|
||||||
|
mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain.* TO 'mailtrain'@'localhost';"
|
||||||
|
mysql -u mailtrain --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain;"
|
||||||
|
|
||||||
|
# Enable firewall, allow connections to SSH, HTTP, HTTPS and SMTP
|
||||||
|
for port in 80/tcp 443/tcp 25/tcp; do firewall-cmd --add-port=$port --permanent; done
|
||||||
|
firewall-cmd --reload
|
||||||
|
|
||||||
|
# Fetch Mailtrain files
|
||||||
|
mkdir -p /opt/mailtrain
|
||||||
|
cd /opt/mailtrain
|
||||||
|
git clone git://github.com/Mailtrain-org/mailtrain.git .
|
||||||
|
|
||||||
|
# Normally we would let Mailtrain itself to import the initial SQL data but in this case
|
||||||
|
# we need to modify it, before we start Mailtrain
|
||||||
|
mysql -u mailtrain -p"$MYSQL_PASSWORD" mailtrain < setup/sql/mailtrain.sql
|
||||||
|
|
||||||
|
mysql -u mailtrain -p"$MYSQL_PASSWORD" mailtrain <<EOT
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('admin_email','admin@$HOSTNAME') ON DUPLICATE KEY UPDATE \`value\`='admin@$HOSTNAME';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('default_address','admin@$HOSTNAME') ON DUPLICATE KEY UPDATE \`value\`='admin@$HOSTNAME';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_hostname','localhost') ON DUPLICATE KEY UPDATE \`value\`='localhost';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_disable_auth','') ON DUPLICATE KEY UPDATE \`value\`='';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_user','mailtrain') ON DUPLICATE KEY UPDATE \`value\`='mailtrain';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_pass','$SMTP_PASS') ON DUPLICATE KEY UPDATE \`value\`='$SMTP_PASS';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_encryption','NONE') ON DUPLICATE KEY UPDATE \`value\`='NONE';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_port','2525') ON DUPLICATE KEY UPDATE \`value\`='2525';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('default_homepage','http://$HOSTNAME/') ON DUPLICATE KEY UPDATE \`value\`='http://$HOSTNAME/';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('service_url','http://$HOSTNAME/') ON DUPLICATE KEY UPDATE \`value\`='http://$HOSTNAME/';
|
||||||
|
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('dkim_api_key','$DKIM_API_KEY') ON DUPLICATE KEY UPDATE \`value\`='$DKIM_API_KEY';
|
||||||
|
EOT
|
||||||
|
|
||||||
|
# Add new user for the mailtrain daemon to run as
|
||||||
|
useradd mailtrain || true
|
||||||
|
useradd zone-mta || true
|
||||||
|
|
||||||
|
# Setup installation configuration
|
||||||
|
cat >> config/production.toml <<EOT
|
||||||
|
user="mailtrain"
|
||||||
|
group="mailtrain"
|
||||||
|
[log]
|
||||||
|
level="error"
|
||||||
|
[www]
|
||||||
|
port=80
|
||||||
|
secret="`pwgen -1`"
|
||||||
|
[mysql]
|
||||||
|
password="$MYSQL_PASSWORD"
|
||||||
|
[redis]
|
||||||
|
enabled=true
|
||||||
|
[queue]
|
||||||
|
processes=5
|
||||||
|
EOT
|
||||||
|
|
||||||
|
# Install required node packages
|
||||||
|
npm install --no-progress --production
|
||||||
|
chown -R mailtrain:mailtrain .
|
||||||
|
|
||||||
|
# Setup log rotation to not spend up entire storage on logs
|
||||||
|
cat <<EOM > /etc/logrotate.d/mailtrain
|
||||||
|
/var/log/mailtrain.log {
|
||||||
|
daily
|
||||||
|
rotate 12
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
nomail
|
||||||
|
}
|
||||||
|
EOM
|
||||||
|
|
||||||
|
# Set up systemd service script
|
||||||
|
cp setup/mailtrain.service /etc/systemd/system/
|
||||||
|
systemctl enable mailtrain.service
|
||||||
|
|
||||||
|
# Fetch ZoneMTA files
|
||||||
|
mkdir -p /opt/zone-mta
|
||||||
|
cd /opt/zone-mta
|
||||||
|
git clone git://github.com/zone-eu/zone-mta.git .
|
||||||
|
git checkout 6964091273
|
||||||
|
|
||||||
|
# Ensure queue folder
|
||||||
|
mkdir -p /var/data/zone-mta/mailtrain
|
||||||
|
|
||||||
|
# Setup installation configuration
|
||||||
|
cat >> config/production.json <<EOT
|
||||||
|
{
|
||||||
|
"name": "Mailtrain",
|
||||||
|
"user": "zone-mta",
|
||||||
|
"group": "zone-mta",
|
||||||
|
"queue": {
|
||||||
|
"db": "/var/data/zone-mta/mailtrain"
|
||||||
|
},
|
||||||
|
"smtpInterfaces": {
|
||||||
|
"feeder": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 2525,
|
||||||
|
"processes": 2,
|
||||||
|
"authentication": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"maildrop": false,
|
||||||
|
"user": "mailtrain",
|
||||||
|
"pass": "$SMTP_PASS"
|
||||||
|
},
|
||||||
|
"log": {
|
||||||
|
"level": "info",
|
||||||
|
"syslog": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"core/email-bounce": false,
|
||||||
|
"core/http-bounce": {
|
||||||
|
"enabled": "main",
|
||||||
|
"url": "http://localhost/webhooks/zone-mta"
|
||||||
|
},
|
||||||
|
"core/http-auth": {
|
||||||
|
"enabled": ["receiver", "main"],
|
||||||
|
"url": "http://localhost:8080/test-auth"
|
||||||
|
},
|
||||||
|
"core/default-headers": {
|
||||||
|
"enabled": ["receiver", "main", "sender"],
|
||||||
|
"futureDate": false,
|
||||||
|
"xOriginatingIP": false
|
||||||
|
},
|
||||||
|
"core/http-config": {
|
||||||
|
"enabled": ["main", "receiver"],
|
||||||
|
"url": "http://localhost/webhooks/zone-mta/sender-config?api_token=$DKIM_API_KEY"
|
||||||
|
},
|
||||||
|
"core/rcpt-mx": false
|
||||||
|
},
|
||||||
|
"pools": {
|
||||||
|
"default": [{
|
||||||
|
"address": "0.0.0.0",
|
||||||
|
"name": "$HOSTNAME"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"zones": {
|
||||||
|
"default": {
|
||||||
|
"processes": 3,
|
||||||
|
"connections": 5,
|
||||||
|
"throttling": false,
|
||||||
|
"pool": "default"
|
||||||
|
},
|
||||||
|
"transactional": {
|
||||||
|
"processes": 1,
|
||||||
|
"connections": 1,
|
||||||
|
"pool": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"domainConfig": {
|
||||||
|
"default": {
|
||||||
|
"maxConnections": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOT
|
||||||
|
|
||||||
|
# Install required node packages
|
||||||
|
npm install --no-progress --production
|
||||||
|
npm install leveldown
|
||||||
|
|
||||||
|
# Ensure queue folder is owned by MTA user
|
||||||
|
chown -R zone-mta:zone-mta /var/data/zone-mta/mailtrain
|
||||||
|
|
||||||
|
# Set up systemd service script
|
||||||
|
cp setup/zone-mta.service /etc/systemd/system/
|
||||||
|
systemctl enable zone-mta.service
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start zone-mta.service
|
||||||
|
systemctl start mailtrain.service
|
||||||
|
|
||||||
|
echo "Success! Open http://$HOSTNAME/ and log in as admin:test";
|
35
setup/sql/upgrade-00027.sql
Normal file
35
setup/sql/upgrade-00027.sql
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
# Header section
|
||||||
|
# Define incrementing schema version number
|
||||||
|
SET @schema_version = '27';
|
||||||
|
|
||||||
|
# Create table to report templates
|
||||||
|
CREATE TABLE `report_templates` (
|
||||||
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(255) DEFAULT '',
|
||||||
|
`mime_type` varchar(255) DEFAULT 'text/html' NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`user_fields` longtext,
|
||||||
|
`js` longtext,
|
||||||
|
`hbs` longtext,
|
||||||
|
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
# Create table to store reports
|
||||||
|
CREATE TABLE `reports` (
|
||||||
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(255) DEFAULT '',
|
||||||
|
`description` text,
|
||||||
|
`report_template` int(11) unsigned NOT NULL,
|
||||||
|
`params` longtext,
|
||||||
|
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `report_template` (`report_template`),
|
||||||
|
CONSTRAINT `report_template_ibfk_1` FOREIGN KEY (`report_template`) REFERENCES `report_templates` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
|
||||||
|
# Footer section
|
||||||
|
LOCK TABLES `settings` WRITE;
|
||||||
|
INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version;
|
||||||
|
UNLOCK TABLES;
|
24
views/report-templates/create.hbs
Normal file
24
views/report-templates/create.hbs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||||
|
<li><a href="/report-templates">{{#translate}}Templates{{/translate}}</a></li>
|
||||||
|
<li class="active">{{#translate}}Create Template{{/translate}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>{{#translate}}Create Report Template{{/translate}}</h2>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form class="form-horizontal" method="post" action="/report-templates/create">
|
||||||
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
|
|
||||||
|
{{> report_template_fields }}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Template{{/translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
36
views/report-templates/edit.hbs
Normal file
36
views/report-templates/edit.hbs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||||
|
<li><a href="/report-templates">{{#translate}}Templates{{/translate}}</a></li>
|
||||||
|
<li class="active">{{#translate}}Edit Template{{/translate}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>{{#translate}}Edit Report Template{{/translate}}</h2>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form method="post" class="delete-form" id="report-templates-delete" action="/report-templates/delete">
|
||||||
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
|
<input type="hidden" name="id" value="{{id}}" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<form class="form-horizontal" method="post" action="/report-templates/edit">
|
||||||
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
|
<input type="hidden" name="id" value="{{id}}" />
|
||||||
|
|
||||||
|
{{> report_template_fields }}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<div class="pull-right">
|
||||||
|
<button type="submit" form="report-templates-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> {{#translate}}Delete Template{{/translate}}</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="submit" value="update-and-stay" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update and Stay{{/translate}}</button>
|
||||||
|
<button type="submit" name="submit" value="update-and-leave" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update and Leave{{/translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
59
views/report-templates/partials/report-template-fields.hbs
Normal file
59
views/report-templates/partials/report-template-fields.hbs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="col-sm-2 control-label">{{#translate}}Name{{/translate}}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="{{#translate}}Template Name{{/translate}}" autofocus required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="col-sm-2 control-label">{{#translate}}Description{{/translate}}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
|
||||||
|
<span class="help-block">{{#translate}}HTML is allowed{{/translate}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mimeType" class="col-sm-2 control-label">{{#translate}}Type{{/translate}}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select name="mimeType" class="form-control">
|
||||||
|
{{#each mimeTypes}}
|
||||||
|
<option value="{{key}}" {{#if selected}} selected {{/if}}>{{value}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-sm-2 control-label">{{#translate}}User selectable fields{{/translate}}</label>
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<div class="help-block" style="margin-top: -8px;">
|
||||||
|
<small>JSON specification of user selectable fields.</small>
|
||||||
|
</div>
|
||||||
|
<div class="code-editor-json" style="height: 250px; border: 1px solid #ccc;"></div>
|
||||||
|
<input type="hidden" name="userFields" value="{{userFields}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-sm-2 control-label">{{#translate}}Data processing code{{/translate}}</label>
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<div class="help-block" style="margin-top: -8px;">
|
||||||
|
<small>Write the body of the JavaScript function with signature <code>function(inputs, callback)</code> that returns an object to be rendered by the Handlebars template below.</small>
|
||||||
|
</div>
|
||||||
|
<div class="code-editor-javascript" style="height: 700px; border: 1px solid #ccc;"></div>
|
||||||
|
<input type="hidden" name="js" value="{{js}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-sm-2 control-label">{{#translate}}Rendering template{{/translate}}</label>
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<div class="help-block" style="margin-top: -8px;">
|
||||||
|
<small>Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</small>
|
||||||
|
</div>
|
||||||
|
<div class="code-editor-handlebars" style="height: 700px; border: 1px solid #ccc;"></div>
|
||||||
|
<input type="hidden" name="hbs" value="{{hbs}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
44
views/report-templates/report-templates.hbs
Normal file
44
views/report-templates/report-templates.hbs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||||
|
<li class="active">{{#translate}}Templates{{/translate}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="pull-right">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
{{#translate}}Create Template{{/translate}} <span class="caret"></span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="/report-templates/create">{{#translate}}Blank{{/translate}}</a></li>
|
||||||
|
<li><a href="/report-templates/create?type=subscribers-all">{{#translate}}All Subscribers{{/translate}}</a></li>
|
||||||
|
<li><a href="/report-templates/create?type=subscribers-grouped">{{#translate}}Grouped Subscribers{{/translate}}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{#translate}}Report Templates{{/translate}}</h2>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table data-topic-url="/report-templates" data-sort-column="2" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,0,1,0">
|
||||||
|
<thead>
|
||||||
|
<th class="col-md-1">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Name{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Description{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Created{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th class="col-md-1">
|
||||||
|
|
||||||
|
</th>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
23
views/reports/create-select-template.hbs
Normal file
23
views/reports/create-select-template.hbs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||||
|
<li class="active">{{#translate}}Create Report{{/translate}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>{{#translate}}Create Report{{/translate}}</h2>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form class="form-horizontal" method="get" action="/reports/create">
|
||||||
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
|
|
||||||
|
{{> report_select_template }}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-chevron-right"></i> {{#translate}}Next{{/translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
23
views/reports/create.hbs
Normal file
23
views/reports/create.hbs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||||
|
<li class="active">{{#translate}}Create Report{{/translate}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>{{#translate}}Create Report{{/translate}}</h2>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form class="form-horizontal" method="post" action="/reports/create">
|
||||||
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
|
|
||||||
|
{{> report_fields }}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Report{{/translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
34
views/reports/edit.hbs
Normal file
34
views/reports/edit.hbs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||||
|
<li class="active">{{#translate}}Edit Report{{/translate}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>{{#translate}}Edit Report{{/translate}}</h2>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form method="post" class="delete-form" id="reports-delete" action="/reports/delete">
|
||||||
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
|
<input type="hidden" name="id" value="{{id}}" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<form class="form-horizontal" method="post" action="/reports/edit">
|
||||||
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
|
<input type="hidden" name="id" value="{{id}}" />
|
||||||
|
|
||||||
|
{{> report_fields }}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<div class="pull-right">
|
||||||
|
<button type="submit" form="reports-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> {{#translate}}Delete Report{{/translate}}</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update{{/translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
49
views/reports/partials/report-fields.hbs
Normal file
49
views/reports/partials/report-fields.hbs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
{{> report_select_template options="readonly" }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="col-sm-2 control-label">{{#translate}}Name{{/translate}}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="{{#translate}}Report Name{{/translate}}" autofocus required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="col-sm-2 control-label">{{#translate}}Description{{/translate}}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
|
||||||
|
<span class="help-block">{{#translate}}HTML is allowed{{/translate}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#each userFields}}
|
||||||
|
{{#switch type}}
|
||||||
|
{{#case "campaign"}}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="col-sm-2 control-label">{{name}}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table data-topic-url="/campaigns/quicklist" data-sort-column="2" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax data-table-{{#if isMulti}}multi{{/if}}selectable display nowrap" width="100%" data-row-sort="0,1,0,1">
|
||||||
|
<thead>
|
||||||
|
<th class="col-md-1">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Name{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Description{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Created{{/translate}}
|
||||||
|
</th>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<input type="hidden" name="{{id}}Selection" value="{{value}}" />
|
||||||
|
</div>
|
||||||
|
<span class="help-block">{{#translate}}Select a campaign in the table above by clicking on the respective row number.{{/translate}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/case}}
|
||||||
|
{{/switch}}
|
||||||
|
{{/each}}
|
||||||
|
|
11
views/reports/partials/report-select-template.hbs
Normal file
11
views/reports/partials/report-select-template.hbs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="col-sm-2 control-label">{{#translate}}Report Template{{/translate}}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" id="reportTemplate" name="reportTemplate" required {{options}}>
|
||||||
|
<option value=""> –– {{#translate}}Select{{/translate}} –– </option>
|
||||||
|
{{#each reportTemplates}}
|
||||||
|
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
39
views/reports/reports.hbs
Normal file
39
views/reports/reports.hbs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li class="active">{{#translate}}Reports{{/translate}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="pull-right">
|
||||||
|
<a class="btn btn-primary" href="/reports/create" role="button"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Report{{/translate}}</a>
|
||||||
|
|
||||||
|
<a class="btn btn-primary" href="/report-templates" role="button">{{#translate}}Report Templates{{/translate}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{#translate}}Reports{{/translate}}</h2>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table data-topic-url="/reports" data-sort-column="2" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,1,0,1,0">
|
||||||
|
<thead>
|
||||||
|
<th class="col-md-1">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Name{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Template{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Description{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{#translate}}Created{{/translate}}
|
||||||
|
</th>
|
||||||
|
<th class="col-md-1">
|
||||||
|
|
||||||
|
</th>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
7
views/reports/view.hbs
Normal file
7
views/reports/view.hbs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||||
|
<li><a href="/reports/">{{#translate}}Reports{{/translate}}</a></li>
|
||||||
|
<li class="active">{{title}}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{{report}}
|
Loading…
Add table
Add a link
Reference in a new issue