- "Channels" feature
- Shoutout config param rendered on the homepage
- "Clone" feature for campaigns
This commit is contained in:
Tomas Bures 2020-06-20 08:09:23 +02:00
parent 82251d1cb9
commit 00432e6cfe
23 changed files with 1691 additions and 494 deletions

View file

@ -1,6 +1,7 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from './lib/i18n';
import {requiresAuthenticatedUser} from './lib/page';
import {withComponentMixins} from "./lib/decorator-helpers";
@ -14,6 +15,10 @@ export default class List extends Component {
super(props);
}
static propTypes = {
configItems: PropTypes.object
}
render() {
const t = this.props.t;
@ -21,6 +26,7 @@ export default class List extends Component {
<div>
<h2>{t('Mailtrain 2 beta')}</h2>
<div>{t('Build') + ' 2020-05-28-0102'}</div>
<p>{this.props.configItems.shoutout}</p>
</div>
);
}

View file

@ -79,9 +79,20 @@ export default class CUD extends Component {
[CampaignSource.URL]: t('url')
};
const sourceLabelsOrder = [
CampaignSource.CUSTOM, CampaignSource.CUSTOM_FROM_CAMPAIGN , CampaignSource.TEMPLATE, CampaignSource.CUSTOM_FROM_TEMPLATE, CampaignSource.URL
];
let sourceLabelsOrder;
if (props.createFromChannel) {
// If a campaign is created within a channel, we allow only for those source types that makes sense
sourceLabelsOrder = [
CampaignSource.CUSTOM, CampaignSource.CUSTOM_FROM_CAMPAIGN, CampaignSource.CUSTOM_FROM_TEMPLATE
];
} else {
// Regular creation or createFromCampaign
sourceLabelsOrder = [
CampaignSource.CUSTOM, CampaignSource.CUSTOM_FROM_CAMPAIGN , CampaignSource.TEMPLATE, CampaignSource.CUSTOM_FROM_TEMPLATE, CampaignSource.URL
];
}
this.sourceOptions = [];
for (const key of sourceLabelsOrder) {
@ -116,6 +127,8 @@ export default class CUD extends Component {
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object,
createFromChannel: PropTypes.object,
createFromCampaign: PropTypes.object,
permissions: PropTypes.object,
type: PropTypes.number
}
@ -298,61 +311,209 @@ export default class CUD extends Component {
}
} else {
const data = {};
for (const overridable of campaignOverridables) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
}
const lstUid = this.getNextListEntryId();
const lstPrefix = 'lists_' + lstUid + '_';
this.populateFormValues({
...data,
if (this.props.createFromChannel) {
const channel = this.props.createFromChannel;
type: this.props.type,
for (const overridable of campaignOverridables) {
if (channel[overridable + '_override'] === null) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
} else {
data[overridable + '_override'] = channel[overridable + '_override'];
data[overridable + '_overriden'] = true;
}
}
name: '',
description: '',
const lsts = [];
for (const lst of channel.lists) {
const lstUid = this.getNextListEntryId();
[lstPrefix + 'list']: null,
[lstPrefix + 'segment']: null,
[lstPrefix + 'useSegmentation']: false,
lists: [lstUid],
const prefix = 'lists_' + lstUid + '_';
send_configuration: null,
namespace: getDefaultNamespace(this.props.permissions),
data[prefix + 'list'] = lst.list;
data[prefix + 'segment'] = lst.segment;
data[prefix + 'useSegmentation'] = !!lst.segment;
subject: '',
lsts.push(lstUid);
}
data.lists = lsts;
click_tracking_disabled: false,
open_tracking_disabled: false,
data.type = CampaignType.REGULAR;
unsubscribe_url: '',
data.name = channel.cpg_name;
data.description = channel.cpg_description;
data.send_configuration = channel.send_configuration;
if (channel.send_configuration) {
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(channel.send_configuration);
}
data.namespace = channel.namespace;
data.subject = channel.subject;
data.click_tracking_disabled = channel.click_tracking_disabled;
data.open_tracking_disabled = channel.open_tracking_disabled;
data.unsubscribe_url = channel.unsubscribe_url;
data.source = channel.source;
if (channel.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
data.data_sourceTemplate = channel.sourceTemplate;
}
if (channel.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
channel.data_sourceCampaign = channel.data.sourceCampaign;
}
if (channel.source === CampaignSource.CUSTOM) {
data.data_sourceCustom_type = channel.data.sourceCustom.type;
data.data_sourceCustom_tag_language = channel.data.sourceCustom.tag_language;
data.data_sourceCustom_data = channel.data.sourceCustom.data;
this.templateTypes[channel.data.sourceCustom.type].afterLoad(data);
}
} else if (this.props.createFromCampaign) {
const sourceCampaign = this.props.createFromCampaign;
for (const overridable of campaignOverridables) {
if (sourceCampaign[overridable + '_override'] === null) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
} else {
data[overridable + '_override'] = sourceCampaign[overridable + '_override'];
data[overridable + '_overriden'] = true;
}
}
const lsts = [];
for (const lst of sourceCampaign.lists) {
const lstUid = this.getNextListEntryId();
const prefix = 'lists_' + lstUid + '_';
data[prefix + 'list'] = lst.list;
data[prefix + 'segment'] = lst.segment;
data[prefix + 'useSegmentation'] = !!lst.segment;
lsts.push(lstUid);
}
data.lists = lsts;
data.type = sourceCampaign.type;
data.name = sourceCampaign.name;
data.description = sourceCampaign.description;
data.send_configuration = sourceCampaign.send_configuration;
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(sourceCampaign.send_configuration);
data.namespace = sourceCampaign.namespace;
data.subject = sourceCampaign.subject;
data.click_tracking_disabled = sourceCampaign.click_tracking_disabled;
data.open_tracking_disabled = sourceCampaign.open_tracking_disabled;
data.unsubscribe_url = sourceCampaign.unsubscribe_url;
source: CampaignSource.CUSTOM,
// This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE
data_sourceTemplate: null,
data.data_sourceTemplate = null;
// This is for CampaignSource.CUSTOM_FROM_CAMPAIGN
data_sourceCampaign: null,
data.data_sourceCampaign = null;
// This is for CampaignSource.CUSTOM
data_sourceCustom_type: mailtrainConfig.editors[0],
data_sourceCustom_tag_language: mailtrainConfig.tagLanguages[0],
data_sourceCustom_data: {},
data_sourceCustom_html: '',
data_sourceCustom_text: '',
data.data_sourceCustom_type = mailtrainConfig.editors[0];
data.data_sourceCustom_tag_language = mailtrainConfig.tagLanguages[0];
data.data_sourceCustom_data = {};
data.data_sourceCustom_html = '';
data.data_sourceCustom_text = '';
...this.templateTypes[mailtrainConfig.editors[0]].initData(),
Object.assign(data, this.templateTypes[mailtrainConfig.editors[0]].initData());
// This is for CampaignSource.URL
data_sourceUrl: '',
data.data_sourceUrl = '';
// This is for CampaignType.RSS
data_feedUrl: ''
});
data.data_feedUrl = '';
if (sourceCampaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceCampaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN || sourceCampaign.source === CampaignSource.CUSTOM) {
data.source = CampaignSource.CUSTOM_FROM_CAMPAIGN;
data.data_sourceCampaign = sourceCampaign.id;
} else if (sourceCampaign.source === CampaignSource.TEMPLATE) {
data.source = CampaignSource.TEMPLATE;
data.data_sourceTemplate = sourceCampaign.data.sourceTemplate;
} else if (sourceCampaign.source === CampaignSource.URL) {
data.source = CampaignSource.URL;
data.data_sourceUrl = sourceCampaign.data.sourceUrl;
}
} else {
for (const overridable of campaignOverridables) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
}
data.type = this.props.type;
data.name = '';
data.description = '';
const lstUid = this.getNextListEntryId();
const lstPrefix = 'lists_' + lstUid + '_';
data[lstPrefix + 'list'] = null;
data[lstPrefix + 'segment'] = null;
data[lstPrefix + 'useSegmentation'] = false;
data.lists = [lstUid];
data.send_configuration = null;
data.namespace = getDefaultNamespace(this.props.permissions);
data.subject = '';
data.click_tracking_disabled = false;
data.open_tracking_disabled = false;
data.unsubscribe_url = '';
data.source = CampaignSource.CUSTOM;
// This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE
data.data_sourceTemplate = null;
// This is for CampaignSource.CUSTOM_FROM_CAMPAIGN
data.data_sourceCampaign = null;
// This is for CampaignSource.CUSTOM
data.data_sourceCustom_type = mailtrainConfig.editors[0];
data.data_sourceCustom_tag_language = mailtrainConfig.tagLanguages[0];
data.data_sourceCustom_data = {};
data.data_sourceCustom_html = '';
data.data_sourceCustom_text = '';
Object.assign(data, this.templateTypes[mailtrainConfig.editors[0]].initData());
// This is for CampaignSource.URL
data.data_sourceUrl = '';
// This is for CampaignType.RSS
data.data_feedUrl = '';
}
this.populateFormValues(data);
}
}

