diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js
new file mode 100644
index 00000000..3216a3d4
--- /dev/null
+++ b/client/src/campaigns/CUD.js
@@ -0,0 +1,503 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {
+ Trans,
+ translate
+} from 'react-i18next';
+import {
+ NavButton,
+ requiresAuthenticatedUser,
+ Title,
+ withPageHelpers
+} from '../lib/page'
+import {
+ ACEEditor,
+ AlignedRow,
+ Button,
+ ButtonRow,
+ CheckBox,
+ Dropdown,
+ Form,
+ FormSendMethod,
+ InputField,
+ StaticField,
+ TableSelect,
+ TextArea,
+ withForm
+} from '../lib/form';
+import {
+ withAsyncErrorHandler,
+ withErrorHandling
+} from '../lib/error-handling';
+import {
+ NamespaceSelect,
+ validateNamespace
+} from '../lib/namespace';
+import {DeleteModalDialog} from "../lib/modals";
+import mailtrainConfig from 'mailtrainConfig';
+import {
+ getEditForm,
+ getTemplateTypes,
+ getTypeForm
+} from '../templates/helpers';
+import {ActionLink} from "../lib/bootstrap-components";
+import axios from '../lib/axios';
+import styles from "../lib/styles.scss";
+import {getUrl} from "../lib/urls";
+import {CampaignType, CampaignSource} from "../../../shared/campaigns";
+import moment from 'moment';
+import {getMailerTypes} from "../send-configurations/helpers";
+
+
+@translate()
+@withForm
+@withPageHelpers
+@withErrorHandling
+@requiresAuthenticatedUser
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_');
+ this.mailerTypes = getMailerTypes(props.t);
+
+ this.state = {
+ showMergeTagReference: false,
+ elementInFullscreen: false,
+ };
+
+ this.initForm({
+ onChange: {
+ send_configuration: ::this.onSendConfigurationChanged
+ },
+ onChangeBeforeValidation: {
+ data_sourceCustom_type: ::this.onCustomTemplateTypeChanged
+ }
+ });
+ }
+
+ static propTypes = {
+ action: PropTypes.string.isRequired,
+ wizard: PropTypes.string,
+ entity: PropTypes.object,
+ type: PropTypes.number.isRequired
+ }
+
+ onCustomTemplateTypeChanged(mutState, key, oldType, type) {
+ if (type) {
+ this.templateTypes[type].afterTypeChange(mutState);
+ }
+ }
+
+ componentDidMount() {
+ if (this.props.entity) {
+ this.getFormValuesFromEntity(this.props.entity, data => {
+ if (data.source === CampaignSource.TEMPLATE || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ data.data_sourceTemplate = data.data.sourceTemplate;
+ } else {
+ data.data_sourceTemplate = null;
+ }
+
+ if (data.source === CampaignSource.CUSTOM || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ data.data_sourceCustom_type = data.data.source.type;
+ data.data_sourceCustom_data = data.data.source.data;
+ data.data_sourceCustom_html = data.data.source.html;
+ data.data_sourceCustom_text = data.data.source.text;
+
+ this.templateTypes[data.type].afterLoad(data);
+
+ } else {
+ data.data_sourceCustom_type = null;
+ data.data_sourceCustom_data = {};
+ data.data_sourceCustom_html = '';
+ data.data_sourceCustom_text = '';
+ }
+
+ if (data.source === CampaignSource.URL) {
+ data.data_sourceUrl = data.data.sourceUrl;
+ } else {
+ data.data_sourceUrl = null;
+ }
+
+ if (data.type === CampaignType.RSS) {
+ data.data_feedUrl = data.data.feedUrl;
+ } else {
+ data.data_feedUrl = '';
+ }
+
+ data.useSegmentation = !!data.segment;
+
+ this.fetchSendConfiguration(data.send_configuration);
+ });
+
+ } else {
+ this.populateFormValues({
+ type: this.props.type,
+
+ name: '',
+ description: '',
+ list: null,
+ segment: null,
+ useSegmentation: false,
+ send_configuration: null,
+ namespace: mailtrainConfig.user.namespace,
+ from_name_override: '',
+ from_name_overriden: false,
+ from_email_override: '',
+ from_email_overriden: false,
+ reply_to_override: '',
+ reply_to_overriden: false,
+ subject_override: '',
+ subject_overriden: false,
+ click_tracking_disabled: false,
+ open_trackings_disabled: false,
+
+ source: CampaignSource.TEMPLATE,
+
+ // This is for CampaignSource.TEMPLATE
+ data_sourceTemplate: null,
+
+ // This is for CampaignSource.CUSTOM
+ data_sourceCustom_type: null,
+ data_sourceCustom_data: {},
+ data_sourceCustom_html: '',
+ data_sourceCustom_text: '',
+
+ ...this.templateTypes[mailtrainConfig.editors[0]].initData(),
+
+ // This is for CampaignSource.URL
+ data_sourceUrl: '',
+
+ // This is for CampaignType.RSS
+ data_feedUrl: ''
+ });
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('Name must not be empty'));
+ } else {
+ state.setIn(['name', 'error'], null);
+ }
+
+ if (!state.getIn(['list', 'value'])) {
+ state.setIn(['list', 'error'], t('List must be selected'));
+ } else {
+ state.setIn(['list', 'error'], null);
+ }
+
+ if (state.getIn(['useSegmentation', 'value']) && !state.getIn(['segment', 'value'])) {
+ state.setIn(['segment', 'error'], t('Segment must be selected'));
+ } else {
+ state.setIn(['segment', 'error'], null);
+ }
+
+ if (state.getIn(['from_email_overriden', 'value']) && !state.getIn(['from_email_override', 'value'])) {
+ state.setIn(['from_email_override', 'error'], t('"From" email must not be empty'));
+ } else {
+ state.setIn(['from_email_override', 'error'], null);
+ }
+
+
+ const campaignTypeKey = state.getIn(['type', 'value']);
+
+ const sourceTypeKey = state.getIn(['source', 'value']);
+
+ if (sourceTypeKey === CampaignSource.TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ if (!state.getIn(['data_sourceTemplate', 'value'])) {
+ state.setIn(['data_sourceTemplate', 'error'], t('Template must be selected'));
+ } else {
+ state.setIn(['data_sourceTemplate', 'error'], null);
+ }
+
+ } else if (sourceTypeKey === CampaignSource.CUSTOM) {
+ // The type is used only in create form. In case of CUSTOM_FROM_TEMPLATE, it is determined by the source template, so no need to check it here
+ const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']);
+ if (!customTemplateTypeKey) {
+ state.setIn(['data_sourceCustom_type', 'error'], t('Type must be selected'));
+ } else {
+ state.setIn(['data_sourceCustom_type', 'error'], null);
+ }
+
+ if (customTemplateTypeKey) {
+ this.templateTypes[customTemplateTypeKey].validate(state);
+ }
+
+ } else if (sourceTypeKey === CampaignSource.URL) {
+ if (!state.getIn(['data_sourceUrl', 'value'])) {
+ state.setIn(['data_sourceUrl', 'error'], t('URL must not be empty'));
+ } else {
+ state.setIn(['data_sourceUrl', 'error'], null);
+ }
+ }
+
+ if (campaignTypeKey === CampaignType.RSS) {
+ if (!state.getIn(['data_feedUrl', 'value'])) {
+ state.setIn(['data_feedUrl', 'error'], t('RSS feed URL must be given'));
+ } else {
+ state.setIn(['data_feedUrl', 'error'], null);
+ }
+ }
+
+ validateNamespace(t, state);
+
+ }
+
+ async submitHandler() {
+ const t = this.props.t;
+
+ if (this.props.entity) {
+ const sourceTypeKey = this.getFormValue('source');
+ if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
+ await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
+ }
+ }
+
+ let sendMethod, url;
+ if (this.props.entity) {
+ sendMethod = FormSendMethod.PUT;
+ url = `rest/campaigns/${this.props.entity.id}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = 'rest/campaigns'
+ }
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('Saving ...'));
+
+ const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
+ if (!data.useSegmentation) {
+ data.segment = null;
+ }
+ delete data.useSegmentation;
+
+ data.data = {};
+ if (data.source === CampaignSource.TEMPLATE || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ data.data.sourceTemplate = data.data_sourceTemplate;
+ }
+
+ if (data.source === CampaignSource.CUSTOM || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ this.templateTypes[data.data_sourceCustom_type].beforeSave(data);
+
+ data.data.source = {
+ type: data.data_sourceCustom_type,
+ data: data.data_sourceCustom_data,
+ html: data.data_sourceCustom_html,
+ text: data.data_sourceCustom_text
+ }
+ }
+
+ if (data.source === CampaignSource.URL) {
+ data.data.sourceUrl = data.data_sourceUrl;
+ }
+
+ if (data.type === CampaignType.RSS) {
+ data.data.feedUrl = data.data_feedUrl;
+ }
+
+ for (const key in data) {
+ if (key.startsWith('data_')) {
+ delete data[key];
+ }
+ }
+ });
+
+ if (submitResponse) {
+ if (this.props.entity) {
+ this.navigateToWithFlashMessage('/campaigns', 'success', t('Campaign saved'));
+ } else {
+ this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/edit`, 'success', t('Campaign saved'));
+ }
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
+ }
+ }
+
+ async extractPlainText() {
+ const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
+ await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
+
+ const html = this.getFormValue('data_sourceCustom_html');
+ if (!html) {
+ return;
+ }
+
+ if (this.isFormDisabled()) {
+ return;
+ }
+
+ this.disableForm();
+
+ const response = await axios.post(getUrl('rest/html-to-text', { html }));
+
+ this.updateFormValue('data_sourceCustom_text', response.data.text);
+
+ this.enableForm();
+ }
+
+ async toggleMergeTagReference() {
+ this.setState({
+ showMergeTagReference: !this.state.showMergeTagReference
+ });
+ }
+
+ async setElementInFullscreen(elementInFullscreen) {
+ this.setState({
+ elementInFullscreen
+ });
+ }
+
+ render() {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+ const canDelete = isEdit && this.props.entity.permissions.includes('delete');
+ const useSaveAndEditLabel = !isEdit;
+
+ let templateEdit = null;
+ let extraSettings = null;
+
+ const sourceTypeKey = this.getFormValue('source');
+ const campaignTypeKey = this.getFormValue('type');
+
+ if (sourceTypeKey === CampaignSource.TEMPLATE || (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
+ const templatesColumns = [
+ { data: 1, title: t('Name') },
+ { data: 2, title: t('Description') },
+ { data: 3, title: t('Type'), render: data => this.templateTypes[data].typeName },
+ { data: 4, title: t('Created'), render: data => moment(data).fromNow() },
+ { data: 5, title: t('Namespace') },
+ ];
+
+ templateEdit = ;
+
+ } else if (sourceTypeKey === CampaignSource.CUSTOM || (isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
+ const customTemplateTypeOptions = [];
+ for (const key of mailtrainConfig.editors) {
+ customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName});
+ }
+
+ // TODO: Toggle HTML preview
+
+ const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
+
+ let customTemplateEditForm = null;
+ let customTemplateTypeForm = null;
+
+ if (customTemplateTypeKey) {
+ customTemplateTypeForm = getTypeForm(this, customTemplateTypeKey, isEdit);
+
+ if (isEdit) {
+ customTemplateEditForm = getEditForm(this, customTemplateTypeKey);
+ }
+ }
+
+ templateEdit =
+ {isEdit
+ ?
+
+ {customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName}
+
+ :
+
+ }
+
+ {customTemplateTypeForm}
+
+ {customTemplateEditForm}
+
;
+
+ } else if (sourceTypeKey === CampaignSource.URL) {
+ templateEdit =
+ }
+
+ if (campaignTypeKey === CampaignType.RSS) {
+ extraSettings =
+
+ }
+
+ const listsColumns = [
+ { data: 1, title: t('Name') },
+ { data: 2, title: t('ID'), render: data => {data}
},
+ { data: 3, title: t('Subscribers') },
+ { data: 4, title: t('Description') },
+ { data: 5, title: t('Namespace') }
+ ];
+
+ const segmentsColumns = [
+ { data: 1, title: t('Name') }
+ ];
+
+ const sendConfigurationsColumns = [
+ { data: 1, title: t('Name') },
+ { data: 2, title: t('Description') },
+ { data: 3, title: t('Type'), render: data => this.mailerTypes[data].typeName },
+ { data: 4, title: t('Created'), render: data => moment(data).fromNow() },
+ { data: 5, title: t('Namespace') }
+ ];
+
+ return (
+
+ {canDelete &&
+
+ }
+
+
{isEdit ? t('Edit Campaign') : t('Create Campaign')}
+
+
+
+ );
+ }
+}
diff --git a/client/src/campaigns/List.js b/client/src/campaigns/List.js
new file mode 100644
index 00000000..969e6326
--- /dev/null
+++ b/client/src/campaigns/List.js
@@ -0,0 +1,144 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {translate} from 'react-i18next';
+import {Icon} from '../lib/bootstrap-components';
+import {
+ NavButton,
+ requiresAuthenticatedUser,
+ Title,
+ Toolbar,
+ withPageHelpers
+} from '../lib/page';
+import {
+ withAsyncErrorHandler,
+ withErrorHandling
+} from '../lib/error-handling';
+import {Table} from '../lib/table';
+import moment from 'moment';
+import {
+ CampaignSource,
+ CampaignStatus,
+ CampaignType
+} from "../../../shared/campaigns";
+import {checkPermissions} from "../lib/permissions";
+
+@translate()
+@withPageHelpers
+@withErrorHandling
+@requiresAuthenticatedUser
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ const t = props.t;
+
+ this.campaignStatuses = {
+ [CampaignStatus.IDLE]: t('Idle'),
+ [CampaignStatus.FINISHED]: t('Finished'),
+ [CampaignStatus.PAUSED]: t('Paused'),
+ [CampaignStatus.INACTIVE]: t('Inactive'),
+ [CampaignStatus.ACTIVE]: t('Active')
+ };
+
+ this.campaignTypes = {
+ [CampaignType.REGULAR]: t('Regular'),
+ [CampaignType.TRIGGERED]: t('Triggered'),
+ [CampaignType.RSS]: t('RSS')
+ };
+
+ this.state = {};
+ }
+
+ @withAsyncErrorHandler
+ async fetchPermissions() {
+ const result = await checkPermissions({
+ createCampaign: {
+ entityTypeId: 'namespace',
+ requiredOperations: ['createCampaign']
+ }
+ });
+
+ this.setState({
+ createPermitted: result.data.createCampaign
+ });
+ }
+
+ componentDidMount() {
+ this.fetchPermissions();
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const columns = [
+ { data: 1, title: t('Name') },
+ { data: 2, title: t('Description') },
+ { data: 3, title: t('Type'), render: data => this.campaignTypes[data] },
+ {
+ data: 4,
+ title: t('Status'),
+ render: (data, display, rowData) => {
+ if (data === CampaignStatus.SCHEDULED) {
+ const scheduled = rowData[5];
+ if (scheduled && new Date(scheduled) > new Date()) {
+ return t('Sending scheduled');
+ } else {
+ return t('Sending');
+ }
+ } else {
+ return this.campaignStatuses[data];
+ }
+ }
+ },
+ { data: 7, title: t('Created'), render: data => moment(data).fromNow() },
+ { data: 8, title: t('Namespace') },
+ {
+ actions: data => {
+ const actions = [];
+ const perms = data[9];
+ const campaignSource = data[6];
+
+ if (perms.includes('edit')) {
+ actions.push({
+ label: ,
+ link: `/campaigns/${data[0]}/edit`
+ });
+ }
+
+ if (perms.includes('manageFiles') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
+ actions.push({
+ label: ,
+ link: `/campaigns/${data[0]}/files`
+ });
+ }
+
+ // FIXME: add attachments
+
+ if (perms.includes('share')) {
+ actions.push({
+ label: ,
+ link: `/campaigns/${data[0]}/share`
+ });
+ }
+
+ return actions;
+ }
+ }
+ ];
+
+ return (
+
+
+ {this.state.createPermitted &&
+
+ }
+
+
+
{t('Campaigns')}
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/campaigns/root.js b/client/src/campaigns/root.js
new file mode 100644
index 00000000..90d3839f
--- /dev/null
+++ b/client/src/campaigns/root.js
@@ -0,0 +1,66 @@
+'use strict';
+
+import React from 'react';
+
+import CampaignsCUD from './CUD';
+import CampaignsList from './List';
+import Share from '../shares/Share';
+import Files from "../lib/files";
+import {CampaignSource, CampaignType} from "../../../shared/campaigns";
+
+
+function getMenus(t) {
+ return {
+ 'campaigns': {
+ title: t('Campaigns'),
+ link: '/campaigns',
+ panelComponent: CampaignsList,
+ children: {
+ ':campaignId([0-9]+)': {
+ title: resolved => t('Campaign "{{name}}"', {name: resolved.campaign.name}),
+ resolve: {
+ campaign: params => `rest/campaigns/${params.campaignId}`
+ },
+ link: params => `/campaigns/${params.campaignId}/edit`,
+ navs: {
+ ':action(edit|delete)': {
+ title: t('Edit'),
+ link: params => `/campaigns/${params.campaignId}/edit`,
+ visible: resolved => resolved.campaign.permissions.includes('edit'),
+ panelRender: props =>
+ },
+ files: {
+ title: t('Files'),
+ link: params => `/campaigns/${params.campaignId}/files`,
+ visible: resolved => resolved.campaign.permissions.includes('edit') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE),
+ panelRender: props =>
+ },
+ // FIXME: add attachments
+ share: {
+ title: t('Share'),
+ link: params => `/campaigns/${params.campaignId}/share`,
+ visible: resolved => resolved.campaign.permissions.includes('share'),
+ panelRender: props =>
+ }
+ }
+ },
+ 'create-regular': {
+ title: t('Create Regular Campaign'),
+ panelRender: props =>
+ },
+ 'create-rss': {
+ title: t('Create RSS Campaign'),
+ panelRender: props =>
+ },
+ 'create-triggered': {
+ title: t('Create Triggered Campaign'),
+ panelRender: props =>
+ }
+ }
+ }
+ };
+}
+
+export default {
+ getMenus
+}
diff --git a/client/src/lists/subscriptions/List.js b/client/src/lists/subscriptions/List.js
index daa2b9cf..b5cfd266 100644
--- a/client/src/lists/subscriptions/List.js
+++ b/client/src/lists/subscriptions/List.js
@@ -151,6 +151,8 @@ export default class List extends Component {
dataUrl += '/' + this.props.segmentId;
}
+
+ // FIXME - presents segments in a data table as in campaign edit
return (
diff --git a/client/src/root.js b/client/src/root.js
index be71ffb1..d027e52c 100644
--- a/client/src/root.js
+++ b/client/src/root.js
@@ -15,6 +15,7 @@ import blacklist from './blacklist/root';
import lists from './lists/root';
import namespaces from './namespaces/root';
import reports from './reports/root';
+import campaigns from './campaigns/root';
import templates from './templates/root';
import users from './users/root';
import sendConfigurations from './send-configurations/root';
@@ -45,7 +46,7 @@ class Root extends Component {
const t = props.t;
const self = this;
- const topLevelMenuKeys = ['lists', 'templates', 'reports'];
+ const topLevelMenuKeys = ['lists', 'templates', 'campaigns', 'reports'];
class MainMenu extends Component {
render() {
@@ -123,7 +124,8 @@ class Root extends Component {
...blacklist.getMenus(t),
...account.getMenus(t),
...settings.getMenus(t),
- ...sendConfigurations.getMenus(t)
+ ...sendConfigurations.getMenus(t),
+ ...campaigns.getMenus(t)
}
}
};
diff --git a/client/src/send-configurations/root.js b/client/src/send-configurations/root.js
index c08c1490..a42f0c27 100644
--- a/client/src/send-configurations/root.js
+++ b/client/src/send-configurations/root.js
@@ -17,7 +17,7 @@ function getMenus(t) {
':sendConfigurationId([0-9]+)': {
title: resolved => t('Template "{{name}}"', {name: resolved.sendConfiguration.name}),
resolve: {
- sendConfiguration: params => `rest/send-configurations/${params.sendConfigurationId}`
+ sendConfiguration: params => `rest/send-configurations-private/${params.sendConfigurationId}`
},
link: params => `/send-configurations/${params.sendConfigurationId}/edit`,
navs: {
diff --git a/client/src/templates/CUD.js b/client/src/templates/CUD.js
index 4785eb63..0497cbc7 100644
--- a/client/src/templates/CUD.js
+++ b/client/src/templates/CUD.js
@@ -2,10 +2,7 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
-import {
- Trans,
- translate
-} from 'react-i18next';
+import {translate} from 'react-i18next';
import {
NavButton,
requiresAuthenticatedUser,
@@ -13,8 +10,6 @@ import {
withPageHelpers
} from '../lib/page'
import {
- ACEEditor,
- AlignedRow,
Button,
ButtonRow,
Dropdown,
@@ -32,8 +27,11 @@ import {
} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
-import {getTemplateTypes} from './helpers';
-import {ActionLink} from "../lib/bootstrap-components";
+import {
+ getEditForm,
+ getTemplateTypes,
+ getTypeForm
+} from './helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
@@ -158,7 +156,6 @@ export default class CUD extends Component {
const html = this.getFormValue('html');
if (!html) {
- alert('Missing HTML content');
return;
}
@@ -203,101 +200,15 @@ export default class CUD extends Component {
let editForm = null;
if (isEdit && typeKey) {
- editForm =
-
-
- {this.state.showMergeTagReference &&
-
-
Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: [TAG_NAME]
or [TAG_NAME/fallback]
where fallback
is an optional text value used when TAG_NAME
is empty.
-
You can use any of the standard merge tags below. In addition to that every custom field has its own merge tag. Check the fields of the list you are going to send to.
-
-
-
-
- Merge tag
-
-
- Description
-
-
-
-
-
-
- [LINK_UNSUBSCRIBE]
-
-
- URL that points to the unsubscribe page
-
-
-
-
- [LINK_PREFERENCES]
-
-
- URL that points to the preferences page of the subscriber
-
-
-
-
- [LINK_BROWSER]
-
-
- URL to preview the message in a browser
-
-
-
-
- [EMAIL]
-
-
- Email address
-
-
-
-
- [SUBSCRIPTION_ID]
-
-
- Unique ID that identifies the recipient
-
-
-
-
- [LIST_ID]
-
-
- Unique ID that identifies the list used for this campaign
-
-
-
-
- [CAMPAIGN_ID]
-
-
- Unique ID that identifies current campaign
-
-
-
-
-
}
-
-
- {this.templateTypes[typeKey].getHTMLEditor(this)}
-
-
To extract the text from HTML click here . Please note that your existing plaintext in the field above will be overwritten. This feature uses the Premailer API , a third party service. Their Terms of Service and Privacy Policy apply.}/>
-
+ editForm = getEditForm(this, typeKey);
}
let typeForm = null;
if (typeKey) {
- typeForm =
- {this.templateTypes[typeKey].getTypeForm(this, isEdit)}
-
;
+ typeForm = getTypeForm(this, typeKey, isEdit);
}
-
return (
{canDelete &&
diff --git a/client/src/templates/List.js b/client/src/templates/List.js
index 94b2b93a..77635baf 100644
--- a/client/src/templates/List.js
+++ b/client/src/templates/List.js
@@ -1,14 +1,22 @@
'use strict';
-import React, { Component } from 'react';
-import { translate } from 'react-i18next';
-import { Icon } from '../lib/bootstrap-components';
-import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
-import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
-import { Table } from '../lib/table';
-import axios from '../lib/axios';
+import React, {Component} from 'react';
+import {translate} from 'react-i18next';
+import {Icon} from '../lib/bootstrap-components';
+import {
+ NavButton,
+ requiresAuthenticatedUser,
+ Title,
+ Toolbar,
+ withPageHelpers
+} from '../lib/page';
+import {
+ withAsyncErrorHandler,
+ withErrorHandling
+} from '../lib/error-handling';
+import {Table} from '../lib/table';
import moment from 'moment';
-import { getTemplateTypes } from './helpers';
+import {getTemplateTypes} from './helpers';
import {checkPermissions} from "../lib/permissions";
@translate()
diff --git a/client/src/templates/helpers.js b/client/src/templates/helpers.js
index a28232e3..7e996040 100644
--- a/client/src/templates/helpers.js
+++ b/client/src/templates/helpers.js
@@ -17,19 +17,20 @@ import {
} from "../lib/mosaico";
import {getTemplateTypes as getMosaicoTemplateTypes} from './mosaico/helpers';
-import {getTrustedUrl, getSandboxUrl} from "../lib/urls";
+import {getSandboxUrl} from "../lib/urls";
import mailtrainConfig from 'mailtrainConfig';
-export function getTemplateTypes(t) {
+export function getTemplateTypes(t, prefix = '') {
+ // The prefix is used to to enable use within other forms (i.e. campaign form)
const templateTypes = {};
function initFieldsIfMissing(mutState, templateType) {
const initVals = templateTypes[templateType].initData();
for (const key in initVals) {
- if (!mutState.hasIn([key])) {
- mutState.setIn([key, 'value'], initVals[key]);
+ if (!mutState.hasIn([prefix + key])) {
+ mutState.setIn([prefix + key, 'value'], initVals[key]);
}
}
}
@@ -55,43 +56,43 @@ export function getTemplateTypes(t) {
templateTypes.mosaico = {
typeName: t('Mosaico'),
getTypeForm: (owner, isEdit) =>
-
,
+
,
getHTMLEditor: owner =>
owner.editorNode = node}
entity={owner.props.entity}
- initialModel={owner.getFormValue('mosaicoData').model}
- initialMetadata={owner.getFormValue('mosaicoData').metadata}
- templatePath={getSandboxUrl(`mosaico/templates/${owner.getFormValue('mosaicoTemplate')}/index.html`)}
+ initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
+ initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
+ templatePath={getSandboxUrl(`mosaico/templates/${owner.getFormValue(prefix + 'mosaicoTemplate')}/index.html`)}
entityTypeId={ResourceType.TEMPLATE}
title={t('Mosaico Template Designer')}
onFullscreenAsync={::owner.setElementInFullscreen}/>
,
exportHTMLEditorData: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState();
- owner.updateFormValue('html', html);
- owner.updateFormValue('mosaicoData', {
+ owner.updateFormValue(prefix + 'html', html);
+ owner.updateFormValue(prefix + 'mosaicoData', {
metadata,
model
});
},
initData: () => ({
- mosaicoTemplate: '',
- mosaicoData: {}
+ [prefix + 'mosaicoTemplate']: '',
+ [prefix + 'mosaicoData']: {}
}),
afterLoad: data => {
- data.mosaicoTemplate = data.data.mosaicoTemplate;
- data.mosaicoData = {
- metadata: data.data.metadata,
- model: data.data.model
+ data[prefix + 'mosaicoTemplate'] = data[prefix + 'data'].mosaicoTemplate;
+ data[prefix + 'mosaicoData'] = {
+ metadata: data[prefix + 'data'].metadata,
+ model: data[prefix + 'data'].model
};
},
beforeSave: data => {
- data.data = {
- mosaicoTemplate: data.mosaicoTemplate,
- metadata: data.mosaicoData.metadata,
- model: data.mosaicoData.model
+ data[prefix + 'data'] = {
+ mosaicoTemplate: data[prefix + 'mosaicoTemplate'],
+ metadata: data[prefix + 'mosaicoData'].metadata,
+ model: data[prefix + 'mosaicoData'].model
};
clearBeforeSave(data);
},
@@ -99,11 +100,11 @@ export function getTemplateTypes(t) {
initFieldsIfMissing(mutState, 'mosaico');
},
validate: state => {
- const mosaicoTemplate = state.getIn(['mosaicoTemplate', 'value']);
+ const mosaicoTemplate = state.getIn([prefix + 'mosaicoTemplate', 'value']);
if (!mosaicoTemplate) {
- state.setIn(['mosaicoTemplate', 'error'], t('Mosaico template must be selected'));
+ state.setIn([prefix + 'mosaicoTemplate', 'error'], t('Mosaico template must be selected'));
} else {
- state.setIn(['mosaicoTemplate', 'error'], null);
+ state.setIn([prefix + 'mosaicoTemplate', 'error'], null);
}
}
};
@@ -113,23 +114,23 @@ export function getTemplateTypes(t) {
templateTypes.mosaicoWithFsTemplate = {
typeName: t('Mosaico with predefined templates'),
getTypeForm: (owner, isEdit) =>
-
,
+ ,
getHTMLEditor: owner =>
owner.editorNode = node}
entity={owner.props.entity}
- initialModel={owner.getFormValue('mosaicoData').model}
- initialMetadata={owner.getFormValue('mosaicoData').metadata}
- templatePath={getSandboxUrl(`public/mosaico/templates/${owner.getFormValue('mosaicoFsTemplate')}/index.html`)}
+ initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
+ initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
+ templatePath={getSandboxUrl(`public/mosaico/templates/${owner.getFormValue(prefix + 'mosaicoFsTemplate')}/index.html`)}
entityTypeId={ResourceType.TEMPLATE}
title={t('Mosaico Template Designer')}
onFullscreenAsync={::owner.setElementInFullscreen}/>
,
exportHTMLEditorData: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState();
- owner.updateFormValue('html', html);
- owner.updateFormValue('mosaicoData', {
+ owner.updateFormValue(prefix + 'html', html);
+ owner.updateFormValue(prefix + 'mosaicoData', {
metadata,
model
});
@@ -139,17 +140,17 @@ export function getTemplateTypes(t) {
mosaicoData: {}
}),
afterLoad: data => {
- data.mosaicoFsTemplate = data.data.mosaicoFsTemplate;
- data.mosaicoData = {
- metadata: data.data.metadata,
- model: data.data.model
+ data['mosaicoFsTemplate'] = data[prefix + 'data'].mosaicoFsTemplate;
+ data[prefix + 'mosaicoData'] = {
+ metadata: data[prefix + 'data'].metadata,
+ model: data[prefix + 'data'].model
};
},
beforeSave: data => {
- data.data = {
- mosaicoFsTemplate: data.mosaicoFsTemplate,
- metadata: data.mosaicoData.metadata,
- model: data.mosaicoData.model
+ data[prefix + 'data'] = {
+ mosaicoFsTemplate: data[prefix + 'mosaicoFsTemplate'],
+ metadata: data[prefix + 'mosaicoData'].metadata,
+ model: data[prefix + 'mosaicoData'].model
};
clearBeforeSave(data);
},
@@ -176,7 +177,7 @@ export function getTemplateTypes(t) {
templateTypes.ckeditor = {
typeName: t('CKEditor'),
getTypeForm: (owner, isEdit) => null,
- getHTMLEditor: owner => ,
+ getHTMLEditor: owner => ,
exportHTMLEditorData: async owner => {},
initData: () => ({}),
afterLoad: data => {},
@@ -190,7 +191,7 @@ export function getTemplateTypes(t) {
templateTypes.codeeditor = {
typeName: t('Code Editor'),
getTypeForm: (owner, isEdit) => null,
- getHTMLEditor: owner => ,
+ getHTMLEditor: owner => ,
exportHTMLEditorData: async owner => {},
initData: () => ({}),
afterLoad: data => {},
@@ -215,4 +216,101 @@ export function getTemplateTypes(t) {
};
return templateTypes;
-}
\ No newline at end of file
+}
+
+
+export function getEditForm(owner, typeKey, prefix = '') {
+ const t = owner.props.t;
+
+ return
+
+
+ {owner.state.showMergeTagReference &&
+
+
Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: [TAG_NAME]
or [TAG_NAME/fallback]
where fallback
is an optional text value used when TAG_NAME
is empty.
+
You can use any of the standard merge tags below. In addition to that every custom field has its own merge tag. Check the fields of the list you are going to send to.
+
+
+
+
+ Merge tag
+
+
+ Description
+
+
+
+
+
+
+ [LINK_UNSUBSCRIBE]
+
+
+ URL that points to the unsubscribe page
+
+
+
+
+ [LINK_PREFERENCES]
+
+
+ URL that points to the preferences page of the subscriber
+
+
+
+
+ [LINK_BROWSER]
+
+
+ URL to preview the message in a browser
+
+
+
+
+ [EMAIL]
+
+
+ Email address
+
+
+
+
+ [SUBSCRIPTION_ID]
+
+
+ Unique ID that identifies the recipient
+
+
+
+
+ [LIST_ID]
+
+
+ Unique ID that identifies the list used for this campaign
+
+
+
+
+ [CAMPAIGN_ID]
+
+
+ Unique ID that identifies current campaign
+
+
+
+
+
}
+
+
+ {owner.templateTypes[typeKey].getHTMLEditor(owner)}
+
+
To extract the text from HTML click here . Please note that your existing plaintext in the field above will be overwritten. This feature uses the Premailer API , a third party service. Their Terms of Service and Privacy Policy apply.}/>
+ ;
+}
+
+export function getTypeForm(owner, typeKey, isEdit) {
+ return
+ {owner.templateTypes[typeKey].getTypeForm(this, isEdit)}
+
;
+}
+
diff --git a/config/default.toml b/config/default.toml
index ebf48fe3..ab91fb70 100644
--- a/config/default.toml
+++ b/config/default.toml
@@ -202,28 +202,28 @@ rootNamespaceRole="master"
[roles.namespace.master]
name="Master"
description="All permissions"
-permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "manageUsers"]
+permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "createCampaign", "manageUsers"]
[roles.namespace.master.children]
-sendConfiguration=["view", "edit", "delete", "share", "send", "overrideAllowed", "overrideAll"]
-list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"]
+sendConfiguration=["viewPublic", "viewPrivate", "edit", "delete", "share", "sendWithoutOverrides", "sendWithAllowedOverrides", "sendWithAnyOverrides"]
+list=["view", "edit", "delete", "share", "viewFields", "manageFields", "viewSubscriptions", "manageSubscriptions", "viewSegments", "manageSegments"]
customForm=["view", "edit", "delete", "share"]
-campaign=["view", "edit", "delete", "share", "manageFiles"]
-template=["view", "edit", "delete", "share", "manageFiles"]
+campaign=["view", "edit", "delete", "share", "viewFiles", "manageFiles", "manageAttachments", "send", "viewStats"]
+template=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
reportTemplate=["view", "edit", "delete", "share", "execute"]
-mosaicoTemplate=["view", "edit", "delete", "share", "manageFiles"]
-namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "manageUsers"]
+mosaicoTemplate=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
+namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "createCampaign", "manageUsers"]
[roles.sendConfiguration.master]
name="Master"
description="All permissions"
-permissions=["view", "edit", "delete", "share", "send", "overrideAllowed", "overrideAll"]
+permissions=["viewPublic", "viewPrivate", "edit", "delete", "share", "sendWithoutOverrides", "sendWithAllowedOverrides", "sendWithAnyOverrides"]
[roles.list.master]
name="Master"
description="All permissions"
-permissions=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"]
+permissions=["view", "edit", "delete", "share", "viewFields", "manageFields", "viewSubscriptions", "manageSubscriptions", "viewSegments", "manageSegments"]
[roles.customForm.master]
name="Master"
@@ -233,12 +233,12 @@ permissions=["view", "edit", "delete", "share"]
[roles.campaign.master]
name="Master"
description="All permissions"
-permissions=["view", "edit", "delete", "share", "manageFiles"]
+permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles", "manageAttachments", "send", "viewStats"]
[roles.template.master]
name="Master"
description="All permissions"
-permissions=["view", "edit", "delete", "share", "manageFiles"]
+permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
[roles.report.master]
name="Master"
@@ -253,7 +253,8 @@ permissions=["view", "edit", "delete", "share", "execute"]
[roles.mosaicoTemplate.master]
name="Master"
description="All permissions"
-permissions=["view", "edit", "delete", "share", "manageFiles"]
+permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
+
[roles.global.editor]
@@ -317,3 +318,4 @@ permissions=[]
name="Editor"
description="XXX"
permissions=[]
+
diff --git a/lib/mailers.js b/lib/mailers.js
index 7d007a03..8e7d77d7 100644
--- a/lib/mailers.js
+++ b/lib/mailers.js
@@ -41,7 +41,7 @@ async function getOrCreateMailer(sendConfigurationId) {
if (!sendConfiguration) {
sendConfiguration = await sendConfigurations.getSystemSendConfiguration();
} else {
- sendConfiguration = await sendConfigurations.getById(contextHelpers.getAdminContext(), sendConfigurationId, false);
+ sendConfiguration = await sendConfigurations.getById(contextHelpers.getAdminContext(), sendConfigurationId, false, true);
}
const transport = transports.get(sendConfiguration.id) || await _createTransport(sendConfiguration);
diff --git a/models/campaigns.js b/models/campaigns.js
index 75ba996b..86375b20 100644
--- a/models/campaigns.js
+++ b/models/campaigns.js
@@ -3,18 +3,28 @@
const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
+const shortid = require('shortid');
const shares = require('./shares');
+const files = require('./files');
+const { CampaignSource, CampaignType} = require('../shared/campaigns');
+const segments = require('./segments');
-async function listDTAjax(params) {
+const allowedKeysCommon = ['name', 'description', 'list', 'segment', 'namespace',
+ 'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override',
+ 'source', 'data', 'click_tracking_disabled', 'open_tracking_disabled'];
+
+const allowedKeysCreate = new Set(['type', ...allowedKeysCommon]);
+const allowedKeysUpdate = new Set([...allowedKeysCommon]);
+
+async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
params,
builder => builder.from('campaigns')
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace'),
- ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.status', 'campaigns.created']
+ ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.type', 'campaigns.status', 'campaigns.scheduled', 'campaigns.source', 'campaigns.created', 'namespaces.name']
);
-
}
async function getById(context, id) {
@@ -26,6 +36,122 @@ async function getById(context, id) {
});
}
+async function _validateAndPreprocess(tx, context, entity, isCreate) {
+ await namespaceHelpers.validateEntity(tx, entity);
+
+ if (isCreate) {
+ enforce(entity.type === CampaignType.REGULAR && entity.type === CampaignType.RSS && entity.type === CampaignType.TRIGGERED, 'Unknown campaign type');
+ }
+
+ enforce(entity.source >= CampaignSource.MIN && entity.source <= CampaignSource.MAX, 'Unknown campaign source');
+
+ await shares.enforceEntityPermissionTx(tx, context, 'list', entity.list, 'view');
+
+ if (entity.segment) {
+ // Check that the segment under the list exists
+ await segments.getByIdTx(tx, context, entity.list, entity.segment);
+ }
+
+ if (entity.source === CampaignSource.TEMPLATE || (isCreate && entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
+ await shares.enforceEntityPermissionTx(tx, context, 'template', entity.data.sourceTemplate, 'view');
+ }
+
+ entity.data = JSON.stringify(data);
+}
+
+async function create(context, entity) {
+ return await knex.transaction(async tx => {
+ await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCampaign');
+
+ let copyFilesFromTemplateId;
+ if (entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ copyFilesFromTemplateId = entity.data.sourceTemplate;
+ }
+
+ await _validateAndPreprocess(tx, context, entity, true);
+
+ const filteredEntity = filterObject(entity, allowedKeysCreate);
+ filteredEntity.cid = shortid.generate();
+
+ const ids = await tx('campaigns').insert(filteredEntity);
+ const id = ids[0];
+
+ await knex.schema.raw('CREATE TABLE `campaign__' + id + '` (\n' +
+ ' `id` int(10) unsigned NOT NULL AUTO_INCREMENT,\n' +
+ ' `list` int(10) unsigned NOT NULL,\n' +
+ ' `segment` int(10) unsigned NOT NULL,\n' +
+ ' `subscription` int(10) unsigned NOT NULL,\n' +
+ ' `status` tinyint(4) unsigned NOT NULL DEFAULT \'0\',\n' +
+ ' `response` varchar(255) DEFAULT NULL,\n' +
+ ' `response_id` varchar(255) CHARACTER SET ascii DEFAULT NULL,\n' +
+ ' `updated` timestamp NULL DEFAULT NULL,\n' +
+ ' `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n' +
+ ' PRIMARY KEY (`id`),\n' +
+ ' UNIQUE KEY `list` (`list`,`segment`,`subscription`),\n' +
+ ' KEY `created` (`created`),\n' +
+ ' KEY `response_id` (`response_id`),\n' +
+ ' KEY `status_index` (`status`),\n' +
+ ' KEY `subscription_index` (`subscription`)\n' +
+ ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n');
+
+ await knex.schema.raw('CREATE TABLE `campaign__tracker' + id + '` (\n' +
+ ' `list` int(10) unsigned NOT NULL,\n' +
+ ' `subscriber` int(10) unsigned NOT NULL,\n' +
+ ' `link` int(10) NOT NULL,\n' +
+ ' `ip` varchar(100) CHARACTER SET ascii DEFAULT NULL,\n' +
+ ' `device_type` varchar(50) DEFAULT NULL,\n' +
+ ' `country` varchar(2) CHARACTER SET ascii DEFAULT NULL,\n' +
+ ' `count` int(11) unsigned NOT NULL DEFAULT \'1\',\n' +
+ ' `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n' +
+ ' PRIMARY KEY (`list`,`subscriber`,`link`),\n' +
+ ' KEY `created_index` (`created`)\n' +
+ ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n');
+
+ await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: id });
+
+ if (copyFilesFromTemplateId) {
+ files.copyAllTx(tx, context, 'template', copyFilesFromTemplateId, 'campaign', id);
+ }
+
+ return id;
+ });
+}
+
+async function updateWithConsistencyCheck(context, entity) {
+ await knex.transaction(async tx => {
+ await shares.enforceEntityPermissionTx(tx, context, 'campaign', entity.id, 'edit');
+
+ const existing = await tx('campaigns').where('id', entity.id).first();
+ if (!existing) {
+ throw new interoperableErrors.NotFoundError();
+ }
+
+ const existingHash = hash(existing);
+ if (existingHash !== entity.originalHash) {
+ throw new interoperableErrors.ChangedError();
+ }
+
+ await _validateAndPreprocess(tx, context, entity, false);
+
+ await namespaceHelpers.validateMove(context, entity, existing, 'campaign', 'createCampaign', 'delete');
+
+ await tx('campaigns').where('id', entity.id).update(filterObject(entity, allowedKeysUpdate));
+
+ await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: entity.id });
+ });
+}
+
+async function remove(context, id) {
+ await knex.transaction(async tx => {
+ await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'delete');
+
+ await tx('campaigns').where('id', id).del();
+ await knex.schema.dropTableIfExists('campaign__' + id);
+ await knex.schema.dropTableIfExists('campaign_tracker__' + id);
+ });
+}
+
+
module.exports = {
listDTAjax,
getById
diff --git a/models/fields.js b/models/fields.js
index 3a6240e4..47bb63cf 100644
--- a/models/fields.js
+++ b/models/fields.js
@@ -200,7 +200,7 @@ function hash(entity) {
async function getById(context, listId, id) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields');
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewFields');
const entity = await tx('custom_fields').where({list: listId, id}).first();
@@ -235,7 +235,7 @@ async function listTx(tx, listId) {
async function list(context, listId) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageFields', 'manageSegments', 'manageSubscriptions']);
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewFields']);
return await listTx(tx, listId);
});
}
@@ -286,7 +286,7 @@ async function listByOrderListTx(tx, listId, extraColumns = []) {
async function listDTAjax(context, listId, params) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields');
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewFields');
return await dtHelpers.ajaxListTx(
tx,
@@ -330,7 +330,7 @@ async function listDTAjax(context, listId, params) {
async function listGroupedDTAjax(context, listId, params) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields');
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewFields');
return await dtHelpers.ajaxListTx(
tx,
diff --git a/models/files.js b/models/files.js
index 6e2a77f2..0e6c80e8 100644
--- a/models/files.js
+++ b/models/files.js
@@ -32,7 +32,7 @@ function getFilesTable(type) {
async function listDTAjax(context, type, entityId, params) {
enforceTypePermitted(type);
- await shares.enforceEntityPermission(context, type, entityId, 'manageFiles');
+ await shares.enforceEntityPermission(context, type, entityId, 'viewFiles');
return await dtHelpers.ajaxList(
params,
builder => builder.from(getFilesTable(type)).where({entity: entityId}),
@@ -41,8 +41,9 @@ async function listDTAjax(context, type, entityId, params) {
}
async function list(context, type, entityId) {
+ enforceTypePermitted(type);
return await knex.transaction(async tx => {
- await shares.enforceEntityPermission(context, type, entityId, 'view');
+ await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'viewFiles');
return await tx(getFilesTable(type)).where({entity: entityId}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc');
});
}
@@ -51,7 +52,7 @@ async function getFileById(context, type, id) {
enforceTypePermitted(type);
const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type)).where('id', id).first();
- await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'view');
+ await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'viewFiles');
return file;
});
@@ -69,7 +70,7 @@ async function getFileById(context, type, id) {
async function _getFileBy(context, type, entityId, key, value) {
enforceTypePermitted(type);
const file = await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view');
+ await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'viewFiles');
const file = await tx(getFilesTable(type)).where({entity: entityId, [key]: value}).first();
return file;
});
@@ -212,6 +213,8 @@ async function createFiles(context, type, entityId, files, getUrl = null, dontRe
}
async function removeFile(context, type, id) {
+ enforceTypePermitted(type);
+
const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type)).where('id', id).select('entity', 'filename').first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles');
@@ -223,6 +226,27 @@ async function removeFile(context, type, id) {
await fs.removeAsync(filePath);
}
+async function copyAllTx(tx, context, fromType, fromEntityId, toType, toEntityId) {
+ enforceTypePermitted(fromType);
+ await shares.enforceEntityPermissionTx(tx, context, fromType, fromEntityId, 'viewFiles');
+
+ enforceTypePermitted(toType);
+ await shares.enforceEntityPermissionTx(tx, context, toType, toEntityId, 'manageFiles');
+
+ const rows = await tx(getFilesTable(fromType)).where({entity: fromEntityId});
+ for (const row of rows) {
+ const fromFilePath = getFilePath(fromType, fromEntityId, row.filename);
+ const toFilePath = getFilePath(toType, toEntityId, row.filename);
+ await fs.copyAsync(fromFilePath, toFilePath, {});
+
+ delete row.id;
+ row.entity = toEntityId;
+ }
+
+ await tx(getFilesTable(toType)).insert(rows);
+}
+
+
module.exports = {
filesDir,
listDTAjax,
@@ -234,5 +258,6 @@ module.exports = {
createFiles,
removeFile,
getFileUrl,
- getFilePath
+ getFilePath,
+ copyAllTx
};
\ No newline at end of file
diff --git a/models/lists.js b/models/lists.js
index c46e9fc2..b445c23c 100644
--- a/models/lists.js
+++ b/models/lists.js
@@ -66,12 +66,16 @@ async function getByCid(context, cid) {
});
}
+async function _validateAndPreprocess(tx, entity) {
+ await namespaceHelpers.validateEntity(tx, entity);
+ enforce(entity.unsubscription_mode >= UnsubscriptionMode.MIN && entity.unsubscription_mode <= UnsubscriptionMode.MAX, 'Unknown unsubscription mode');
+}
+
async function create(context, entity) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createList');
- await namespaceHelpers.validateEntity(tx, entity);
- enforce(entity.unsubscription_mode >= 0 && entity.unsubscription_mode < UnsubscriptionMode.MAX, 'Unknown unsubscription mode');
+ await _validateAndPreprocess(tx, entity);
const filteredEntity = filterObject(entity, allowedKeys);
filteredEntity.cid = shortid.generate();
@@ -79,9 +83,34 @@ async function create(context, entity) {
const ids = await tx('lists').insert(filteredEntity);
const id = ids[0];
- await knex.schema.raw('CREATE TABLE `subscription__' + id + '` LIKE subscription');
-
- await shares.rebuildPermissionsTx(tx, { entityTypeId: 'list', entityId: id });
+ await knex.schema.raw('CREATE TABLE `subscription__' + id + '` (\n' +
+ ' `id` int(10) unsigned NOT NULL AUTO_INCREMENT,\n' +
+ ' `cid` varchar(255) CHARACTER SET ascii NOT NULL,\n' +
+ ' `email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT \'\',\n' +
+ ' `opt_in_ip` varchar(100) DEFAULT NULL,\n' +
+ ' `opt_in_country` varchar(2) DEFAULT NULL,\n' +
+ ' `tz` varchar(100) CHARACTER SET ascii DEFAULT NULL,\n' +
+ ' `imported` int(11) unsigned DEFAULT NULL,\n' +
+ ' `status` tinyint(4) unsigned NOT NULL DEFAULT \'1\',\n' +
+ ' `is_test` tinyint(4) unsigned NOT NULL DEFAULT \'0\',\n' +
+ ' `status_change` timestamp NULL DEFAULT NULL,\n' +
+ ' `latest_open` timestamp NULL DEFAULT NULL,\n' +
+ ' `latest_click` timestamp NULL DEFAULT NULL,\n' +
+ ' `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n' +
+ ' `first_name` varchar(255) DEFAULT NULL,\n' +
+ ' `last_name` varchar(255) DEFAULT NULL,\n' +
+ ' PRIMARY KEY (`id`),\n' +
+ ' UNIQUE KEY `email` (`email`),\n' +
+ ' UNIQUE KEY `cid` (`cid`),\n' +
+ ' KEY `status` (`status`),\n' +
+ ' KEY `first_name` (`first_name`(191)),\n' +
+ ' KEY `last_name` (`last_name`(191)),\n' +
+ ' KEY `subscriber_tz` (`tz`),\n' +
+ ' KEY `is_test` (`is_test`),\n' +
+ ' KEY `latest_open` (`latest_open`),\n' +
+ ' KEY `latest_click` (`latest_click`),\n' +
+ ' KEY `created` (`created`)\n' +
+ ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n');
return id;
});
@@ -101,9 +130,9 @@ async function updateWithConsistencyCheck(context, entity) {
throw new interoperableErrors.ChangedError();
}
- await namespaceHelpers.validateEntity(tx, entity);
+ await _validateAndPreprocess(tx, entity);
+
await namespaceHelpers.validateMove(context, entity, existing, 'list', 'createList', 'delete');
- enforce(entity.unsubscription_mode >= 0 && entity.unsubscription_mode < UnsubscriptionMode.MAX, 'Unknown unsubscription mode');
await tx('lists').where('id', entity.id).update(filterObject(entity, allowedKeys));
diff --git a/models/segments.js b/models/segments.js
index 82f4fe98..b25f494f 100644
--- a/models/segments.js
+++ b/models/segments.js
@@ -214,7 +214,7 @@ function hash(entity) {
async function listDTAjax(context, listId, params) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSegments');
return await dtHelpers.ajaxListTx(
tx,
@@ -229,18 +229,27 @@ async function listDTAjax(context, listId, params) {
async function listIdName(context, listId) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']);
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSegments']);
return await tx('segments').select(['id', 'name']).where('list', listId).orderBy('name', 'asc');
});
}
+async function getByIdTx(tx, context, listId, id) {
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSegments');
+ const entity = await tx('segments').where({id, list: listId}).first();
+
+ if (!entity) {
+ throw new interoperableErrors.NotFoundError();
+ }
+
+ entity.settings = JSON.parse(entity.settings);
+ return entity;
+}
+
async function getById(context, listId, id) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
- const entity = await tx('segments').where({id, list: listId}).first();
- entity.settings = JSON.parse(entity.settings);
- return entity;
+ return getByIdTx(tx, context, listId, id);
});
}
@@ -321,6 +330,8 @@ async function updateWithConsistencyCheck(context, listId, entity) {
async function removeTx(tx, context, listId, id) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
+ // FIXME - check dependencies: campaigns
+
// The listId "where" is here to prevent deleting segment of a list for which a user does not have permission
await tx('segments').where({list: listId, id}).del();
}
@@ -402,6 +413,7 @@ Object.assign(module.exports, {
listDTAjax,
listIdName,
getById,
+ getByIdTx,
create,
updateWithConsistencyCheck,
remove,
diff --git a/models/send-configurations.js b/models/send-configurations.js
index bc22bc36..51d3ceb8 100644
--- a/models/send-configurations.js
+++ b/models/send-configurations.js
@@ -21,7 +21,7 @@ function hash(entity) {
async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
- [{ entityTypeId: 'sendConfiguration', requiredOperations: ['view'] }],
+ [{ entityTypeId: 'sendConfiguration', requiredOperations: ['viewPublic'] }],
params,
builder => builder
.from('send_configurations')
@@ -30,11 +30,20 @@ async function listDTAjax(context, params) {
);
}
-async function getById(context, id, withPermissions = true) {
+async function getById(context, id, withPermissions = true, withPrivateData = true) {
return await knex.transaction(async tx => {
- await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', id, 'view');
- const entity = await tx('send_configurations').where('id', id).first();
- entity.mailer_settings = JSON.parse(entity.mailer_settings);
+ let entity;
+
+ if (withPrivateData) {
+ await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', id, 'viewPrivate');
+ entity = await tx('send_configurations').where('id', id).first();
+ entity.mailer_settings = JSON.parse(entity.mailer_settings);
+ } else {
+ await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', id, 'viewPublic');
+ entity = await tx('send_configurations').where('id', id).select(
+ ['id', 'name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable']
+ ).first();
+ }
// note that permissions are optional as as this methods may be used with synthetic admin context
if (withPermissions) {
diff --git a/models/subscriptions.js b/models/subscriptions.js
index f8102531..d1d10f9c 100644
--- a/models/subscriptions.js
+++ b/models/subscriptions.js
@@ -415,7 +415,7 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta
}
if ((isCreate && !(meta && meta.update)) || 'status' in entity) {
- enforce(entity.status >= 0 && entity.status < SubscriptionStatus.MAX, 'Invalid status');
+ enforce(entity.status >= SubscriptionStatus.MIN && entity.status <= SubscriptionStatus.MAX, 'Invalid status');
}
for (const key in groupedFieldsMap) {
diff --git a/models/templates.js b/models/templates.js
index 7656da5d..eb1d6f9a 100644
--- a/models/templates.js
+++ b/models/templates.js
@@ -40,6 +40,10 @@ async function listDTAjax(context, params) {
}
async function _validateAndPreprocess(tx, entity) {
+ await namespaceHelpers.validateEntity(tx, entity);
+
+ // We don't check contents of the "data" because it is processed solely on the client. The client generates the HTML code we use when sending out campaigns.
+
entity.data = JSON.stringify(entity.data);
}
@@ -49,8 +53,6 @@ async function create(context, entity) {
await _validateAndPreprocess(tx, entity);
- await namespaceHelpers.validateEntity(tx, entity);
-
const ids = await tx('templates').insert(filterObject(entity, allowedKeys));
const id = ids[0];
@@ -78,7 +80,6 @@ async function updateWithConsistencyCheck(context, entity) {
await _validateAndPreprocess(tx, entity);
- await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');
await tx('templates').where('id', entity.id).update(filterObject(entity, allowedKeys));
diff --git a/routes/rest/campaigns.js b/routes/rest/campaigns.js
index 7acfef93..a3444b23 100644
--- a/routes/rest/campaigns.js
+++ b/routes/rest/campaigns.js
@@ -10,5 +10,28 @@ router.postAsync('/campaigns-table', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listDTAjax(req.context, req.body));
});
+router.getAsync('/campaings/:campaignId', passport.loggedIn, async (req, res) => {
+ const campaign = await campaigns.getById(req.context, req.params.campaignId);
+ campaign.hash = campaigns.hash(campaign);
+ return res.json(campaign);
+});
+
+router.postAsync('/campaigns', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ return res.json(await campaigns.create(req.context, req.body));
+});
+
+router.putAsync('/campaigns/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ const entity = req.body;
+ entity.id = parseInt(req.params.campaignId);
+
+ await campaigns.updateWithConsistencyCheck(req.context, entity);
+ return res.json();
+});
+
+router.deleteAsync('/campaigns/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ await campaigns.remove(req.context, req.params.campaignId);
+ return res.json();
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/routes/rest/send-configurations.js b/routes/rest/send-configurations.js
index def055c0..e99a95e4 100644
--- a/routes/rest/send-configurations.js
+++ b/routes/rest/send-configurations.js
@@ -6,8 +6,14 @@ const sendConfigurations = require('../../models/send-configurations');
const router = require('../../lib/router-async').create();
-router.getAsync('/send-configurations/:sendConfigurationId', passport.loggedIn, async (req, res) => {
- const sendConfiguration = await sendConfigurations.getById(req.context, req.params.sendConfigurationId);
+router.getAsync('/send-configurations-private/:sendConfigurationId', passport.loggedIn, async (req, res) => {
+ const sendConfiguration = await sendConfigurations.getById(req.context, req.params.sendConfigurationId, true, true);
+ sendConfiguration.hash = sendConfigurations.hash(sendConfiguration);
+ return res.json(sendConfiguration);
+});
+
+router.getAsync('/send-configurations-public/:sendConfigurationId', passport.loggedIn, async (req, res) => {
+ const sendConfiguration = await sendConfigurations.getById(req.context, req.params.sendConfigurationId, true, false);
sendConfiguration.hash = sendConfigurations.hash(sendConfiguration);
return res.json(sendConfiguration);
});
diff --git a/setup/knex/migrations/20180718220444_upgrade_campaigns.js b/setup/knex/migrations/20180718220444_upgrade_campaigns.js
index 38471b16..00e99b2c 100644
--- a/setup/knex/migrations/20180718220444_upgrade_campaigns.js
+++ b/setup/knex/migrations/20180718220444_upgrade_campaigns.js
@@ -44,7 +44,7 @@ OK | namespace | int(10) unsigned | NO | MUL | NULL
New columns:
+-------------------------+---------------------+------+-----+-------------------+----------------+
| data | longtext | NO | | NULL | |
- | source_type | int(10) unsigned | NO | | | |
+ | source | int(10) unsigned | NO | | | |
| send_configuration | int(10) unsigned | NO | | | |
+-------------------------+---------------------+------+-----+-------------------+----------------+
@@ -55,38 +55,13 @@ scheduled - used only for campaign type NORMAL
*/
const { getSystemSendConfigurationId } = require('../../../shared/send-configurations');
-
-const CampaignSource = {
- TEMPLATE: 1,
- CUSTOM: 2,
- URL: 3,
- RSS: 4
-};
-
-const CampaignType = {
- NORMAL: 1,
- RSS: 2,
- RSS_ENTRY: 3,
- TRIGGERED: 4
-};
-
-const CampaignStatus = {
- // For campaign types: NORMAL, RSS_ENTRY
- IDLE: 1,
- SCHEDULED: 2,
- FINISHED: 3,
- PAUSED: 4,
-
- // For campaign types: RSS, TRIGGERED
- INACTIVE: 5,
- ACTIVE: 6
-};
+const { CampaignSource, CampaignType} = require('../../../shared/campaigns');
exports.up = (knex, Promise) => (async() => {
await knex.schema.table('campaigns', table => {
table.text('data', 'longtext');
- table.integer('source_type').unsigned().notNullable();
+ table.integer('source').unsigned().notNullable();
// Add a default values, such that the new column has some valid non-null value
table.integer('send_configuration').unsigned().notNullable().references(`send_configurations.id`).defaultTo(getSystemSendConfigurationId());
@@ -97,7 +72,7 @@ exports.up = (knex, Promise) => (async() => {
for (const campaign of campaigns) {
const data = {};
- if (campaign.type === CampaignType.NORMAL || campaign.type === CampaignType.RSS_ENTRY || campaign.type === CampaignType.NORMAL || campaign.type === CampaignType.TRIGGERED) {
+ if (campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.RSS_ENTRY || campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.TRIGGERED) {
if (campaign.template) {
let editorType = campaign.editor_name;
const editorData = JSON.parse(campaign.editor_data || '{}');
@@ -106,21 +81,26 @@ exports.up = (knex, Promise) => (async() => {
editorType = 'ckeditor';
}
- campaign.source_type = CampaignSource.CUSTOM;
- data.source = {
+ campaign.source = CampaignSource.CUSTOM_FROM_TEMPLATE;
+ data.sourceCustom = {
type: editorType,
data: editorData,
html: campaign.html,
text: campaign.text,
htmlPrepared: campaign.html_prepared
};
+
+ data.sourceTemplate = campaign.template;
+
+ // For source === CampaignSource.TEMPLATE, the data is as follows:
+ // data.sourceTemplate =
} else {
- campaign.source_type = CampaignSource.URL;
+ campaign.source = CampaignSource.URL;
data.sourceUrl = campaign.source_url;
}
} else if (campaign.type === CampaignType.RSS) {
- campaign.source_type = CampaignSource.RSS;
+ campaign.source = CampaignSource.RSS;
data.feedUrl = campaign.source_url;
data.checkStatus = campaign.checkStatus;
@@ -150,6 +130,8 @@ exports.up = (knex, Promise) => (async() => {
table.integer('send_configuration').unsigned().notNullable().alter();
});
+ await knex.schema.dropTableIfExists('campaign');
+ await knex.schema.dropTableIfExists('campaign_tracker');
})();
exports.down = (knex, Promise) => (async() => {
diff --git a/setup/knex/migrations/20180718220445_drop_template_table.js b/setup/knex/migrations/20180718220445_drop_template_table.js
new file mode 100644
index 00000000..617bbadf
--- /dev/null
+++ b/setup/knex/migrations/20180718220445_drop_template_table.js
@@ -0,0 +1,6 @@
+exports.up = (knex, Promise) => (async() => {
+ await knex.schema.dropTableIfExists('subscription');
+})();
+
+exports.down = (knex, Promise) => (async() => {
+})();
diff --git a/shared/campaigns.js b/shared/campaigns.js
new file mode 100644
index 00000000..966df8d9
--- /dev/null
+++ b/shared/campaigns.js
@@ -0,0 +1,47 @@
+'use strict';
+
+const CampaignSource = {
+ MIN: 1,
+
+ TEMPLATE: 1,
+ CUSTOM: 2,
+ CUSTOM_FROM_TEMPLATE: 3,
+ URL: 4,
+ RSS: 5,
+
+ MAX: 6
+};
+
+const CampaignType = {
+ MIN: 1,
+
+ REGULAR: 1,
+ RSS: 2,
+ RSS_ENTRY: 3,
+ TRIGGERED: 4,
+
+ MAX: 4
+};
+
+const CampaignStatus = {
+ MIN: 1,
+
+ // For campaign types: NORMAL, RSS_ENTRY
+ IDLE: 1,
+ SCHEDULED: 2,
+ FINISHED: 3,
+ PAUSED: 4,
+
+ // For campaign types: RSS, TRIGGERED
+ INACTIVE: 5,
+ ACTIVE: 6,
+
+ MAA: 6
+};
+
+
+module.exports = {
+ CampaignSource,
+ CampaignType,
+ CampaignStatus
+};
\ No newline at end of file
diff --git a/shared/lists.js b/shared/lists.js
index 75a5f556..d80421a7 100644
--- a/shared/lists.js
+++ b/shared/lists.js
@@ -1,20 +1,26 @@
'use strict';
const UnsubscriptionMode = {
+ MIN: 0,
+
ONE_STEP: 0,
ONE_STEP_WITH_FORM: 1,
TWO_STEP: 2,
TWO_STEP_WITH_FORM: 3,
MANUAL: 4,
- MAX: 5
+
+ MAX: 4
};
const SubscriptionStatus = {
+ MIN: 0,
+
SUBSCRIBED: 1,
UNSUBSCRIBED: 2,
BOUNCED: 3,
COMPLAINED: 4,
- MAX: 5
+
+ MAX: 4
};
function getFieldKey(field) {
diff --git a/shared/reports.js b/shared/reports.js
index 64b6cd05..b5f4a33c 100644
--- a/shared/reports.js
+++ b/shared/reports.js
@@ -1,11 +1,14 @@
'use strict';
const ReportState = {
+ MIN: 0,
+
SCHEDULED: 0,
PROCESSING: 1,
FINISHED: 2,
FAILED: 3,
- MAX: 4
+
+ MAX: 3
};
module.exports = {