First attempt on campaign editing. Misses attachments. Untested.

This commit is contained in:
Tomas Bures 2018-07-31 10:04:28 +05:30
parent ee786bc8ad
commit 0e0fb944e3
26 changed files with 1244 additions and 233 deletions

503
client/src/campaigns/CUD.js Normal file
View file

@ -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 = <TableSelect id="data_sourceTemplate" label={t('Template')} withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} />;
} 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 = <div>
{isEdit
?
<StaticField id="data_sourceCustom_type" className={styles.formDisabled} label={t('Type')}>
{customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName}
</StaticField>
:
<Dropdown id="data_sourceCustom_type" label={t('Type')} options={customTemplateTypeOptions}/>
}
{customTemplateTypeForm}
{customTemplateEditForm}
</div>;
} else if (sourceTypeKey === CampaignSource.URL) {
templateEdit = <InputField id="data_sourceUrl" label={t('Render URL')}/>
}
if (campaignTypeKey === CampaignType.RSS) {
extraSettings = <InputField id="data_feedUrl" label={t('RSS Feed Url')}/>
}
const listsColumns = [
{ data: 1, title: t('Name') },
{ data: 2, title: t('ID'), render: data => <code>{data}</code> },
{ 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 (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/campaigns/${this.props.entity.id}`}
cudUrl={`/campaigns/${this.props.entity.id}/edit`}
listUrl="/campaigns"
deletingMsg={t('Deleting campaign ...')}
deletedMsg={t('Campaign deleted')}/>
}
<Title>{isEdit ? t('Edit Campaign') : t('Create Campaign')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')}/>
<TableSelect id="list" label={t('List')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} />
<CheckBox id="useSegmentation" text={t('Use segmentation')}/>
{this.getFormValue('useSegmentation') &&
<TableSelect id="segment" label={t('Segment')} withHeader dropdown dataUrl='rest/segments-table' columns={segmentsColumns} selectionLabelIndex={1} />
}
{extraSettings}
<NamespaceSelect/>
<TableSelect id="send_configuration" label={t('Send configuration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
<CheckBox id="from_name_overriden" text={t('Override email "From" name')}/>
{ this.getFormValue('from_name_overriden') && <InputField id="from_name_override" label={t('Email "From" name')}/> }
<CheckBox id="from_email_overriden" text={t('Override email "From" address')}/>
{ this.getFormValue('from_email_overriden') && <InputField id="from_email_override" label={t('Email "From" address')}/> }
<CheckBox id="reply_to_overriden" text={t('Override email "Reply-to" address')}/>
{ this.getFormValue('reply_to_overriden') && <InputField id="reply_to_override" label={t('Email "Reply-to" address')}/> }
<CheckBox id="subject_overriden" text={t('Override email "Subject" line')}/>
{ this.getFormValue('subject_overriden') && <InputField id="subject_override" label={t('Email "Subject" line')}/> }
<CheckBox id="open_trackings_disabled" text={t('Disable opened tracking')}/>
<CheckBox id="click_tracking_disabled" text={t('Disable clicked tracking')}/>
{templateEdit}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={isEdit ? t('Save') : t('Save and edit campaign')}/>
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/campaigns/${this.props.entity.id}/delete`}/> }
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -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: <Icon icon="edit" title={t('Edit')}/>,
link: `/campaigns/${data[0]}/edit`
});
}
if (perms.includes('manageFiles') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
actions.push({
label: <Icon icon="hdd" title={t('Files')}/>,
link: `/campaigns/${data[0]}/files`
});
}
// FIXME: add attachments
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share-alt" title={t('Share')}/>,
link: `/campaigns/${data[0]}/share`
});
}
return actions;
}
}
];
return (
<div>
<Toolbar>
{this.state.createPermitted &&
<NavButton linkTo="/campaigns/create" className="btn-primary" icon="plus" label={t('Create Campaign')}/>
}
</Toolbar>
<Title>{t('Campaigns')}</Title>
<Table withHeader dataUrl="rest/campaigns-table" columns={columns} />
</div>
);
}
}

View file

@ -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 => <CampaignsCUD action={props.match.params.action} entity={props.resolved.campaign} />
},
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 => <Files title={t('Files')} entity={props.resolved.campaign} entityTypeId="campaign" />
},
// FIXME: add attachments
share: {
title: t('Share'),
link: params => `/campaigns/${params.campaignId}/share`,
visible: resolved => resolved.campaign.permissions.includes('share'),
panelRender: props => <Share title={t('Share')} entity={props.resolved.campaign} entityTypeId="campaign" />
}
}
},
'create-regular': {
title: t('Create Regular Campaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} />
},
'create-rss': {
title: t('Create RSS Campaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} />
},
'create-triggered': {
title: t('Create Triggered Campaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.TRIGGERED} />
}
}
}
};
}
export default {
getMenus
}

View file

