diff --git a/client/src/report-templates/CUD.js b/client/src/reports/CUD.js
similarity index 100%
rename from client/src/report-templates/CUD.js
rename to client/src/reports/CUD.js
diff --git a/client/src/report-templates/List.js b/client/src/reports/List.js
similarity index 100%
rename from client/src/report-templates/List.js
rename to client/src/reports/List.js
diff --git a/client/src/report-templates/root.js b/client/src/reports/root.js
similarity index 100%
rename from client/src/report-templates/root.js
rename to client/src/reports/root.js
diff --git a/client/src/reports/templates/CUD.js b/client/src/reports/templates/CUD.js
new file mode 100644
index 00000000..e19a2a44
--- /dev/null
+++ b/client/src/reports/templates/CUD.js
@@ -0,0 +1,350 @@
+'use strict';
+
+import React, { Component } from 'react';
+import { translate, Trans } from 'react-i18next';
+import { withPageHelpers, Title } from '../lib/page'
+import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../lib/form';
+import axios from '../lib/axios';
+import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
+import { ModalDialog } from '../lib/bootstrap-components';
+
+@translate()
+@withForm
+@withPageHelpers
+@withErrorHandling
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+
+ if (props.edit) {
+ this.state.entityId = parseInt(props.match.params.id);
+ }
+
+ this.initForm();
+ }
+
+ isDelete() {
+ return this.props.match.params.action === 'delete';
+ }
+
+ @withAsyncErrorHandler
+ async loadFormValues() {
+ await this.getFormValuesFromURL(`/rest/report-templates/${this.state.entityId}`);
+ }
+
+ componentDidMount() {
+ if (this.props.edit) {
+ this.loadFormValues();
+
+ } else {
+ const wizard = this.props.match.params.wizard;
+
+ if (wizard === 'subscribers-all') {
+ this.populateFormValues({
+ name: '',
+ description: 'Generates a campaign report listing all subscribers along with their statistics.',
+ mime_type: 'text/html',
+ user_fields:
+ '[\n' +
+ ' {\n' +
+ ' "id": "campaign",\n' +
+ ' "name": "Campaign",\n' +
+ ' "type": "campaign",\n' +
+ ' "minOccurences": 1,\n' +
+ ' "maxOccurences": 1\n' +
+ ' }\n' +
+ ']',
+ js:
+ 'campaigns.results(inputs.campaign, ["*"], "", (err, results) => {\n' +
+ ' if (err) {\n' +
+ ' return callback(err);\n' +
+ ' }\n' +
+ '\n' +
+ ' const data = {\n' +
+ ' results: results\n' +
+ ' };\n' +
+ '\n' +
+ ' return callback(null, data);\n' +
+ '});',
+ hbs:
+ '
{{title}}
\n' +
+ '\n' +
+ '\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' {{#translate}}Email{{/translate}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{#translate}}Tracker Count{{/translate}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{#if results}}\n' +
+ ' \n' +
+ ' {{#each results}}\n' +
+ ' \n' +
+ ' \n' +
+ ' {{email}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{tracker_count}}\n' +
+ ' | \n' +
+ '
\n' +
+ ' {{/each}}\n' +
+ ' \n' +
+ ' {{/if}}\n' +
+ '
\n' +
+ '
'
+ });
+
+ } else if (wizard === 'subscribers-grouped') {
+ this.populateFormValues({
+ name: '',
+ description: 'Generates a campaign report with results are aggregated by some "Country" custom field.',
+ mime_type: 'text/html',
+ user_fields:
+ '[\n' +
+ ' {\n' +
+ ' "id": "campaign",\n' +
+ ' "name": "Campaign",\n' +
+ ' "type": "campaign",\n' +
+ ' "minOccurences": 1,\n' +
+ ' "maxOccurences": 1\n' +
+ ' }\n' +
+ ']',
+ js:
+ 'campaigns.results(inputs.campaign, ["custom_country", "count(*) AS count_all", "SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"], "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.count_opened / row.count_all) * 100);\n' +
+ ' }\n' +
+ '\n' +
+ ' let data = {\n' +
+ ' results: results\n' +
+ ' };\n' +
+ '\n' +
+ ' return callback(null, data);\n' +
+ '});',
+ hbs:
+ '{{title}}
\n' +
+ '\n' +
+ '\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' {{#translate}}Country{{/translate}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{#translate}}Opened{{/translate}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{#translate}}All{{/translate}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{#translate}}Percentage{{/translate}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{#if results}}\n' +
+ ' \n' +
+ ' {{#each results}}\n' +
+ ' \n' +
+ ' \n' +
+ ' {{custom_country}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{count_opened}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{count_all}}\n' +
+ ' | \n' +
+ ' \n' +
+ ' {{percentage}}%\n' +
+ ' | \n' +
+ '
\n' +
+ ' {{/each}}\n' +
+ ' \n' +
+ ' {{/if}}\n' +
+ '
\n' +
+ '
'
+ });
+
+ } else if (wizard === 'export-list-csv') {
+ this.populateFormValues({
+ name: '',
+ description: 'Exports a list as a CSV file.',
+ mime_type: 'text/csv',
+ user_fields:
+ '[\n' +
+ ' {\n' +
+ ' "id": "list",\n' +
+ ' "name": "List",\n' +
+ ' "type": "list",\n' +
+ ' "minOccurences": 1,\n' +
+ ' "maxOccurences": 1\n' +
+ ' }\n' +
+ ']',
+ js:
+ 'subscriptions.list(inputs.list.id,0,0, (err, results) => {\n' +
+ ' if (err) {\n' +
+ ' return callback(err);\n' +
+ ' }\n' +
+ '\n' +
+ ' let data = {\n' +
+ ' results: results\n' +
+ ' };\n' +
+ '\n' +
+ ' return callback(null, data);\n' +
+ '});',
+ hbs:
+ '{{#each results}}\n' +
+ '{{firstName}},{{lastName}},{{email}}\n' +
+ '{{/each}}'
+ });
+
+ } else {
+ this.populateFormValues({
+ name: '',
+ description: '',
+ mime_type: 'text/html',
+ user_fields: '',
+ js: '',
+ hbs: ''
+ });
+ }
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+ const edit = this.props.edit;
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('Name must not be empty'));
+ } else {
+ state.setIn(['name', 'error'], null);
+ }
+
+ if (!state.getIn(['mime_type', 'value'])) {
+ state.setIn(['mime_type', 'error'], t('MIME Type must be selected'));
+ } else {
+ state.setIn(['mime_type', 'error'], null);
+ }
+
+ try {
+ const userFields = JSON.parse(state.getIn(['user_fields', 'value']));
+ state.setIn(['user_fields', 'error'], null);
+ } catch (err) {
+ if (err instanceof SyntaxError) {
+ state.setIn(['user_fields', 'error'], t('Syntax error in the user fields specification'));
+ }
+ }
+ }
+
+ async submitAndStay() {
+ await Form.handleChangedError(this, async () => await this.doSubmit(true));
+ }
+
+ async submitAndLeave() {
+ await Form.handleChangedError(this, async () => await this.doSubmit(false));
+ }
+
+ async doSubmit(stay) {
+ const t = this.props.t;
+ const edit = this.props.edit;
+
+ let sendMethod, url;
+ if (edit) {
+ sendMethod = FormSendMethod.PUT;
+ url = `/rest/report-templates/${this.state.entityId}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = '/rest/report-templates'
+ }
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('Saving report template ...'));
+
+ const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
+ delete data.password2;
+ });
+
+ if (submitSuccessful) {
+ if (stay) {
+ this.enableForm();
+ this.setFormStatusMessage('success', t('Report template saved'));
+ } else {
+ this.navigateToWithFlashMessage('/reports/templates', 'success', t('Report template saved'));
+ }
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
+ }
+ }
+
+ async showDeleteModal() {
+ this.navigateTo(`/reports/templates/edit/${this.state.entityId}/delete`);
+ }
+
+ async hideDeleteModal() {
+ this.navigateTo(`/reports/templates/edit/${this.state.entityId}`);
+ }
+
+ async performDelete() {
+ const t = this.props.t;
+
+ await this.hideDeleteModal();
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('Deleting report template...'));
+
+ await axios.delete(`/rest/report-templates/${this.state.entityId}`);
+
+ this.navigateToWithFlashMessage('/reports/templates', 'success', t('Report template deleted'));
+ }
+
+ render() {
+ const t = this.props.t;
+ const edit = this.props.edit;
+
+ return (
+
+ {edit &&
+
+ {t('Are you sure you want to delete report template "{{name}}"?', {name: this.getFormValue('name')})}
+
+ }
+
+
{edit ? t('Edit Report Template') : t('Create Report Template')}
+
+
+
+ );
+ }
+}
diff --git a/client/src/reports/templates/List.js b/client/src/reports/templates/List.js
new file mode 100644
index 00000000..906d4645
--- /dev/null
+++ b/client/src/reports/templates/List.js
@@ -0,0 +1,44 @@
+'use strict';
+
+import React, { Component } from 'react';
+import { translate } from 'react-i18next';
+import { DropdownMenu } from '../lib/bootstrap-components';
+import { Title, Toolbar, DropdownLink } from '../lib/page';
+import { Table } from '../lib/table';
+import moment from 'moment';
+
+@translate()
+export default class List extends Component {
+ render() {
+ const t = this.props.t;
+
+ const actionLinks = [{
+ label: 'Edit',
+ link: data => '/reports/templates/edit/' + data[0]
+ }];
+
+ const columns = [
+ { data: 0, title: "#" },
+ { data: 1, title: t('Name') },
+ { data: 2, title: t('Description') },
+ { data: 3, title: t('Created'), render: data => moment(data).fromNow() }
+ ];
+
+ return (
+
+
+
+ {t('Blank')}
+ {t('All Subscribers')}
+ {t('Grouped Subscribers')}
+ {t('Export List as CSV')}
+
+
+
+
{t('Report Templates')}
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/models/reports.js b/lib/models/reports-REMOVE.js
similarity index 100%
rename from lib/models/reports.js
rename to lib/models/reports-REMOVE.js
diff --git a/models/reports.js b/models/reports.js
new file mode 100644
index 00000000..ec8d9b17
--- /dev/null
+++ b/models/reports.js
@@ -0,0 +1,60 @@
+'use strict';
+
+const knex = require('../lib/knex');
+const hasher = require('node-object-hash')();
+const { enforce, filterObject } = require('../lib/helpers');
+const dtHelpers = require('../lib/dt-helpers');
+const interoperableErrors = require('../shared/interoperable-errors');
+
+const allowedKeys = new Set(['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs']);
+
+function hash(ns) {
+ return hasher.hash(filterObject(ns, allowedKeys));
+}
+
+async function getById(templateId) {
+ const template = await knex('report_templates').where('id', templateId).first();
+ if (!template) {
+ throw new interoperableErrors.NotFoundError();
+ }
+
+ return template;
+}
+
+async function listDTAjax(params) {
+ return await dtHelpers.ajaxList(params, tx => tx('report_templates'), ['report_templates.id', 'report_templates.name', 'report_templates.description', 'report_templates.created']);
+}
+
+async function create(template) {
+ const templateId = await knex('report_templates').insert(filterObject(template, allowedKeys));
+ return templateId;
+}
+
+async function updateWithConsistencyCheck(template) {
+ await knex.transaction(async tx => {
+ const existingTemplate = await tx('report_templates').where('id', template.id).first();
+ if (!template) {
+ throw new interoperableErrors.NotFoundError();
+ }
+
+ const existingNsHash = hash(existingTemplate);
+ if (existingNsHash != template.originalHash) {
+ throw new interoperableErrors.ChangedError();
+ }
+
+ await tx('report_templates').where('id', template.id).update(filterObject(template, allowedKeys));
+ });
+}
+
+async function remove(templateId) {
+ await knex('report_templates').where('id', templateId).del();
+}
+
+module.exports = {
+ hash,
+ getById,
+ listDTAjax,
+ create,
+ updateWithConsistencyCheck,
+ remove
+};
\ No newline at end of file
diff --git a/routes/reports.js b/routes/reports-REMOVE.js
similarity index 100%
rename from routes/reports.js
rename to routes/reports-REMOVE.js
diff --git a/routes/report-templates-legacy-integration.js b/routes/reports-legacy-integration.js
similarity index 100%
rename from routes/report-templates-legacy-integration.js
rename to routes/reports-legacy-integration.js
diff --git a/routes/rest/reports.js b/routes/rest/reports.js
new file mode 100644
index 00000000..6ca9218d
--- /dev/null
+++ b/routes/rest/reports.js
@@ -0,0 +1,39 @@
+'use strict';
+
+const passport = require('../../lib/passport');
+const _ = require('../../lib/translate')._;
+const reportTemplates = require('../../models/report-templates');
+
+const router = require('../../lib/router-async').create();
+
+
+router.getAsync('/report-templates/:reportTemplateId', passport.loggedIn, async (req, res) => {
+ const reportTemplate = await reportTemplates.getById(req.params.reportTemplateId);
+ reportTemplate.hash = reportTemplates.hash(reportTemplate);
+ return res.json(reportTemplate);
+});
+
+router.postAsync('/report-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ await reportTemplates.create(req.body);
+ return res.json();
+});
+
+router.putAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ const reportTemplate = req.body;
+ reportTemplate.id = parseInt(req.params.reportTemplateId);
+
+ await reportTemplates.updateWithConsistencyCheck(reportTemplate);
+ return res.json();
+});
+
+router.deleteAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ await reportTemplates.remove(req.context, req.params.reportTemplateId);
+ return res.json();
+});
+
+router.postAsync('/report-templates-table', passport.loggedIn, async (req, res) => {
+ return res.json(await reportTemplates.listDTAjax(req.body));
+});
+
+
+module.exports = router;
\ No newline at end of file