View file

@ -0,0 +1,123 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import {
AlignedRow,
Button,
ButtonRow,
CheckBox,
Dropdown,
Fieldset,
filterData,
Form,
FormSendMethod,
InputField,
StaticField,
TableSelect,
TextArea,
withForm,
withFormErrorHandlers
} from '../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {getTagLanguages, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import campaignsStyles from "./styles.scss";
import {getUrl} from "../lib/urls";
import {campaignOverridables, CampaignSource, CampaignStatus, CampaignType} from "../../../shared/campaigns";
import moment from 'moment';
import {getMailerTypes} from "../send-configurations/helpers";
import {getCampaignLabels} from "./helpers";
import {withComponentMixins} from "../lib/decorator-helpers";
import interoperableErrors from "../../../shared/interoperable-errors";
import {Trans} from "react-i18next";
import {Table} from "../lib/table";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Clone extends Component {
constructor(props) {
super(props);
const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
this.tagLanguages = getTagLanguages(props.t);
this.mailerTypes = getMailerTypes(props.t);
const { campaignTypeLabels } = getCampaignLabels(t);
this.campaignTypeLabels = campaignTypeLabels;
this.initForm({
leaveConfirmation: false,
});
}
static propTypes = {
}
componentDidMount() {
this.populateFormValues({
sourceCampaign: null
});
}
localValidateFormValues(state) {
const t = this.props.t;
const isEdit = !!this.props.entity;
for (const key of state.keys()) {
state.setIn([key, 'error'], null);
}
if (!state.getIn(['sourceCampaign', 'value'])) {
state.setIn(['sourceCampaign', 'error'], t('campaignMustBeSelected'));
}
}
@withFormErrorHandlers
async submitHandler(afterSubmitAction) {
const t = this.props.t;
const sourceCampaign = this.getFormValue('sourceCampaign');
this.navigateTo(`/campaigns/clone/${sourceCampaign}`);
}
render() {
const t = this.props.t;
const campaignsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.campaignTypeLabels[data] },
{ data: 9, title: t('created'), render: data => moment(data).fromNow() },
{ data: 10, title: t('namespace') }
];
return (
<div>
<Title>{t('Create campaign')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<TableSelect id="sourceCampaign" label={t('campaign')} withHeader dropdown dataUrl='rest/campaigns-table' columns={campaignsColumns} order={[4, 'desc']} selectionLabelIndex={1} help={t('Select campaign to be cloned.')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="chevron-right" label={t('Next')}/>
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -3,7 +3,7 @@
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {ButtonDropdown, Icon} from '../lib/bootstrap-components';
import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {DropdownLink, LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table';
import moment from 'moment';
@ -35,126 +35,156 @@ export default class List extends Component {
}
static propTypes = {
permissions: PropTypes.object
permissions: PropTypes.object,
channel: PropTypes.object
}
render() {
const t = this.props.t;
const channel = this.props.channel;
const permissions = this.props.permissions;
const createPermitted = permissions.createCampaign;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code>, className: styles.tblCol_id },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.campaignTypeLabels[data] },
{
data: 5,
title: t('status'),
render: (data, display, rowData) => {
if (data === CampaignStatus.SCHEDULED) {
const scheduled = rowData[6];
if (scheduled && new Date(scheduled) > new Date()) {
return t('sendingScheduled');
} else {
return t('sending');
}
} else {
return this.campaignStatusLabels[data];
}
}
},
{ data: 8, title: t('created'), render: data => moment(data).fromNow() },
{ data: 9, title: t('namespace') },
{
className: styles.tblCol_buttons,
actions: data => {
const actions = [];
const perms = data[10];
const campaignType = data[4];
const status = data[5];
const campaignSource = data[7];
if (perms.includes('view')) {
actions.push({
label: <Icon icon="envelope" title={t('status')}/>,
link: `/campaigns/${data[0]}/status`
});
}
if (perms.includes('viewStats')) {
actions.push({
label: <Icon icon="signal" title={t('statistics')}/>,
link: `/campaigns/${data[0]}/statistics`
});
}
if (perms.includes('view') || perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/campaigns/${data[0]}/edit`
});
}
if (perms.includes('edit') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
actions.push({
label: <Icon icon="align-center" title={t('content')}/>,
link: `/campaigns/${data[0]}/content`
});
}
if (perms.includes('viewFiles') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
actions.push({
label: <Icon icon="hdd" title={t('files')}/>,
link: `/campaigns/${data[0]}/files`
});
}
if (perms.includes('viewAttachments')) {
actions.push({
label: <Icon icon="paperclip" title={t('attachments')}/>,
link: `/campaigns/${data[0]}/attachments`
});
}
if (campaignType === CampaignType.TRIGGERED && perms.includes('viewTriggers')) {
actions.push({
label: <Icon icon="bell" title={t('triggers')}/>,
link: `/campaigns/${data[0]}/triggers`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share" title={t('share')}/>,
link: `/campaigns/${data[0]}/share`
});
}
tableAddDeleteButton(actions, this, perms, `rest/campaigns/${data[0]}`, data[1], t('deletingCampaign'), t('campaignDeleted'));
return actions;
const columns = [];
columns.push({
data: 1,
title: t('name'),
actions: data => {
const perms = data[10];
if (perms.includes('view')) {
return [{label: data[1], link: `/campaigns/${data[0]}/status`}];
} else {
return [{label: data[1]}];
}
}
];
});
columns.push({ data: 2, title: t('id'), render: data => <code>{data}</code>, className: styles.tblCol_id });
columns.push({ data: 3, title: t('description') });
columns.push({ data: 4, title: t('type'), render: data => this.campaignTypeLabels[data] });
if (!channel) {
columns.push({ data: 5, title: t('Channel') });
}
columns.push({
data: 6,
title: t('status'),
render: (data, display, rowData) => {
if (data === CampaignStatus.SCHEDULED) {
const scheduled = rowData[6];
if (scheduled && new Date(scheduled) > new Date()) {
return t('sendingScheduled');
} else {
return t('sending');
}
} else {
return this.campaignStatusLabels[data];
}
}
});
columns.push({ data: 9, title: t('created'), render: data => moment(data).fromNow() });
columns.push({ data: 10, title: t('namespace') });
columns.push({
className: styles.tblCol_buttons,
actions: data => {
const actions = [];
const perms = data[11];
const campaignType = data[4];
const campaignSource = data[8];
if (perms.includes('view')) {
actions.push({
label: <Icon icon="envelope" title={t('status')}/>,
link: `/campaigns/${data[0]}/status`
});
}
if (perms.includes('viewStats')) {
actions.push({
label: <Icon icon="signal" title={t('statistics')}/>,
link: `/campaigns/${data[0]}/statistics`
});
}
if (perms.includes('view') || perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/campaigns/${data[0]}/edit`
});
}
if (perms.includes('edit') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
actions.push({
label: <Icon icon="align-center" title={t('content')}/>,
link: `/campaigns/${data[0]}/content`
});
}
if (perms.includes('viewFiles') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
actions.push({
label: <Icon icon="hdd" title={t('files')}/>,
link: `/campaigns/${data[0]}/files`
});
}
if (perms.includes('viewAttachments')) {
actions.push({
label: <Icon icon="paperclip" title={t('attachments')}/>,
link: `/campaigns/${data[0]}/attachments`
});
}
if (campaignType === CampaignType.TRIGGERED && perms.includes('viewTriggers')) {
actions.push({
label: <Icon icon="bell" title={t('triggers')}/>,
link: `/campaigns/${data[0]}/triggers`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share" title={t('share')}/>,
link: `/campaigns/${data[0]}/share`
});
}
tableAddDeleteButton(actions, this, perms, `rest/campaigns/${data[0]}`, data[1], t('deletingCampaign'), t('campaignDeleted'));
return actions;
}
});
let createButton = null;
if (createPermitted) {
if (channel) {
createButton = <LinkButton to={`/channels/${channel.id}/create`} className="btn-primary" icon="plus" label={t('createCampaign')}/>;
} else {
createButton = (
<>
<LinkButton to={`/campaigns/clone`} className="btn-primary" icon="clone" label={t('Clone Campaign')}/>
<ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" icon="plus" label={t('createCampaign')}>
<DropdownLink to="/campaigns/create-regular">{t('regular')}</DropdownLink>
<DropdownLink to="/campaigns/create-rss">{t('rss')}</DropdownLink>
<DropdownLink to="/campaigns/create-triggered">{t('triggered')}</DropdownLink>
</ButtonDropdown>
</>
);
}
}
return (
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{createPermitted &&
<ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createCampaign')}>
<DropdownLink to="/campaigns/create-regular">{t('regular')}</DropdownLink>
<DropdownLink to="/campaigns/create-rss">{t('rss')}</DropdownLink>
<DropdownLink to="/campaigns/create-triggered">{t('triggered')}</DropdownLink>
</ButtonDropdown>
}
{createButton}
</Toolbar>
<Title>{t('campaigns')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl="rest/campaigns-table" columns={columns} order={[5, 'desc']} />
<Table ref={node => this.table = node} withHeader dataUrl="rest/campaigns-table" columns={columns} order={[6, 'desc']} />
</div>
);
}