@ -151,6 +151,8 @@ export default class List extends Component {
dataUrl += '/' + this.props.segmentId; dataUrl += '/' + this.props.segmentId;
} }
// FIXME - presents segments in a data table as in campaign edit
return ( return (
<div> <div>
<Toolbar> <Toolbar>

View file

@ -15,6 +15,7 @@ import blacklist from './blacklist/root';
import lists from './lists/root'; import lists from './lists/root';
import namespaces from './namespaces/root'; import namespaces from './namespaces/root';
import reports from './reports/root'; import reports from './reports/root';
import campaigns from './campaigns/root';
import templates from './templates/root'; import templates from './templates/root';
import users from './users/root'; import users from './users/root';
import sendConfigurations from './send-configurations/root'; import sendConfigurations from './send-configurations/root';
@ -45,7 +46,7 @@ class Root extends Component {
const t = props.t; const t = props.t;
const self = this; const self = this;
const topLevelMenuKeys = ['lists', 'templates', 'reports']; const topLevelMenuKeys = ['lists', 'templates', 'campaigns', 'reports'];
class MainMenu extends Component { class MainMenu extends Component {
render() { render() {
@ -123,7 +124,8 @@ class Root extends Component {
...blacklist.getMenus(t), ...blacklist.getMenus(t),
...account.getMenus(t), ...account.getMenus(t),
...settings.getMenus(t), ...settings.getMenus(t),
...sendConfigurations.getMenus(t) ...sendConfigurations.getMenus(t),
...campaigns.getMenus(t)
} }
} }
}; };

View file

@ -17,7 +17,7 @@ function getMenus(t) {
':sendConfigurationId([0-9]+)': { ':sendConfigurationId([0-9]+)': {
title: resolved => t('Template "{{name}}"', {name: resolved.sendConfiguration.name}), title: resolved => t('Template "{{name}}"', {name: resolved.sendConfiguration.name}),
resolve: { resolve: {
sendConfiguration: params => `rest/send-configurations/${params.sendConfigurationId}` sendConfiguration: params => `rest/send-configurations-private/${params.sendConfigurationId}`
}, },
link: params => `/send-configurations/${params.sendConfigurationId}/edit`, link: params => `/send-configurations/${params.sendConfigurationId}/edit`,
navs: { navs: {

View file

@ -2,10 +2,7 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {translate} from 'react-i18next';
Trans,
translate
} from 'react-i18next';
import { import {
NavButton, NavButton,
requiresAuthenticatedUser, requiresAuthenticatedUser,
@ -13,8 +10,6 @@ import {
withPageHelpers withPageHelpers
} from '../lib/page' } from '../lib/page'
import { import {
ACEEditor,
AlignedRow,
Button, Button,
ButtonRow, ButtonRow,
Dropdown, Dropdown,
@ -32,8 +27,11 @@ import {
} from '../lib/namespace'; } from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals"; import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {getTemplateTypes} from './helpers'; import {
import {ActionLink} from "../lib/bootstrap-components"; getEditForm,
getTemplateTypes,
getTypeForm
} from './helpers';
import axios from '../lib/axios'; import axios from '../lib/axios';
import styles from "../lib/styles.scss"; import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls"; import {getUrl} from "../lib/urls";
@ -158,7 +156,6 @@ export default class CUD extends Component {
const html = this.getFormValue('html'); const html = this.getFormValue('html');
if (!html) { if (!html) {
alert('Missing HTML content');
return; return;
} }
@ -203,101 +200,15 @@ export default class CUD extends Component {
let editForm = null; let editForm = null;
if (isEdit && typeKey) { if (isEdit && typeKey) {
editForm = <div> editForm = getEditForm(this, typeKey);
<AlignedRow>
<Button className="btn-default" onClickAsync={::this.toggleMergeTagReference} label={t('Merge tag reference')}/>
{this.state.showMergeTagReference &&
<div style={{marginTop: '15px'}}>
<Trans><p>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional text value used when <code>TAG_NAME</code> is empty.</p></Trans>
<Trans><p>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.</p></Trans>
<table className="table table-bordered table-condensed table-striped">
<thead>
<tr>
<th>
<Trans>Merge tag</Trans>
</th>
<th>
<Trans>Description</Trans>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
[LINK_UNSUBSCRIBE]
</th>
<td>
<Trans>URL that points to the unsubscribe page</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_PREFERENCES]
</th>
<td>
<Trans>URL that points to the preferences page of the subscriber</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_BROWSER]
</th>
<td>
<Trans>URL to preview the message in a browser</Trans>
</td>
</tr>
<tr>
<th scope="row">
[EMAIL]
</th>
<td>
<Trans>Email address</Trans>
</td>
</tr>
<tr>
<th scope="row">
[SUBSCRIPTION_ID]
</th>
<td>
<Trans>Unique ID that identifies the recipient</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LIST_ID]
</th>
<td>
<Trans>Unique ID that identifies the list used for this campaign</Trans>
</td>
</tr>
<tr>
<th scope="row">
[CAMPAIGN_ID]
</th>
<td>
<Trans>Unique ID that identifies current campaign</Trans>
</td>
</tr>
</tbody>
</table>
</div>}
</AlignedRow>
{this.templateTypes[typeKey].getHTMLEditor(this)}
<ACEEditor id="text" height="400px" mode="text" label={t('Template content (plain text)')} help={<Trans>To extract the text from HTML click <ActionLink onClickAsync={::this.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}/>
</div>
} }
let typeForm = null; let typeForm = null;
if (typeKey) { if (typeKey) {
typeForm = <div> typeForm = getTypeForm(this, typeKey, isEdit);
{this.templateTypes[typeKey].getTypeForm(this, isEdit)}
</div>;
} }
return ( return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}> <div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
{canDelete && {canDelete &&

View file

@ -1,14 +1,22 @@
'use strict'; 'use strict';
import React, { Component } from 'react'; import React, {Component} from 'react';
import { translate } from 'react-i18next'; import {translate} from 'react-i18next';
import { Icon } from '../lib/bootstrap-components'; import {Icon} from '../lib/bootstrap-components';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page'; import {
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; NavButton,
import { Table } from '../lib/table'; requiresAuthenticatedUser,
import axios from '../lib/axios'; Title,
Toolbar,
withPageHelpers
} from '../lib/page';
import {
withAsyncErrorHandler,
withErrorHandling
} from '../lib/error-handling';
import {Table} from '../lib/table';
import moment from 'moment'; import moment from 'moment';
import { getTemplateTypes } from './helpers'; import {getTemplateTypes} from './helpers';
import {checkPermissions} from "../lib/permissions"; import {checkPermissions} from "../lib/permissions";
@translate() @translate()

View file

@ -17,19 +17,20 @@ import {
} from "../lib/mosaico"; } from "../lib/mosaico";
import {getTemplateTypes as getMosaicoTemplateTypes} from './mosaico/helpers'; import {getTemplateTypes as getMosaicoTemplateTypes} from './mosaico/helpers';
import {getTrustedUrl, getSandboxUrl} from "../lib/urls"; import {getSandboxUrl} from "../lib/urls";
import mailtrainConfig from 'mailtrainConfig'; 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 = {}; const templateTypes = {};
function initFieldsIfMissing(mutState, templateType) { function initFieldsIfMissing(mutState, templateType) {
const initVals = templateTypes[templateType].initData(); const initVals = templateTypes[templateType].initData();
for (const key in initVals) { for (const key in initVals) {
if (!mutState.hasIn([key])) { if (!mutState.hasIn([prefix + key])) {
mutState.setIn([key, 'value'], initVals[key]); mutState.setIn([prefix + key, 'value'], initVals[key]);
} }
} }
} }
@ -55,43 +56,43 @@ export function getTemplateTypes(t) {
templateTypes.mosaico = { templateTypes.mosaico = {
typeName: t('Mosaico'), typeName: t('Mosaico'),
getTypeForm: (owner, isEdit) => getTypeForm: (owner, isEdit) =>
<TableSelect id="mosaicoTemplate" label={t('Mosaico template')} withHeader dropdown dataUrl='rest/mosaico-templates-table' columns={mosaicoTemplatesColumns} selectionLabelIndex={1} disabled={isEdit} />, <TableSelect id={prefix + 'mosaicoTemplate'} label={t('Mosaico template')} withHeader dropdown dataUrl='rest/mosaico-templates-table' columns={mosaicoTemplatesColumns} selectionLabelIndex={1} disabled={isEdit} />,
getHTMLEditor: owner => getHTMLEditor: owner =>
<AlignedRow label={t('Template content (HTML)')}> <AlignedRow label={t('Template content (HTML)')}>
<MosaicoEditor <MosaicoEditor
ref={node => owner.editorNode = node} ref={node => owner.editorNode = node}
entity={owner.props.entity} entity={owner.props.entity}
initialModel={owner.getFormValue('mosaicoData').model} initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
initialMetadata={owner.getFormValue('mosaicoData').metadata} initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
templatePath={getSandboxUrl(`mosaico/templates/${owner.getFormValue('mosaicoTemplate')}/index.html`)} templatePath={getSandboxUrl(`mosaico/templates/${owner.getFormValue(prefix + 'mosaicoTemplate')}/index.html`)}
entityTypeId={ResourceType.TEMPLATE} entityTypeId={ResourceType.TEMPLATE}
title={t('Mosaico Template Designer')} title={t('Mosaico Template Designer')}
onFullscreenAsync={::owner.setElementInFullscreen}/> onFullscreenAsync={::owner.setElementInFullscreen}/>
</AlignedRow>, </AlignedRow>,
exportHTMLEditorData: async owner => { exportHTMLEditorData: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState(); const {html, metadata, model} = await owner.editorNode.exportState();
owner.updateFormValue('html', html); owner.updateFormValue(prefix + 'html', html);
owner.updateFormValue('mosaicoData', { owner.updateFormValue(prefix + 'mosaicoData', {
metadata, metadata,
model model
}); });
}, },
initData: () => ({ initData: () => ({
mosaicoTemplate: '', [prefix + 'mosaicoTemplate']: '',
mosaicoData: {} [prefix + 'mosaicoData']: {}
}), }),
afterLoad: data => { afterLoad: data => {
data.mosaicoTemplate = data.data.mosaicoTemplate; data[prefix + 'mosaicoTemplate'] = data[prefix + 'data'].mosaicoTemplate;
data.mosaicoData = { data[prefix + 'mosaicoData'] = {
metadata: data.data.metadata, metadata: data[prefix + 'data'].metadata,
model: data.data.model model: data[prefix + 'data'].model
}; };
}, },
beforeSave: data => { beforeSave: data => {
data.data = { data[prefix + 'data'] = {
mosaicoTemplate: data.mosaicoTemplate, mosaicoTemplate: data[prefix + 'mosaicoTemplate'],
metadata: data.mosaicoData.metadata, metadata: data[prefix + 'mosaicoData'].metadata,
model: data.mosaicoData.model model: data[prefix + 'mosaicoData'].model
}; };
clearBeforeSave(data); clearBeforeSave(data);
}, },
@ -99,11 +100,11 @@ export function getTemplateTypes(t) {
initFieldsIfMissing(mutState, 'mosaico'); initFieldsIfMissing(mutState, 'mosaico');
}, },
validate: state => { validate: state => {
const mosaicoTemplate = state.getIn(['mosaicoTemplate', 'value']); const mosaicoTemplate = state.getIn([prefix + 'mosaicoTemplate', 'value']);
if (!mosaicoTemplate) { if (!mosaicoTemplate) {
state.setIn(['mosaicoTemplate', 'error'], t('Mosaico template must be selected')); state.setIn([prefix + 'mosaicoTemplate', 'error'], t('Mosaico template must be selected'));
} else { } else {
state.setIn(['mosaicoTemplate', 'error'], null); state.setIn([prefix + 'mosaicoTemplate', 'error'], null);
} }
} }
}; };
@ -113,23 +114,23 @@ export function getTemplateTypes(t) {
templateTypes.mosaicoWithFsTemplate = { templateTypes.mosaicoWithFsTemplate = {
typeName: t('Mosaico with predefined templates'), typeName: t('Mosaico with predefined templates'),
getTypeForm: (owner, isEdit) => getTypeForm: (owner, isEdit) =>
<Dropdown id="mosaicoFsTemplate" label={t('Mosaico Template')} options={mosaicoFsTemplatesOptions}/>, <Dropdown id={prefix + 'mosaicoFsTemplate'} label={t('Mosaico Template')} options={mosaicoFsTemplatesOptions}/>,
getHTMLEditor: owner => getHTMLEditor: owner =>
<AlignedRow label={t('Template content (HTML)')}> <AlignedRow label={t('Template content (HTML)')}>
<MosaicoEditor <MosaicoEditor
ref={node => owner.editorNode = node} ref={node => owner.editorNode = node}
entity={owner.props.entity} entity={owner.props.entity}
initialModel={owner.getFormValue('mosaicoData').model} initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
initialMetadata={owner.getFormValue('mosaicoData').metadata} initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
templatePath={getSandboxUrl(`public/mosaico/templates/${owner.getFormValue('mosaicoFsTemplate')}/index.html`)} templatePath={getSandboxUrl(`public/mosaico/templates/${owner.getFormValue(prefix + 'mosaicoFsTemplate')}/index.html`)}
entityTypeId={ResourceType.TEMPLATE} entityTypeId={ResourceType.TEMPLATE}
title={t('Mosaico Template Designer')} title={t('Mosaico Template Designer')}
onFullscreenAsync={::owner.setElementInFullscreen}/> onFullscreenAsync={::owner.setElementInFullscreen}/>
</AlignedRow>, </AlignedRow>,
exportHTMLEditorData: async owner => { exportHTMLEditorData: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState(); const {html, metadata, model} = await owner.editorNode.exportState();
owner.updateFormValue('html', html); owner.updateFormValue(prefix + 'html', html);
owner.updateFormValue('mosaicoData', { owner.updateFormValue(prefix + 'mosaicoData', {
metadata, metadata,
model model
}); });
@ -139,17 +140,17 @@ export function getTemplateTypes(t) {
mosaicoData: {} mosaicoData: {}
}), }),
afterLoad: data => { afterLoad: data => {
data.mosaicoFsTemplate = data.data.mosaicoFsTemplate; data['mosaicoFsTemplate'] = data[prefix + 'data'].mosaicoFsTemplate;
data.mosaicoData = { data[prefix + 'mosaicoData'] = {
metadata: data.data.metadata, metadata: data[prefix + 'data'].metadata,
model: data.data.model model: data[prefix + 'data'].model
}; };
}, },
beforeSave: data => { beforeSave: data => {
data.data = { data[prefix + 'data'] = {
mosaicoFsTemplate: data.mosaicoFsTemplate, mosaicoFsTemplate: data[prefix + 'mosaicoFsTemplate'],
metadata: data.mosaicoData.metadata, metadata: data[prefix + 'mosaicoData'].metadata,
model: data.mosaicoData.model model: data[prefix + 'mosaicoData'].model
}; };
clearBeforeSave(data); clearBeforeSave(data);
}, },
@ -176,7 +177,7 @@ export function getTemplateTypes(t) {
templateTypes.ckeditor = { templateTypes.ckeditor = {
typeName: t('CKEditor'), typeName: t('CKEditor'),
getTypeForm: (owner, isEdit) => null, getTypeForm: (owner, isEdit) => null,
getHTMLEditor: owner => <CKEditor id="html" height="600px" label={t('Template content (HTML)')}/>, getHTMLEditor: owner => <CKEditor id={prefix + 'html'} height="600px" label={t('Template content (HTML)')}/>,
exportHTMLEditorData: async owner => {}, exportHTMLEditorData: async owner => {},
initData: () => ({}), initData: () => ({}),
afterLoad: data => {}, afterLoad: data => {},
@ -190,7 +191,7 @@ export function getTemplateTypes(t) {
templateTypes.codeeditor = { templateTypes.codeeditor = {
typeName: t('Code Editor'), typeName: t('Code Editor'),
getTypeForm: (owner, isEdit) => null, getTypeForm: (owner, isEdit) => null,
getHTMLEditor: owner => <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>, getHTMLEditor: owner => <ACEEditor id={prefix + 'html'} height="600px" mode="html" label={t('Template content (HTML)')}/>,
exportHTMLEditorData: async owner => {}, exportHTMLEditorData: async owner => {},
initData: () => ({}), initData: () => ({}),
afterLoad: data => {}, afterLoad: data => {},
@ -215,4 +216,101 @@ export function getTemplateTypes(t) {
}; };
return templateTypes; return templateTypes;
} }
export function getEditForm(owner, typeKey, prefix = '') {
const t = owner.props.t;
return <div>
<AlignedRow>
<Button className="btn-default" onClickAsync={::owner.toggleMergeTagReference} label={t('Merge tag reference')}/>
{owner.state.showMergeTagReference &&
<div style={{marginTop: '15px'}}>
<Trans><p>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional text value used when <code>TAG_NAME</code> is empty.</p></Trans>
<Trans><p>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.</p></Trans>
<table className="table table-bordered table-condensed table-striped">
<thead>
<tr>
<th>
<Trans>Merge tag</Trans>
</th>
<th>
<Trans>Description</Trans>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
[LINK_UNSUBSCRIBE]
</th>
<td>
<Trans>URL that points to the unsubscribe page</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_PREFERENCES]
</th>
<td>
<Trans>URL that points to the preferences page of the subscriber</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_BROWSER]
</th>
<td>
<Trans>URL to preview the message in a browser</Trans>
</td>
</tr>
<tr>
<th scope="row">
[EMAIL]
</th>
<td>
<Trans>Email address</Trans>
</td>
</tr>
<tr>
<th scope="row">
[SUBSCRIPTION_ID]
</th>
<td>
<Trans>Unique ID that identifies the recipient</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LIST_ID]
</th>
<td>
<Trans>Unique ID that identifies the list used for this campaign</Trans>
</td>
</tr>
<tr>
<th scope="row">
[CAMPAIGN_ID]
</th>
<td>
<Trans>Unique ID that identifies current campaign</Trans>
</td>
</tr>
</tbody>
</table>
</div>}
</AlignedRow>
{owner.templateTypes[typeKey].getHTMLEditor(owner)}
<ACEEditor id={prefix + 'text'} height="400px" mode="text" label={t('Template content (plain text)')} help={<Trans>To extract the text from HTML click <ActionLink onClickAsync={::owner.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}/>
</div>;
}
export function getTypeForm(owner, typeKey, isEdit) {
return <div>
{owner.templateTypes[typeKey].getTypeForm(this, isEdit)}
</div>;
}

View file

@ -202,28 +202,28 @@ rootNamespaceRole="master"
[roles.namespace.master] [roles.namespace.master]
name="Master" name="Master"
description="All permissions" 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] [roles.namespace.master.children]
sendConfiguration=["view", "edit", "delete", "share", "send", "overrideAllowed", "overrideAll"] sendConfiguration=["viewPublic", "viewPrivate", "edit", "delete", "share", "sendWithoutOverrides", "sendWithAllowedOverrides", "sendWithAnyOverrides"]
list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"] list=["view", "edit", "delete", "share", "viewFields", "manageFields", "viewSubscriptions", "manageSubscriptions", "viewSegments", "manageSegments"]
customForm=["view", "edit", "delete", "share"] customForm=["view", "edit", "delete", "share"]
campaign=["view", "edit", "delete", "share", "manageFiles"] campaign=["view", "edit", "delete", "share", "viewFiles", "manageFiles", "manageAttachments", "send", "viewStats"]
template=["view", "edit", "delete", "share", "manageFiles"] template=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"] report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
reportTemplate=["view", "edit", "delete", "share", "execute"] reportTemplate=["view", "edit", "delete", "share", "execute"]
mosaicoTemplate=["view", "edit", "delete", "share", "manageFiles"] mosaicoTemplate=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "manageUsers"] namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "createCampaign", "manageUsers"]
[roles.sendConfiguration.master] [roles.sendConfiguration.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share", "send", "overrideAllowed", "overrideAll"] permissions=["viewPublic", "viewPrivate", "edit", "delete", "share", "sendWithoutOverrides", "sendWithAllowedOverrides", "sendWithAnyOverrides"]
[roles.list.master] [roles.list.master]
name="Master" name="Master"
description="All permissions" 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] [roles.customForm.master]
name="Master" name="Master"
@ -233,12 +233,12 @@ permissions=["view", "edit", "delete", "share"]
[roles.campaign.master] [roles.campaign.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share", "manageFiles"] permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles", "manageAttachments", "send", "viewStats"]
[roles.template.master] [roles.template.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share", "manageFiles"] permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
[roles.report.master] [roles.report.master]
name="Master" name="Master"
@ -253,7 +253,8 @@ permissions=["view", "edit", "delete", "share", "execute"]
[roles.mosaicoTemplate.master] [roles.mosaicoTemplate.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share", "manageFiles"] permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
[roles.global.editor] [roles.global.editor]
@ -317,3 +318,4 @@ permissions=[]
name="Editor" name="Editor"
description="XXX" description="XXX"
permissions=[] permissions=[]

View file

@ -41,7 +41,7 @@ async function getOrCreateMailer(sendConfigurationId) {
if (!sendConfiguration) { if (!sendConfiguration) {
sendConfiguration = await sendConfigurations.getSystemSendConfiguration(); sendConfiguration = await sendConfigurations.getSystemSendConfiguration();
} else { } 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); const transport = transports.get(sendConfiguration.id) || await _createTransport(sendConfiguration);

View file

@ -3,18 +3,28 @@
const knex = require('../lib/knex'); const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers'); const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
const shortid = require('shortid');
const shares = require('./shares'); 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( return await dtHelpers.ajaxListWithPermissions(
context, context,
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }], [{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
params, params,
builder => builder.from('campaigns') builder => builder.from('campaigns')
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace'), .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) { 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 = { module.exports = {
listDTAjax, listDTAjax,
getById getById

View file

@ -200,7 +200,7 @@ function hash(entity) {
async function getById(context, listId, id) { async function getById(context, listId, id) {
return await knex.transaction(async tx => { 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(); 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) { async function list(context, listId) {
return await knex.transaction(async tx => { 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); return await listTx(tx, listId);
}); });
} }
@ -286,7 +286,7 @@ async function listByOrderListTx(tx, listId, extraColumns = []) {
async function listDTAjax(context, listId, params) { async function listDTAjax(context, listId, params) {
return await knex.transaction(async tx => { 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( return await dtHelpers.ajaxListTx(
tx, tx,
@ -330,7 +330,7 @@ async function listDTAjax(context, listId, params) {
async function listGroupedDTAjax(context, listId, params) { async function listGroupedDTAjax(context, listId, params) {
return await knex.transaction(async tx => { 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( return await dtHelpers.ajaxListTx(
tx, tx,

View file

@ -32,7 +32,7 @@ function getFilesTable(type) {
async function listDTAjax(context, type, entityId, params) { async function listDTAjax(context, type, entityId, params) {
enforceTypePermitted(type); enforceTypePermitted(type);
await shares.enforceEntityPermission(context, type, entityId, 'manageFiles'); await shares.enforceEntityPermission(context, type, entityId, 'viewFiles');
return await dtHelpers.ajaxList( return await dtHelpers.ajaxList(
params, params,
builder => builder.from(getFilesTable(type)).where({entity: entityId}), 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) { async function list(context, type, entityId) {
enforceTypePermitted(type);
return await knex.transaction(async tx => { 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'); 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); enforceTypePermitted(type);
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type)).where('id', id).first(); 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; return file;
}); });
@ -69,7 +70,7 @@ async function getFileById(context, type, id) {
async function _getFileBy(context, type, entityId, key, value) { async function _getFileBy(context, type, entityId, key, value) {
enforceTypePermitted(type); enforceTypePermitted(type);
const file = await knex.transaction(async tx => { 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(); const file = await tx(getFilesTable(type)).where({entity: entityId, [key]: value}).first();
return file; return file;
}); });
@ -212,6 +213,8 @@ async function createFiles(context, type, entityId, files, getUrl = null, dontRe
} }
async function removeFile(context, type, id) { async function removeFile(context, type, id) {
enforceTypePermitted(type);
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type)).where('id', id).select('entity', 'filename').first(); const file = await tx(getFilesTable(type)).where('id', id).select('entity', 'filename').first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles'); await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles');
@ -223,6 +226,27 @@ async function removeFile(context, type, id) {
await fs.removeAsync(filePath); 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 = { module.exports = {
filesDir, filesDir,
listDTAjax, listDTAjax,
@ -234,5 +258,6 @@ module.exports = {
createFiles, createFiles,
removeFile, removeFile,
getFileUrl, getFileUrl,
getFilePath getFilePath,
copyAllTx
}; };

View file

@ -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) { async function create(context, entity) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createList'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createList');
await namespaceHelpers.validateEntity(tx, entity); await _validateAndPreprocess(tx, entity);
enforce(entity.unsubscription_mode >= 0 && entity.unsubscription_mode < UnsubscriptionMode.MAX, 'Unknown unsubscription mode');
const filteredEntity = filterObject(entity, allowedKeys); const filteredEntity = filterObject(entity, allowedKeys);
filteredEntity.cid = shortid.generate(); filteredEntity.cid = shortid.generate();
@ -79,9 +83,34 @@ async function create(context, entity) {
const ids = await tx('lists').insert(filteredEntity); const ids = await tx('lists').insert(filteredEntity);
const id = ids[0]; const id = ids[0];
await knex.schema.raw('CREATE TABLE `subscription__' + id + '` LIKE subscription'); await knex.schema.raw('CREATE TABLE `subscription__' + id + '` (\n' +
' `id` int(10) unsigned NOT NULL AUTO_INCREMENT,\n' +
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'list', entityId: id }); ' `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; return id;
}); });
@ -101,9 +130,9 @@ async function updateWithConsistencyCheck(context, entity) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }
await namespaceHelpers.validateEntity(tx, entity); await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'list', 'createList', 'delete'); 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)); await tx('lists').where('id', entity.id).update(filterObject(entity, allowedKeys));

View file

@ -214,7 +214,7 @@ function hash(entity) {
async function listDTAjax(context, listId, params) { async function listDTAjax(context, listId, params) {
return await knex.transaction(async tx => { 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( return await dtHelpers.ajaxListTx(
tx, tx,
@ -229,18 +229,27 @@ async function listDTAjax(context, listId, params) {
async function listIdName(context, listId) { async function listIdName(context, listId) {
return await knex.transaction(async tx => { 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'); 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) { async function getById(context, listId, id) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments'); return getByIdTx(tx, context, listId, id);
const entity = await tx('segments').where({id, list: listId}).first();
entity.settings = JSON.parse(entity.settings);
return entity;
}); });
} }
@ -321,6 +330,8 @@ async function updateWithConsistencyCheck(context, listId, entity) {
async function removeTx(tx, context, listId, id) { async function removeTx(tx, context, listId, id) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments'); 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 // 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(); await tx('segments').where({list: listId, id}).del();
} }
@ -402,6 +413,7 @@ Object.assign(module.exports, {
listDTAjax, listDTAjax,
listIdName, listIdName,
getById, getById,
getByIdTx,
create, create,
updateWithConsistencyCheck, updateWithConsistencyCheck,
remove, remove,

View file

@ -21,7 +21,7 @@ function hash(entity) {
async function listDTAjax(context, params) { async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
[{ entityTypeId: 'sendConfiguration', requiredOperations: ['view'] }], [{ entityTypeId: 'sendConfiguration', requiredOperations: ['viewPublic'] }],
params, params,
builder => builder builder => builder
.from('send_configurations') .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 => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', id, 'view'); let entity;
const entity = await tx('send_configurations').where('id', id).first();
entity.mailer_settings = JSON.parse(entity.mailer_settings); 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 // note that permissions are optional as as this methods may be used with synthetic admin context
if (withPermissions) { if (withPermissions) {

View file

@ -415,7 +415,7 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta
} }
if ((isCreate && !(meta && meta.update)) || 'status' in entity) { 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) { for (const key in groupedFieldsMap) {

View file

@ -40,6 +40,10 @@ async function listDTAjax(context, params) {
} }
async function _validateAndPreprocess(tx, entity) { 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); entity.data = JSON.stringify(entity.data);
} }
@ -49,8 +53,6 @@ async function create(context, entity) {
await _validateAndPreprocess(tx, entity); await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity);
const ids = await tx('templates').insert(filterObject(entity, allowedKeys)); const ids = await tx('templates').insert(filterObject(entity, allowedKeys));
const id = ids[0]; const id = ids[0];
@ -78,7 +80,6 @@ async function updateWithConsistencyCheck(context, entity) {
await _validateAndPreprocess(tx, entity); await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete'); await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');
await tx('templates').where('id', entity.id).update(filterObject(entity, allowedKeys)); await tx('templates').where('id', entity.id).update(filterObject(entity, allowedKeys));

View file

@ -10,5 +10,28 @@ router.postAsync('/campaigns-table', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listDTAjax(req.context, req.body)); 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; module.exports = router;

View file

@ -6,8 +6,14 @@ const sendConfigurations = require('../../models/send-configurations');
const router = require('../../lib/router-async').create(); const router = require('../../lib/router-async').create();
router.getAsync('/send-configurations/:sendConfigurationId', passport.loggedIn, async (req, res) => { router.getAsync('/send-configurations-private/:sendConfigurationId', passport.loggedIn, async (req, res) => {
const sendConfiguration = await sendConfigurations.getById(req.context, req.params.sendConfigurationId); 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); sendConfiguration.hash = sendConfigurations.hash(sendConfiguration);
return res.json(sendConfiguration); return res.json(sendConfiguration);
}); });

View file

@ -44,7 +44,7 @@ OK | namespace | int(10) unsigned | NO | MUL | NULL
New columns: New columns:
+-------------------------+---------------------+------+-----+-------------------+----------------+ +-------------------------+---------------------+------+-----+-------------------+----------------+
| data | longtext | NO | | NULL | | | data | longtext | NO | | NULL | |
| source_type | int(10) unsigned | NO | | | | | source | int(10) unsigned | NO | | | |
| send_configuration | 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 { getSystemSendConfigurationId } = require('../../../shared/send-configurations');
const { CampaignSource, CampaignType} = require('../../../shared/campaigns');
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
};
exports.up = (knex, Promise) => (async() => { exports.up = (knex, Promise) => (async() => {
await knex.schema.table('campaigns', table => { await knex.schema.table('campaigns', table => {
table.text('data', 'longtext'); 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 // 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()); 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) { for (const campaign of campaigns) {
const data = {}; 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) { if (campaign.template) {
let editorType = campaign.editor_name; let editorType = campaign.editor_name;
const editorData = JSON.parse(campaign.editor_data || '{}'); const editorData = JSON.parse(campaign.editor_data || '{}');
@ -106,21 +81,26 @@ exports.up = (knex, Promise) => (async() => {
editorType = 'ckeditor'; editorType = 'ckeditor';
} }
campaign.source_type = CampaignSource.CUSTOM; campaign.source = CampaignSource.CUSTOM_FROM_TEMPLATE;
data.source = { data.sourceCustom = {
type: editorType, type: editorType,
data: editorData, data: editorData,
html: campaign.html, html: campaign.html,
text: campaign.text, text: campaign.text,
htmlPrepared: campaign.html_prepared htmlPrepared: campaign.html_prepared
}; };
data.sourceTemplate = campaign.template;
// For source === CampaignSource.TEMPLATE, the data is as follows:
// data.sourceTemplate = <template id>
} else { } else {
campaign.source_type = CampaignSource.URL; campaign.source = CampaignSource.URL;
data.sourceUrl = campaign.source_url; data.sourceUrl = campaign.source_url;
} }
} else if (campaign.type === CampaignType.RSS) { } else if (campaign.type === CampaignType.RSS) {
campaign.source_type = CampaignSource.RSS; campaign.source = CampaignSource.RSS;
data.feedUrl = campaign.source_url; data.feedUrl = campaign.source_url;
data.checkStatus = campaign.checkStatus; data.checkStatus = campaign.checkStatus;
@ -150,6 +130,8 @@ exports.up = (knex, Promise) => (async() => {
table.integer('send_configuration').unsigned().notNullable().alter(); table.integer('send_configuration').unsigned().notNullable().alter();
}); });
await knex.schema.dropTableIfExists('campaign');
await knex.schema.dropTableIfExists('campaign_tracker');
})(); })();
exports.down = (knex, Promise) => (async() => { exports.down = (knex, Promise) => (async() => {

View file

@ -0,0 +1,6 @@
exports.up = (knex, Promise) => (async() => {
await knex.schema.dropTableIfExists('subscription');
})();
exports.down = (knex, Promise) => (async() => {
})();

47
shared/campaigns.js Normal file
View file

@ -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
};

View file

@ -1,20 +1,26 @@
'use strict'; 'use strict';
const UnsubscriptionMode = { const UnsubscriptionMode = {
MIN: 0,
ONE_STEP: 0, ONE_STEP: 0,
ONE_STEP_WITH_FORM: 1, ONE_STEP_WITH_FORM: 1,
TWO_STEP: 2, TWO_STEP: 2,
TWO_STEP_WITH_FORM: 3, TWO_STEP_WITH_FORM: 3,
MANUAL: 4, MANUAL: 4,
MAX: 5
MAX: 4
}; };
const SubscriptionStatus = { const SubscriptionStatus = {
MIN: 0,
SUBSCRIBED: 1, SUBSCRIBED: 1,
UNSUBSCRIBED: 2, UNSUBSCRIBED: 2,
BOUNCED: 3, BOUNCED: 3,
COMPLAINED: 4, COMPLAINED: 4,
MAX: 5
MAX: 4
}; };
function getFieldKey(field) { function getFieldKey(field) {

View file

@ -1,11 +1,14 @@
'use strict'; 'use strict';
const ReportState = { const ReportState = {
MIN: 0,
SCHEDULED: 0, SCHEDULED: 0,
PROCESSING: 1, PROCESSING: 1,
FINISHED: 2, FINISHED: 2,
FAILED: 3, FAILED: 3,
MAX: 4
MAX: 3
}; };
module.exports = { module.exports = {