- "Channels" feature
- Shoutout config param rendered on the homepage
- "Clone" feature for campaigns
This commit is contained in:
Tomas Bures 2020-07-17 14:53:48 +02:00
parent 00432e6cfe
commit d170548cfa
25 changed files with 1009 additions and 525 deletions

View file

@ -298,7 +298,7 @@ defaultRoles:
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]
channel: [view, edit, delete, createCampaign, 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]
@ -308,13 +308,13 @@ defaultRoles:
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.
description: In the respective namespace, the user has all permissions for managing lists, channels, templates and campaigns and the permission to send to send configurations.
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]
channel: [view, edit, delete, createCampaign, 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]
@ -325,7 +325,7 @@ defaultRoles:
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, createChannel, createCampaign]
permissions: [view, createTemplate, createCampaign]
children:
sendConfiguration: [viewPublic]
channel: [view]

View file

@ -11,14 +11,14 @@ async function validateEntity(tx, entity) {
}
}
async function validateMove(context, entity, existing, entityTypeId, createOperation, deleteOperation) {
async function validateMoveTx(tx, context, entity, existing, entityTypeId, createOperation, deleteOperation) {
if (existing.namespace !== entity.namespace) {
await shares.enforceEntityPermission(context, 'namespace', entity.namespace, createOperation);
await shares.enforceEntityPermission(context, entityTypeId, entity.id, deleteOperation);
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, createOperation);
await shares.enforceEntityPermissionTx(tx, context, entityTypeId, entity.id, deleteOperation);
}
}
module.exports = {
validateEntity,
validateMove
validateMoveTx
};

View file

