- "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

@ -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() => {
})();