View file

@ -18,6 +18,7 @@ import StatisticsOpened from "./StatisticsOpened";
import StatisticsLinkClicks from "./StatisticsLinkClicks";
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
import {namespaceCheckPermissions} from "../lib/namespace";
import Clone from "./Clone";
function getMenus(t) {
const aggLabels = {
@ -25,6 +26,12 @@ function getMenus(t) {
'devices': t('devices')
};
const createLabels = {
[CampaignType.REGULAR]: t('createRegularCampaign'),
[CampaignType.RSS]: t('createRssCampaign'),
[CampaignType.TRIGGERED]: t('createTriggeredCampaign')
};
return {
'campaigns': {
title: t('campaigns'),
@ -160,16 +167,30 @@ function getMenus(t) {
}
},
'create-regular': {
title: t('createRegularCampaign'),
title: createLabels[CampaignType.REGULAR],
panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} permissions={props.permissions} />
},
'create-rss': {
title: t('createRssCampaign'),
title: createLabels[CampaignType.RSS],
panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} permissions={props.permissions} />
},
'create-triggered': {
title: t('createTriggeredCampaign'),
title: createLabels[CampaignType.TRIGGERED],
panelRender: props => <CampaignsCUD action="create" type={CampaignType.TRIGGERED} permissions={props.permissions} />
},
'clone': {
title: t('Create Campaign'),
link: params => `/campaigns/clone`,
panelRender: props => <Clone />,
children: {
':existingCampaignId([0-9]+)': {
title: resolved => createLabels[resolved.existingCampaign.type],
resolve: {
existingCampaign: params => `rest/campaigns-settings/${params.existingCampaignId}`
},
panelRender: props => <CampaignsCUD action="create" createFromCampaign={props.resolved.existingCampaign} permissions={props.permissions} />
}
}
}
}
}