@ -357,7 +357,7 @@ async function rawGetByTx(tx, key, id) {
.leftJoin('campaign_lists', 'campaigns.id', 'campaign_lists.campaign')
.groupBy('campaigns.id')
.select([
'campaigns.id', 'campaigns.cid', 'campaigns.name', 'campaigns.description', 'campaigns.namespace', 'campaigns.status', 'campaigns.type', 'campaigns.source',
'campaigns.id', 'campaigns.cid', 'campaigns.name', 'campaigns.description', 'campaigns.channel', 'campaigns.namespace', 'campaigns.status', 'campaigns.type', 'campaigns.source',
'campaigns.send_configuration', 'campaigns.from_name_override', 'campaigns.from_email_override', 'campaigns.reply_to_override', 'campaigns.subject',
'campaigns.data', 'campaigns.click_tracking_disabled', 'campaigns.open_tracking_disabled', 'campaigns.unsubscribe_url', 'campaigns.scheduled',
'campaigns.delivered', 'campaigns.unsubscribed', 'campaigns.bounced', 'campaigns.complained', 'campaigns.blacklisted', 'campaigns.opened', 'campaigns.clicks',
@ -412,6 +412,7 @@ async function getByIdTx(tx, context, id, withPermissions = true, content = Cont
} else if (content === Content.ONLY_SOURCE_CUSTOM) {
entity = {
id: entity.id,
channel: entity.channel,
send_configuration: entity.send_configuration,
data: {
@ -454,6 +455,8 @@ async function _validateAndPreprocess(tx, context, entity, isCreate, content) {
if (entity.source === CampaignSource.TEMPLATE || 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');
}
enforce(Number.isInteger(entity.source));
@ -481,6 +484,10 @@ async function _createTx(tx, context, entity, content) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCampaign');
if (entity.channel) {
await shares.enforceEntityPermissionTx(tx, context, 'channel', entity.channel, 'createCampaign');
}
let copyFilesFrom = null;
if (entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
copyFilesFrom = {
@ -570,6 +577,13 @@ async function createRssTx(tx, context, entity) {
return await _createTx(tx, context, entity, Content.RSS_ENTRY);
}
async function _validateChannelMoveTx(tx, context, entity, existing) {
if (existing.channel !== entity.channel) {
await shares.enforceEntityPermission(context, 'channel', entity.channel, 'createCampaign');
await shares.enforceEntityPermission(context, 'campaign', entity.id, 'delete');
}
}
async function updateWithConsistencyCheck(context, entity, content) {
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', entity.id, 'edit');
@ -585,11 +599,13 @@ async function updateWithConsistencyCheck(context, entity, content) {
let filteredEntity = filterObject(entity, allowedKeysUpdate);
if (content === Content.ALL) {
await namespaceHelpers.validateMove(context, entity, existing, 'campaign', 'createCampaign', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'campaign', 'createCampaign', 'delete');
await _validateChannelMoveTx(tx, context, entity, existing);
} else if (content === Content.WITHOUT_SOURCE_CUSTOM) {
filteredEntity.data.sourceCustom = existing.data.sourceCustom;
await namespaceHelpers.validateMove(context, entity, existing, 'campaign', 'createCampaign', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'campaign', 'createCampaign', 'delete');
await _validateChannelMoveTx(tx, context, entity, existing);
} else if (content === Content.ONLY_SOURCE_CUSTOM) {
const data = existing.data;

View file

@ -16,8 +16,8 @@ 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'];
const allowedKeys = new Set(['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) {
@ -43,12 +43,27 @@ async function listDTAjax(context, params) {
);
}
async function _getByTx(tx, key, id, withPermissions = true) {
async function listWithCreateCampaignPermissionDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'channel', requiredOperations: ['createCampaign'] }],
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, context, 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.id', 'channels.name', 'channels.cid', 'channels.description', 'channels.namespace', 'channels.cpg_name', 'channels.cpg_description', '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`)
@ -82,7 +97,7 @@ async function _getByTx(tx, key, id, withPermissions = true) {
async function getByIdTx(tx, context, id, withPermissions = true) {
await shares.enforceEntityPermissionTx(tx, context, 'channel', id, 'view');
return await _getByTx(tx, 'id', id, withPermissions);
return await _getByTx(tx, context, 'id', id, withPermissions);
}
async function getById(context, id, withPermissions = true) {
@ -97,12 +112,13 @@ async function _validateAndPreprocess(tx, context, entity, isCreate) {
if (entity.source !== null) {
enforce(Number.isInteger(entity.source));
if (entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
if (entity.source === CampaignSource.TEMPLATE || 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 if (entity.source === CampaignSource.URL) {
} else {
enforce(false, 'Unknown channel source');
}
@ -156,7 +172,7 @@ 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 existing = await _getByTx(tx, context, 'id', entity.id, false);
const existingHash = hash(existing);
if (existingHash !== entity.originalHash) {
@ -166,7 +182,7 @@ async function updateWithConsistencyCheck(context, entity) {
await _validateAndPreprocess(tx, context, entity, false);
let filteredEntity = filterObject(entity, allowedKeys);
await namespaceHelpers.validateMove(context, entity, existing, 'channel', 'createCampaign', 'delete');
await namespaceHelpers.validateMoveTx(tx, 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})));
@ -197,8 +213,8 @@ async function remove(context, id) {
module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax;
module.exports.listWithCreateCampaignPermissionDTAjax = listWithCreateCampaignPermissionDTAjax;
module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById;
module.exports.create = create;

View file

@ -169,7 +169,7 @@ async function updateWithConsistencyCheck(context, entity) {
}
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'customForm', 'createCustomForm', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'customForm', 'createCustomForm', 'delete');
const form = filterObject(entity, allowedFormKeys);
enforce(!Object.keys(checkForMjmlErrors(form)).length, 'Error(s) in form templates');

View file

@ -263,7 +263,7 @@ async function updateWithConsistencyCheck(context, entity) {
await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'list', 'createList', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'list', 'createList', 'delete');
await tx('lists').where('id', entity.id).update(filterObject(entity, allowedKeys));

View file

@ -89,7 +89,7 @@ async function updateWithConsistencyCheck(context, entity) {
await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'mosaicoTemplate', 'createMosaicoTemplate', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'mosaicoTemplate', 'createMosaicoTemplate', 'delete');
await tx('mosaico_templates').where('id', entity.id).update(filterObject(entity, allowedKeys));

View file

@ -198,7 +198,7 @@ async function updateWithConsistencyCheck(context, entity) {
}
// namespaceHelpers.validateEntity is not needed here because it is part of the tree traversal check below
await namespaceHelpers.validateMove(context, entity, existing, 'namespace', 'createNamespace', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'namespace', 'createNamespace', 'delete');
let iter = entity;
while (iter.namespace != null) {

View file

@ -66,7 +66,7 @@ async function updateWithConsistencyCheck(context, entity) {
}
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'reportTemplate', 'createReportTemplate', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'reportTemplate', 'createReportTemplate', 'delete');
await tx('report_templates').where('id', entity.id).update(filterObject(entity, allowedKeys));

View file

@ -102,7 +102,7 @@ async function updateWithConsistencyCheck(context, entity) {
}
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'report', 'createReport', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'report', 'createReport', 'delete');
entity.params = JSON.stringify(entity.params);

View file

@ -155,7 +155,7 @@ async function updateWithConsistencyCheck(context, entity) {
await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'sendConfiguration', 'createSendConfiguration', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'sendConfiguration', 'createSendConfiguration', 'delete');
await tx('send_configurations').where('id', entity.id).update(filterObject(entity, allowedKeys));

View file

@ -132,7 +132,7 @@ async function updateWithConsistencyCheck(context, entity) {
await _validateAndPreprocess(tx, entity);
entity.data = JSON.stringify(entity.data);
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');
await namespaceHelpers.validateMoveTx(tx, context, entity, existing, 'template', 'createTemplate', 'delete');
const filteredEntity = filterObject(entity, allowedKeys);

View file

@ -11,6 +11,10 @@ router.postAsync('/channels-table', passport.loggedIn, async (req, res) => {
return res.json(await channels.listDTAjax(req.context, req.body));
});
router.postAsync('/channels-with-create-campaign-permission-table', passport.loggedIn, async (req, res) => {
return res.json(await channels.listWithCreateCampaignPermissionDTAjax(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);
@ -25,7 +29,7 @@ router.putAsync('/channels/:channelId', passport.loggedIn, passport.csrfProtecti
const entity = req.body;
entity.id = castToInteger(req.params.channelId);
await channels.updateWithConsistencyCheck(req.context);
await channels.updateWithConsistencyCheck(req.context, entity);
return res.json();
});