105
client/src/channels/List.js Normal file
View file

@ -0,0 +1,105 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {Icon} from '../lib/bootstrap-components';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table';
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
import styles from "./styles.scss";
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createChannel;
const columns = [
{
data: 1,
title: t('name'),
actions: data => {
const perms = data[10];
if (perms.includes('view')) {
return [{label: data[1], link: `/channels/${data[0]}/campaigns`}];
} else {
return [{label: data[1]}];
}
}
},
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('namespace') },
{
className: styles.tblCol_buttons,
actions: data => {
const actions = [];
const perms = data[5];
if (perms.includes('view')) {
actions.push({
label: <Icon icon="inbox" title={t('Campaigns')}/>,
link: `/channels/${data[0]}/campaigns`
});
}
if (perms.includes('view') || perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/channels/${data[0]}/edit`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share" title={t('share')}/>,
link: `/channels/${data[0]}/share`
});
}
tableAddDeleteButton(actions, this, perms, `rest/channels/${data[0]}`, data[1], t('Deleting channel ...'), t('Channel deleted'));
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{createPermitted &&
<LinkButton to="/channels/create" className="btn-primary" icon="plus" label={t('Create Channel')}/>
}
</Toolbar>
<Title>{t('Channels')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl="rest/channels-table" columns={columns} />
</div>
);
}
}

View file

@ -0,0 +1,79 @@
'use strict';
import React from 'react';
import CampaignsList from '../campaigns/List';
import CampaignsCUD from '../campaigns/CUD';
import ChannelsList from './List';
//import ChannelsCUD from './CUD';
import Share from '../shares/Share';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
return {
'channels': {
title: t('Channels'),
link: '/channels',
checkPermissions: {
createChannel: {
entityTypeId: 'namespace',
requiredOperations: ['createChannel']
},
createCampaign: {
entityTypeId: 'namespace',
requiredOperations: ['createCampaign']
},
...namespaceCheckPermissions('createChannel'),
},
panelRender: props => <ChannelsList permissions={props.permissions}/>,
children: {
':channelId([0-9]+)': {
title: resolved => t('channelName', {name: ellipsizeBreadcrumbLabel(resolved.channel.name)}),
resolve: {
channel: params => `rest/channels/${params.channelId}`
},
link: params => `/channels/${params.channelId}/campaigns`,
navs: {
campaigns: {
title: t('Campaigns'),
link: params => `/channels/${params.channelId}/campaigns`,
visible: resolved => resolved.channel.permissions.includes('view'),
panelRender: props => <CampaignsList channel={props.resolved.channel} />
},
/*
':action(edit|delete)': {
title: t('edit'),
link: params => `/channels/${params.channelId}/edit`,
visible: resolved => resolved.channel.permissions.includes('view') || resolved.channel.permissions.includes('edit'),
panelRender: props => <ChannelsCUD action={props.match.params.action} entity={props.resolved.channel} permissions={props.permissions} />
},
*/
share: {
title: t('share'),
link: params => `/channels/${params.channelId}/share`,
visible: resolved => resolved.channel.permissions.includes('share'),
panelRender: props => <Share title={t('share')} entity={props.resolved.channel} entityTypeId="channel" />
}
},
children: {
create: {
title: t('createCampaign'),
link: params => `/channels/${params.channelId}/create`,
visible: resolved => resolved.channel.permissions.includes('createCampaign'),
panelRender: props => <CampaignsCUD action="create" channel={props.resolved.channel} permissions={props.permissions} />
}
}
},
'create': {
title: t('Create Channel'),
panelRender: props => <ChannelsCUD action="create" permissions={props.permissions} />
}
}
}
};
}
export default {
getMenus
}

View file

View file

@ -111,6 +111,7 @@ export class Button extends Component {
export class ButtonDropdown extends Component {
static propTypes = {
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
icon: PropTypes.string,
className: PropTypes.string,
buttonClassName: PropTypes.string,
menuClassName: PropTypes.string
@ -119,19 +120,24 @@ export class ButtonDropdown extends Component {
render() {
const props = this.props;
const className = 'dropdown' + (props.className ? ' ' + props.className : '');
const className = 'btn-group' + (props.className ? ' ' + props.className : '');
const buttonClassName = 'btn dropdown-toggle' + (props.buttonClassName ? ' ' + props.buttonClassName : '');
const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : '');
return (
<div className="dropdown" className={className}>
<button type="button" className={buttonClassName} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{props.label}
</button>
<ul className={menuClassName}>
{props.children}
</ul>
let icon;
if (props.icon) {
icon = <Icon icon={props.icon}/>
}
let iconSpacer;
if (props.icon && props.label) {
iconSpacer = ' ';
}
return (
<div className={className}>
<button type="button" className={buttonClassName} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">{icon}{iconSpacer}{props.label}</button>
<ul className={menuClassName}>{props.children}</ul>
</div>
);
}

View file

@ -34,6 +34,8 @@ import styles from "./styles.scss";
import moment from "moment";
import {getUrl} from "./urls";
import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
import cudStyles from "../../../mvis/ivis-core/client/src/settings/jobs/CUD.scss";
import {campaignOverridables, CampaignSource, CampaignType} from "../../../shared/campaigns";
const FormState = {
@ -929,6 +931,207 @@ class ButtonRow extends Component {
}
@withComponentMixins([
withTranslation,
withFormStateOwner
], null, ['submitFormValuesMutator', 'getFormValueIdForPicker'])
class ListCreator extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
className: PropTypes.string,
withOrder: PropTypes.bool,
};
constructor(props) {
super(props);
this._nextEntryId = 0;
}
componentDidMount() {
const values = this.props.initValues;
if (values && values.length > 0) {
this.getFormStateOwner().updateForm(mutState => {
let entryIds = mutState.getIn([this.props.id, 'value']);
if (!entryIds) {
entryIds = [];
}
for (const entryValue of values) {
const entryId = this.getNextEntryId();
mutState.setIn([this.getFormValueId(entryId), 'value'], entryValue);
entryIds.push(entryId);
}
mutState.setIn([this.props.id, 'value'], entryIds);
});
}
}
static getFormValuesMutator(pickerId, data) {
const elems = [];
for (const elem of data[pickerId]) {
const uid = this.getNextEntryId();
const prefix = this.getFormValueId(uid);
data[prefix + 'list'] = elem.list;
data[prefix + 'segment'] = elem.segment;
data[prefix + 'useSegmentation'] = !!elem.segment;
elems.push(uid);
}
data[pickerId] = elems;
}
static submitFormValuesMutator(pickerId, data) {
const entryValues = [];
const entryIds = data[pickerId];
if (!entryIds) {
return entryValues;
}
for (const entryId of entryIds) {
const entryFormId = ListCreator.getFormValueIdForPicker(pickerId, entryId);
const value = data[entryFormId];
entryValues.push(value);
delete data[entryFormId]
}
data[pickerId] = entryValues;
}
static getFormValueIdForPicker(pickerId, entryId) {
return `${pickerId}_${entryId}`;
}
getFormValueId(entryId) {
return ListCreator.getFormValueIdForPicker(this.props.id, entryId);
}
getNextEntryId() {
return this._nextEntryId++;
}
onAddListEntry(positionBefore) {
this.getFormStateOwner().updateForm(mutState => {
let entryIds = mutState.getIn([this.props.id, 'value']);
if (!entryIds) {
entryIds = [];
}
if (positionBefore == null) {
positionBefore = entryIds.length;
}
const entryId = this.getNextEntryId();
mutState.setIn([this.getFormValueId(entryId), 'value'], null);
mutState.setIn([this.props.id, 'value'], [...entryIds.slice(0, positionBefore), entryId, ...entryIds.slice(positionBefore)]);
});
}
onRemoveSetEntry(entryId) {
this.getFormStateOwner().updateForm(mutState => {
const entryIds = mutState.getIn([this.props.id, 'value']);
mutState.delete(this.getFormValueId(entryId));
mutState.setIn([this.props.id, 'value'], entryIds.filter(id => id !== entryId));
});
}
onListEntryMoveUp(position) {
const owner = this.getFormStateOwner();
const entryIds = owner.getFormValue(this.props.id);
owner.updateFormValue(this.props.id, [...entryIds.slice(0, position - 1), entryIds[position], entryIds[position - 1], ...entryIds.slice(position + 1)]);
}
onListEntryMoveDown(position) {
const owner = this.getFormStateOwner();
const entryIds = owner.getFormValue(this.props.id);
owner.updateFormValue(this.props.id, [...entryIds.slice(0, position), entryIds[position + 1], entryIds[position], ...entryIds.slice(position + 2)]);
}
render() {
const props = this.props;
const owner = this.getFormStateOwner();
const id = props.id;
const t = props.t;
const withOrder = props.withOrder;
const entries = [];
const entryIds = owner.getFormValue(id) || [];
const entryButtonsStyles = withOrder ? cudStyles.entryButtonsWithOrder : cudStyles.entryButtons;
for (let pos = 0; pos < entryIds.length; pos++) {
const entryId = entryIds[pos];
const elementId = this.getFormValueId(entryId);
entries.push(
<div key={entryId}
className={cudStyles.entry + (withOrder ? ' ' + cudStyles.withOrder : '') + ' ' + cudStyles.entryWithButtons}>
<div className={entryButtonsStyles}>
<Button
className="btn-secondary"
icon={`trash-alt ${withOrder ? "" : "fa-2x"}`}
title={t('remove')}
onClickAsync={() => this.onRemoveSetEntry(entryId)}
/>
{withOrder &&
<Button
className="btn-secondary"
icon="plus"
title={t('Insert new entry before this one')}
onClickAsync={() => this.onAddListEntry(pos)}
/>
}
{withOrder && pos > 0 &&
<Button
className="btn-secondary"
icon="chevron-up"
title={t('Move up')}
onClickAsync={() => this.onListEntryMoveUp(pos)}
/>
}
{withOrder && pos < entryIds.length - 1 &&
<Button
className="btn-secondary"
icon="chevron-down"
title={t('Move down')}
onClickAsync={() => this.onListEntryMoveDown(pos)}
/>
}
</div>
<div className={cudStyles.entryContent}>
{React.cloneElement(this.props.entryElement, {id: elementId})}
</div>
</div>
);
}
return (
<Fieldset id={id} className={props.classname} help={props.help} flat={props.flat} label={props.label}>
{entries}
<div key="newEntry" className={cudStyles.newEntry}>
<Button
className="btn-secondary"
icon="plus"
label={t('Add entry')}
onClickAsync={() => this.onAddListEntry(entryIds.length)}
/>
</div>
</Fieldset>
);
}
}
@withComponentMixins([
withFormStateOwner
])
@ -984,6 +1187,7 @@ class TableSelect extends Component {
dataUrl: PropTypes.string,
data: PropTypes.array,
columns: PropTypes.array,
order: PropTypes.array,
selectionKeyIndex: PropTypes.number,
selectionLabelIndex: PropTypes.number,
selectionAsArray: PropTypes.bool,
@ -1076,6 +1280,7 @@ class TableSelect extends Component {
data={props.data}
dataUrl={props.dataUrl}
columns={props.columns}
order={props.order}
selectMode={props.selectMode}
selectionAsArray={this.props.selectionAsArray}
withHeader={props.withHeader}
@ -1094,6 +1299,7 @@ class TableSelect extends Component {
data={props.data}
dataUrl={props.dataUrl}
columns={props.columns}
order={props.order}
pageLength={props.pageLength}
selectMode={props.selectMode}
selectionAsArray={this.props.selectionAsArray}

View file

@ -163,6 +163,13 @@ export function getRoutes(structure, parentRoute) {
entryResolve = resolve;
}
let entryResolveWithLocal;
if (entry.localResolve) {
entryResolveWithLocal = Object.assign({}, entryResolve, entry.localResolve);
} else {
entryResolveWithLocal = entryResolve;
}
let entryCheckPermissions;
if (entry.checkPermissions) {
entryCheckPermissions = Object.assign({}, checkPermissions, entry.checkPermissions);
@ -199,7 +206,7 @@ export function getRoutes(structure, parentRoute) {
link: entry.link,
panelInFullScreen: entry.panelInFullScreen,
insideIframe: entry.insideIframe,
resolve: entryResolve,
resolve: entryResolveWithLocal,
checkPermissions: entryCheckPermissions,
parents,
navs: [...navs, ...entryNavs],

View file

@ -12,6 +12,7 @@ import lists from './lists/root';
import namespaces from './namespaces/root';
import reports from './reports/root';
import campaigns from './campaigns/root';
import channels from './channels/root';
import templates from './templates/root';
import users from './users/root';
import sendConfigurations from './send-configurations/root';
@ -25,8 +26,9 @@ import {DropdownActionLink, Icon} from "./lib/bootstrap-components";
import axios from './lib/axios';
import {getUrl} from "./lib/urls";
import {withComponentMixins} from "./lib/decorator-helpers";
import Update from "./settings/Update";
const topLevelMenuKeys = ['lists', 'templates', 'campaigns'];
const topLevelMenuKeys = ['lists', 'channels', 'templates', 'campaigns'];
if (mailtrainConfig.reportsEnabled) {
topLevelMenuKeys.push('reports');
@ -114,7 +116,10 @@ class Root extends Component {
structure = {
title: t('home'),
link: '/',
panelComponent: Home,
localResolve: {
configItems: params => `rest/settings`
},
panelRender: props => <Home configItems={props.resolved.configItems} />,
primaryMenuComponent: MainMenu,
children: {
...login.getMenus(t),
@ -127,7 +132,8 @@ class Root extends Component {
...account.getMenus(t),
...settings.getMenus(t),
...sendConfigurations.getMenus(t),
...campaigns.getMenus(t)
...campaigns.getMenus(t),
...channels.getMenus(t)
}
};

@ -1 +1 @@
Subproject commit f034b066a787aa87c5fd378fd09e5bce8f2e5618
Subproject commit 6daafcfcf20efd3c7aac6b1170bc22180d6cde24

View file

@ -40,6 +40,7 @@ const usersRest = require('./routes/rest/users');
const accountRest = require('./routes/rest/account');
const reportTemplatesRest = require('./routes/rest/report-templates');
const reportsRest = require('./routes/rest/reports');
const channelsRest = require('./routes/rest/channels');
const campaignsRest = require('./routes/rest/campaigns');
const triggersRest = require('./routes/rest/triggers');
const listsRest = require('./routes/rest/lists');
@ -300,6 +301,7 @@ async function createApp(appType) {
app.use('/rest', sendConfigurationsRest);
app.use('/rest', usersRest);
app.use('/rest', accountRest);
app.use('/rest', channelsRest);
app.use('/rest', campaignsRest);
app.use('/rest', triggersRest);
app.use('/rest', listsRest);

View file

@ -293,49 +293,53 @@ defaultRoles:
master:
name: Master
description: All permissions
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers]
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createChannel, createCampaign, manageUsers]
children:
sendConfiguration: [viewPublic, viewPrivate, edit, delete, share, sendWithoutOverrides, sendWithAllowedOverrides, sendWithAnyOverrides]
list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, viewTestSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
customForm: [view, edit, delete, share]
channel: [view, edit, delete, share]
campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles, sendToTestUsers]
report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, edit, delete, share, execute]
mosaicoTemplate: [view, edit, delete, share, viewFiles, manageFiles]
namespace: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers]
namespace: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createChannel, createCampaign, manageUsers]
campaignsAdmin:
name: Campaigns Admin
description: In the respective namespace, the user has all permissions for managing lists, templates and campaigns and the permission to send to send configurations.
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createCampaign]
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createChannel, createCampaign]
children:
sendConfiguration: [viewPublic, sendWithoutOverrides, sendWithAllowedOverrides]
list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, viewTestSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
customForm: [view, edit, delete, share]
channel: [view, edit, delete, share]
campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles, sendToTestUsers]
report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, share, execute]
mosaicoTemplate: [view, edit, delete, share, viewFiles, manageFiles]
namespace: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createCampaign]
namespace: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createChannel, createCampaign]
campaignsCreator:
name: Campaigns Creator
description: In the respective namespace, the user has all permissions to create and manage templates and campaigns. The user can also read public data about send configurations and use Mosaico templates in the namespace.
permissions: [view, createTemplate, createCampaign]
permissions: [view, createTemplate, createChannel, createCampaign]
children:
sendConfiguration: [viewPublic]
channel: [view]
campaign: [view, edit, delete, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, viewFiles, manageFiles, sendToTestUsers]
mosaicoTemplate: [view, viewFiles]
namespace: [view, createTemplate, createCampaign]
namespace: [view, createTemplate, createChannel, createCampaign]
campaignsViewer:
name: Campaigns Viewer
description: In the respective namespace, the user has permissions to view campaigns and templates in order to be able to replicate them.
permissions: [view, createTemplate, createCampaign]
children:
channel: [view]
campaign: [view, viewFiles, viewAttachments, viewTriggers]
template: [view, viewFiles]
mosaicoTemplate: [view, viewFiles]
@ -371,6 +375,16 @@ defaultRoles:
description: All permissions
permissions: [view, edit, delete, share]
channel:
master:
name: Master
description: All permissions
permissions: [view, edit, delete, createCampaign, share]
viewer:
name: Viewer
description: The user can view the channel but cannot edit it or delete it.
permissions: [view]
campaign:
master:
name: Master

View file

@ -54,6 +54,12 @@ const entityTypes = {
},
clientLink: id => `/campaigns/${id}`
},
channel: {
entitiesTable: 'channels',
sharesTable: 'shares_channel',
permissionsTable: 'permissions_channel',
clientLink: id => `/channels/${id}`
},
template: {
entitiesTable: 'templates',
sharesTable: 'shares_template',

View file

@ -28,7 +28,7 @@ const lists = require('./lists');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
const allowedKeysCommon = ['name', 'description', 'namespace', 'channel',
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
const allowedKeysCreate = new Set(['type', 'source', ...allowedKeysCommon]);
@ -67,7 +67,7 @@ function hash(entity, content) {
return hasher.hash(filteredEntity);
}
async function _listDTAjax(context, namespaceId, params) {
async function _listDTAjax(context, namespaceId, channelId, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
@ -75,22 +75,30 @@ async function _listDTAjax(context, namespaceId, params) {
builder => {
builder = builder.from('campaigns')
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace')
.leftJoin('channels', 'channels.id', 'campaigns.channel')
.whereNull('campaigns.parent');
if (namespaceId) {
builder = builder.where('namespaces.id', namespaceId);
}
if (channelId) {
builder = builder.where('channels.id', channelId);
}
return builder;
},
['campaigns.id', 'campaigns.name', 'campaigns.cid', 'campaigns.description', 'campaigns.type', 'campaigns.status', 'campaigns.scheduled', 'campaigns.source', 'campaigns.created', 'namespaces.name']
['campaigns.id', 'campaigns.name', 'campaigns.cid', 'campaigns.description', 'campaigns.type', 'channels.name', 'campaigns.status', 'campaigns.scheduled', 'campaigns.source', 'campaigns.created', 'namespaces.name']
);
}
async function listDTAjax(context, params) {
return await _listDTAjax(context, undefined, params);
return await _listDTAjax(context, undefined, undefined, params);
}
async function listByNamespaceDTAjax(context, namespaceId, params) {
return await _listDTAjax(context, namespaceId, params);
return await _listDTAjax(context, namespaceId, undefined, params);
}
async function listByChannelDTAjax(context, channelId, params) {
return await _listDTAjax(context, undefined, channelId, params);
}
async function listChildrenDTAjax(context, campaignId, params) {
@ -1102,6 +1110,7 @@ module.exports.Content = Content;
module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax;
module.exports.listByChannelDTAjax = listByChannelDTAjax;
module.exports.listByNamespaceDTAjax = listByNamespaceDTAjax;
module.exports.listChildrenDTAjax = listChildrenDTAjax;
module.exports.listWithContentDTAjax = listWithContentDTAjax;

206
server/models/channels.js Normal file
View file

@ -0,0 +1,206 @@
'use strict';
const knex = require('../lib/knex');
const hasher = require('node-object-hash')();
const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors');
const shortid = require('shortid');
const { enforce, filterObject } = require('../lib/helpers');
const shares = require('./shares');
const namespaceHelpers = require('../lib/namespace-helpers');
const { allTagLanguages } = require('../../shared/templates');
const { CampaignSource, } = require('../../shared/campaigns');
const segments = require('./segments');
const dependencyHelpers = require('../lib/dependency-helpers');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeys = ['name', 'description', 'namespace', 'cpg_name', 'cpg_description',
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url', 'source'];
function hash(entity) {
let filteredEntity;
filteredEntity = filterObject(entity, allowedKeys);
filteredEntity.lists = entity.lists;
return hasher.hash(filteredEntity);
}
async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'channel', requiredOperations: ['view'] }],
params,
builder => {
builder = builder.from('channels')
.innerJoin('namespaces', 'namespaces.id', 'channels.namespace');
return builder;
},
['channels.id', 'channels.name', 'channels.cid', 'channels.description', 'namespaces.name']
);
}
async function _getByTx(tx, key, id, withPermissions = true) {
const entity = await tx('channels').where('channels.' + key, id)
.leftJoin('channel_lists', 'channels.id', 'channel_lists.channel')
.groupBy('channels.id')
.select([
'channels.id', 'channels.name', 'channels.cid', 'channels.description', 'channels.namespace', 'channels.source',
'channels.send_configuration', 'channels.from_name_override', 'channels.from_email_override', 'channels.reply_to_override', 'channels.subject',
'channels.data', 'channels.click_tracking_disabled', 'channels.open_tracking_disabled', 'channels.unsubscribe_url',
knex.raw(`GROUP_CONCAT(CONCAT_WS(\':\', channel_lists.list, channel_lists.segment) ORDER BY channel_lists.id SEPARATOR \';\') as lists`)
])
.first();
if (!entity) {
throw new shares.throwPermissionDenied();
}
if (entity.lists) {
entity.lists = entity.lists.split(';').map(x => {
const entries = x.split(':');
const list = Number.parseInt(entries[0]);
const segment = entries[1] ? Number.parseInt(entries[1]) : null;
return {list, segment};
});
} else {
entity.lists = [];
}
entity.data = JSON.parse(entity.data);
if (withPermissions) {
entity.permissions = await shares.getPermissionsTx(tx, context, 'channel', id);
}
return entity;
}
async function getByIdTx(tx, context, id, withPermissions = true) {
await shares.enforceEntityPermissionTx(tx, context, 'channel', id, 'view');
return await _getByTx(tx, 'id', id, withPermissions);
}
async function getById(context, id, withPermissions = true) {
return await knex.transaction(async tx => {
return await getByIdTx(tx, context, id, withPermissions);
});
}
async function _validateAndPreprocess(tx, context, entity, isCreate) {
await namespaceHelpers.validateEntity(tx, entity);
if (entity.source !== null) {
enforce(Number.isInteger(entity.source));
if (entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
await shares.enforceEntityPermissionTx(tx, context, 'template', entity.data.sourceTemplate, 'view');
} else if (entity.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', entity.data.sourceCampaign, 'view');
} else if (entity.source === CampaignSource.CUSTOM) {
enforce(allTagLanguages.includes(entity.data.sourceCustom.tag_language), `Invalid tag language '${entity.data.sourceCustom.tag_language}'`);
} else {
enforce(false, 'Unknown channel source');
}
}
for (const lstSeg of entity.lists) {
await shares.enforceEntityPermissionTx(tx, context, 'list', lstSeg.list, 'view');
if (lstSeg.segment) {
// Check that the segment under the list exists
await segments.getByIdTx(tx, context, lstSeg.list, lstSeg.segment);
}
}
if (entity.send_configuration) {
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', entity.send_configuration, 'viewPublic');
}
}
async function _createTx(tx, context, entity, content) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCampaign');
await _validateAndPreprocess(tx, context, entity, true, content);
const filteredEntity = filterObject(entity, allowedKeys);
filteredEntity.cid = shortid.generate();
filteredEntity.data = JSON.stringify(filteredEntity.data);
const ids = await tx('channels').insert(filteredEntity);
const id = ids[0];
await tx('channel_lists').insert(entity.lists.map(x => ({channel: id, ...x})));
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'channel', entityId: id });
await activityLog.logEntityActivity('channel', EntityActivityType.CREATE, id, {});
return id;
});
}
async function create(context, entity) {
return await knex.transaction(async tx => {
return await _createTx(tx, context, entity);
});
}
async function updateWithConsistencyCheck(context, entity) {
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'channel', entity.id, 'edit');
const existing = await _getByTx(tx, 'id', entity.id, false);
const existingHash = hash(existing);
if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
await _validateAndPreprocess(tx, context, entity, false);
let filteredEntity = filterObject(entity, allowedKeys);
await namespaceHelpers.validateMove(context, entity, existing, 'channel', 'createCampaign', 'delete');
await tx('channel_lists').where('channel', entity.id).del();
await tx('channel_lists').insert(entity.lists.map(x => ({channel: entity.id, ...x})));
filteredEntity.data = JSON.stringify(filteredEntity.data);
await tx('channels').where('id', entity.id).update(filteredEntity);
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'channel', entityId: entity.id });
await activityLog.logEntityActivity('channel', EntityActivityType.UPDATE, entity.id, {});
});
}
async function remove(context, id) {
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'channel', id, 'delete');
await dependencyHelpers.ensureNoDependencies(tx, context, id, [
{ entityTypeId: 'campaign', column: 'channel' }
]);
await tx('channels').where('id', id).del();
await activityLog.logEntityActivity('channel', EntityActivityType.REMOVE, id);
});
}
module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax;
module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById;
module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove;

View file

@ -735,6 +735,8 @@ async function _removeAndGetTx(tx, context, listId, existing) {
if (existing.status === SubscriptionStatus.SUBSCRIBED) {
await tx('lists').where('id', listId).decrement('subscribers', 1);
}
return existing;
}
async function remove(context, listId, id) {

View file

@ -11,6 +11,10 @@ router.postAsync('/campaigns-table', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listDTAjax(req.context, req.body));
});
router.postAsync('/campaigns-by-channel-table/:channelId', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listByChannelDTAjax(req.context, castToInteger(req.params.channelId), req.body));
});
router.postAsync('/campaigns-with-content-table', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listWithContentDTAjax(req.context, req.body));
});

View file

@ -0,0 +1,38 @@
'use strict';
const passport = require('../../lib/passport');
const channels = require('../../models/channels');
const router = require('../../lib/router-async').create();
const {castToInteger} = require('../../lib/helpers');
router.postAsync('/channels-table', passport.loggedIn, async (req, res) => {
return res.json(await channels.listDTAjax(req.context, req.body));
});
router.getAsync('/channels/:channelId', passport.loggedIn, async (req, res) => {
const channel = await channels.getById(req.context, castToInteger(req.params.channelId), true);
channel.hash = channels.hash(channel);
return res.json(channel);
});
router.postAsync('/channels', passport.loggedIn, passport.csrfProtection, async (req, res) => {
return res.json(await channels.create(req.context, req.body));
});
router.putAsync('/channels/:channelId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const entity = req.body;
entity.id = castToInteger(req.params.channelId);
await channels.updateWithConsistencyCheck(req.context);
return res.json();
});
router.deleteAsync('/channels/:channelId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await channels.remove(req.context, castToInteger(req.params.channelId));
return res.json();
});
module.exports = router;

View file

@ -0,0 +1,54 @@
exports.up = (knex, Promise) => (async() => {
await knex.schema.createTable('channels', table => {
table.increments('id').primary();
table.string('cid').unique().collate('utf8_general_ci');
table.string('name');
table.text('description');
table.string('cpg_name');
table.text('cpg_description');
table.string('from_email_override');
table.string('from_name_override');
table.string('reply_to_override');
table.string('subject');
table.integer('send_configuration').unsigned().references(`send_configurations.id`);
table.integer('source').unsigned().notNullable();
table.text('data', 'longtext');
table.boolean('click_tracking_disabled').defaultTo(false);
table.boolean('open_tracking_disabled').defaultTo(false);
table.string('unsubscribe_url');
table.timestamp('created').defaultTo(knex.fn.now());
table.integer('namespace').unsigned().references('namespaces.id');
});
await knex.schema.createTable('channel_lists', table => {
table.increments('id').primary();
table.integer('channel').unsigned().notNullable().references('channels.id');
table.integer('list').unsigned().notNullable().references('lists.id');
table.integer('segment').unsigned().references('segments.id');
});
await knex.schema.table('campaigns', table => {
table.integer('channel').unsigned().references('channels.id');
});
const entityType = 'channel';
await knex.schema
.createTable(`shares_${entityType}`, table => {
table.integer('entity').unsigned().notNullable().references(`${entityType}s.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('role', 128).notNullable();
table.boolean('auto').defaultTo(false);
table.primary(['entity', 'user']);
})
.createTable(`permissions_${entityType}`, table => {
table.integer('entity').unsigned().notNullable().references(`${entityType}s.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('operation', 128).notNullable();
table.primary(['entity', 'user', 'operation']);
});
})();
exports.down = (knex, Promise) => (async() => {
})();

File diff suppressed because it is too large Load diff