Panels with campaign statistics and some fixes in computation of clicks.
This commit is contained in:
parent
ba996d845d
commit
d103a2cc79
18 changed files with 811 additions and 96 deletions
|
@ -5,6 +5,8 @@ const config = require('config');
|
|||
const forms = require('../models/forms');
|
||||
const shares = require('../models/shares');
|
||||
const urls = require('./urls');
|
||||
const settings = require('../models/settings');
|
||||
const contextHelpers = require('./context-helpers');
|
||||
|
||||
|
||||
async function getAnonymousConfig(context, appType) {
|
||||
|
@ -31,6 +33,8 @@ async function getAuthenticatedConfig(context) {
|
|||
globalPermissions[perm] = true;
|
||||
}
|
||||
|
||||
const setts = await settings.get(contextHelpers.getAdminContext(), ['mapsApiKey']);
|
||||
|
||||
return {
|
||||
defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
|
||||
user: {
|
||||
|
@ -42,7 +46,8 @@ async function getAuthenticatedConfig(context) {
|
|||
editors: config.editors,
|
||||
mosaico: config.mosaico,
|
||||
verpEnabled: config.verp.enabled,
|
||||
reportsEnabled: config.reports.enabled
|
||||
reportsEnabled: config.reports.enabled,
|
||||
mapsApiKey: setts.mapsApiKey
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,13 +10,14 @@ const shares = require('./shares');
|
|||
const namespaceHelpers = require('../lib/namespace-helpers');
|
||||
const files = require('./files');
|
||||
const templates = require('./templates');
|
||||
const { CampaignStatus, CampaignSource, CampaignType, getSendConfigurationPermissionRequiredForSend} = require('../../shared/campaigns');
|
||||
const { CampaignStatus, CampaignSource, CampaignType, getSendConfigurationPermissionRequiredForSend } = require('../../shared/campaigns');
|
||||
const sendConfigurations = require('./send-configurations');
|
||||
const triggers = require('./triggers');
|
||||
const {SubscriptionStatus} = require('../../shared/lists');
|
||||
const subscriptions = require('./subscriptions');
|
||||
const segments = require('./segments');
|
||||
const senders = require('../lib/senders');
|
||||
const {LinkId} = require('./links');
|
||||
|
||||
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
|
||||
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
|
||||
|
@ -158,7 +159,8 @@ async function listTestUsersDTAjax(context, campaignId, params) {
|
|||
subsQry = knex.raw(subsUnionSql, subsUnionBindings);
|
||||
}
|
||||
|
||||
return await dtHelpers.ajaxListWithPermissions(
|
||||
return await dtHelpers.ajaxListWithPermissionsTx(
|
||||
tx,
|
||||
context,
|
||||
[{ entityTypeId: 'list', requiredOperations: ['viewSubscriptions'], column: 'subs.list_id' }],
|
||||
params,
|
||||
|
@ -191,6 +193,115 @@ async function listTestUsersDTAjax(context, campaignId, params) {
|
|||
});
|
||||
}
|
||||
|
||||
async function _listSubscriberResultsDTAjax(context, campaignId, getSubsQrys, columns, params) {
|
||||
return await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'view');
|
||||
|
||||
const subsQrys = [];
|
||||
const cpgLists = await tx('campaign_lists').where('campaign', campaignId);
|
||||
|
||||
for (const cpgList of cpgLists) {
|
||||
const subsTable = subscriptions.getSubscriptionTableName(cpgList.list);
|
||||
subsQrys.push(getSubsQrys(subsTable, cpgList));
|
||||
}
|
||||
|
||||
if (subsQrys.length > 0) {
|
||||
let subsSql, subsBindings;
|
||||
|
||||
if (subsQrys.length === 1) {
|
||||
subsSql = '(' + subsQrys[0].sql + ') as `subs`'
|
||||
subsBindings = subsQrys[0].bindings;
|
||||
|
||||
} else {
|
||||
subsSql = '(' +
|
||||
subsQrys.map(qry => '(' + qry.sql + ')').join(' UNION ALL ') +
|
||||
') as `subs`';
|
||||
subsBindings = Array.prototype.concat(...subsQrys.map(qry => qry.bindings));
|
||||
}
|
||||
|
||||
return await dtHelpers.ajaxListWithPermissionsTx(
|
||||
tx,
|
||||
context,
|
||||
[{ entityTypeId: 'list', requiredOperations: ['viewSubscriptions'], column: 'lists.id' }],
|
||||
params,
|
||||
(builder, tx) => builder.from(knex.raw(subsSql, subsBindings))
|
||||
.innerJoin('lists', 'subs.list', 'lists.id')
|
||||
.innerJoin('namespaces', 'lists.namespace', 'namespaces.id')
|
||||
,
|
||||
columns
|
||||
);
|
||||
|
||||
} else {
|
||||
const result = {
|
||||
draw: params.draw,
|
||||
recordsTotal: 0,
|
||||
recordsFiltered: 0,
|
||||
data: []
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function listSentByStatusDTAjax(context, campaignId, status, params) {
|
||||
return await _listSubscriberResultsDTAjax(
|
||||
context,
|
||||
campaignId,
|
||||
(subsTable, cpgList) => knex.from(subsTable)
|
||||
.innerJoin(
|
||||
function () {
|
||||
return this.from('campaign_messages')
|
||||
.where('campaign_messages.campaign', campaignId)
|
||||
.where('campaign_messages.list', cpgList.list)
|
||||
.where('campaign_messages.status', status)
|
||||
.as('related_campaign_messages');
|
||||
},
|
||||
'related_campaign_messages.subscription', subsTable + '.id')
|
||||
.select([subsTable + '.email', subsTable + '.cid', knex.raw('? AS list', [cpgList.list])])
|
||||
.toSQL().toNative(),
|
||||
[ 'subs.email', 'subs.cid', 'lists.cid', 'lists.name', 'namespaces.name' ],
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async function listOpensDTAjax(context, campaignId, params) {
|
||||
return await _listSubscriberResultsDTAjax(
|
||||
context,
|
||||
campaignId,
|
||||
(subsTable, cpgList) => knex.from(subsTable)
|
||||
.innerJoin(
|
||||
function () {
|
||||
return this.from('campaign_links')
|
||||
.where('campaign_links.campaign', campaignId)
|
||||
.where('campaign_links.list', cpgList.list)
|
||||
.where('campaign_links.link', LinkId.OPEN)
|
||||
.as('related_campaign_links');
|
||||
},
|
||||
'related_campaign_links.subscription', subsTable + '.id')
|
||||
.select([subsTable + '.email', subsTable + '.cid', knex.raw('? AS list', [cpgList.list]), 'related_campaign_links.count'])
|
||||
.toSQL().toNative(),
|
||||
[ 'subs.email', 'subs.cid', 'lists.cid', 'lists.name', 'namespaces.name', 'subs.count' ],
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async function listLinkClicksDTAjax(context, campaignId, params) {
|
||||
return await knex.transaction(async (tx) => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'viewStats');
|
||||
|
||||
return await dtHelpers.ajaxListTx(
|
||||
tx,
|
||||
params,
|
||||
builder => builder.from('links')
|
||||
.where('links.campaign', campaignId),
|
||||
[ 'links.url', 'links.visits', 'links.hits' ]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function getTrackingSettingsByCidTx(tx, cid) {
|
||||
const entity = await tx('campaigns').where('campaigns.cid', cid)
|
||||
.select([
|
||||
|
@ -511,6 +622,8 @@ async function _removeTx(tx, context, id, existing = null) {
|
|||
await tx('campaign_messages').where('campaign', id).del();
|
||||
await tx('campaign_links').where('campaign', id).del();
|
||||
|
||||
await tx('links').where('campaign', id).del();
|
||||
|
||||
await triggers.removeAllByCampaignIdTx(tx, context, id);
|
||||
|
||||
await tx('template_dep_campaigns')
|
||||
|
@ -779,11 +892,19 @@ async function reset(context, campaignId) {
|
|||
}
|
||||
|
||||
await tx('campaigns').where('id', campaignId).update({
|
||||
status: CampaignStatus.IDLE
|
||||
status: CampaignStatus.IDLE,
|
||||
delivered: 0,
|
||||
unsubscribed: 0,
|
||||
bounced: 0,
|
||||
complained: 0,
|
||||
blacklisted: 0,
|
||||
opened: 0,
|
||||
clicks: 0
|
||||
});
|
||||
|
||||
await tx('campaign_messages').where('campaign', campaignId).del();
|
||||
await tx('campaign_links').where('campaign', campaignId).del();
|
||||
await tx('links').where('campaign', campaignId).del();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -796,14 +917,51 @@ async function disable(context, campaignId) {
|
|||
}
|
||||
|
||||
|
||||
async function getStatisticsOverview(context, id) {
|
||||
return await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
|
||||
|
||||
const stats = await tx('campaigns').where('id', id).select(['delivered', 'unsubscribed', 'bounced', 'complained', 'blacklisted', 'opened', 'clicks']).first();
|
||||
|
||||
const totalQryGen = await getSubscribersQueryGeneratorTx(tx, id, false);
|
||||
if (totalQryGen) {
|
||||
const res = await totalQryGen(tx).count('* AS subscriptionsTotal').first();
|
||||
stats.total = res.subscriptionsTotal;
|
||||
} else {
|
||||
stats.total = 0;
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
async function getStatisticsOpened(context, id) {
|
||||
return await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
|
||||
|
||||
const devices = await tx('campaign_links').where('campaign', id).groupBy('device_type').select('device_type AS key').count('* as count');
|
||||
const countries = await tx('campaign_links').where('campaign', id).groupBy('country').select('country AS key').count('* as count');
|
||||
|
||||
return {
|
||||
devices,
|
||||
countries
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.Content = Content;
|
||||
module.exports.hash = hash;
|
||||
|
||||
module.exports.listDTAjax = listDTAjax;
|
||||
module.exports.listChildrenDTAjax = listChildrenDTAjax;
|
||||
module.exports.listWithContentDTAjax = listWithContentDTAjax;
|
||||
module.exports.listOthersWhoseListsAreIncludedDTAjax = listOthersWhoseListsAreIncludedDTAjax;
|
||||
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
|
||||
module.exports.listSentByStatusDTAjax = listSentByStatusDTAjax;
|
||||
module.exports.listOpensDTAjax = listOpensDTAjax;
|
||||
module.exports.listLinkClicksDTAjax = listLinkClicksDTAjax;
|
||||
|
||||
|
||||
module.exports.getByIdTx = getByIdTx;
|
||||
module.exports.getById = getById;
|
||||
module.exports.create = create;
|
||||
|
@ -829,4 +987,6 @@ module.exports.enable = enable;
|
|||
module.exports.disable = disable;
|
||||
|
||||
module.exports.rawGetByTx = rawGetByTx;
|
||||
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
|
||||
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
|
||||
module.exports.getStatisticsOverview = getStatisticsOverview;
|
||||
module.exports.getStatisticsOpened = getStatisticsOpened;
|
||||
|
|
|
@ -14,6 +14,7 @@ const he = require('he');
|
|||
const { enforce } = require('../lib/helpers');
|
||||
const { getPublicUrl } = require('../lib/urls');
|
||||
const tools = require('../lib/tools');
|
||||
const shortid = require('shortid');
|
||||
|
||||
const LinkId = {
|
||||
OPEN: -1,
|
||||
|
@ -41,7 +42,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
|||
campaign: campaign.id,
|
||||
list: list.id,
|
||||
subscription: subscription.id,
|
||||
link: linkId,
|
||||
link: clickLinkId,
|
||||
ip: remoteIp,
|
||||
device_type: device.type,
|
||||
country
|
||||
|
@ -49,7 +50,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
|||
|
||||
const campaignLinksQryResult = await tx.raw(campaignLinksQry.sql + (incrementOnDup ? ' ON DUPLICATE KEY UPDATE `count`=`count`+1' : ''), campaignLinksQry.bindings);
|
||||
|
||||
if (campaignLinksQryResult.affectedRows > 1) { // When using DUPLICATE KEY UPDATE, this means that the entry was already there
|
||||
if (campaignLinksQryResult[0].affectedRows > 1) { // When using DUPLICATE KEY UPDATE, this means that the entry was already there
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -84,7 +85,10 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
|||
|
||||
// Update clicks
|
||||
if (linkId > LinkId.GENERAL_CLICK && !campaign.click_tracking_disabled) {
|
||||
await tx('links').increment('hits').where('id', linkId);
|
||||
if (await _countLink(linkId, true)) {
|
||||
await tx('links').increment('visits').where('id', linkId);
|
||||
|
||||
if (await _countLink(LinkId.GENERAL_CLICK, false)) {
|
||||
await tx('campaigns').increment('clicks').where('id', campaign.id);
|
||||
}
|
||||
|
@ -103,7 +107,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
|||
|
||||
async function addOrGet(campaignId, url) {
|
||||
return await knex.transaction(async tx => {
|
||||
const link = tx('links').select(['id', 'cid']).where({
|
||||
const link = await tx('links').select(['id', 'cid']).where({
|
||||
campaign: campaignId,
|
||||
url
|
||||
}).first();
|
||||
|
@ -111,7 +115,7 @@ async function addOrGet(campaignId, url) {
|
|||
if (!link) {
|
||||
let cid = shortid.generate();
|
||||
|
||||
const ids = tx('links').insert({
|
||||
const ids = await tx('links').insert({
|
||||
campaign: campaignId,
|
||||
cid,
|
||||
url
|
||||
|
|
|
@ -5,7 +5,7 @@ const { filterObject } = require('../lib/helpers');
|
|||
const hasher = require('node-object-hash')();
|
||||
const shares = require('./shares');
|
||||
|
||||
const allowedKeys = new Set(['adminEmail', 'uaCode', 'shoutout', 'pgpPassphrase', 'pgpPrivateKey', 'defaultHomepage']);
|
||||
const allowedKeys = new Set(['adminEmail', 'uaCode', 'mapsApiKey', 'shoutout', 'pgpPassphrase', 'pgpPrivateKey', 'defaultHomepage']);
|
||||
// defaultHomepage is used as a default to list.homepage - if the list.homepage is not filled in
|
||||
|
||||
function hash(entity) {
|
||||
|
|
|
@ -8,6 +8,20 @@ const interoperableErrors = require('../../shared/interoperable-errors');
|
|||
|
||||
const trackImg = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
|
||||
|
||||
router.getAsync('/:campaign/:list/:subscription/:link', async (req, res) => {
|
||||
const link = await links.resolve(req.params.link);
|
||||
|
||||
if (link) {
|
||||
// In Mailtrain v1 we would do the URL expansion here based on merge tags. We don't do it here anymore. Instead, the URLs are expanded when message is sent out (in links.updateLinks)
|
||||
res.redirect(link.url);
|
||||
|
||||
await links.countLink(req.ip, req.headers['user-agent'], req.params.campaign, req.params.list, req.params.subscription, link.id);
|
||||
} else {
|
||||
log.error('Redirect', 'Unresolved URL: <%s>', req.url);
|
||||
throw new interoperableErrors.NotFoundError('Oops, we couldn\'t find a link for the URL you clicked');
|
||||
}
|
||||
});
|
||||
|
||||
router.getAsync('/:campaign/:list/:subscription', async (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/gif',
|
||||
|
@ -20,18 +34,4 @@ router.getAsync('/:campaign/:list/:subscription', async (req, res) => {
|
|||
});
|
||||
|
||||
|
||||
router.getAsync('/:campaign/:list/:subscription/:link', async (req, res) => {
|
||||
const link = await links.resolve(req.params.link);
|
||||
|
||||
if (link) {
|
||||
// In Mailtrain v1 we would do the URL expansion here based on merge tags. We don't do it here anymore. Instead, the URLs are expanded when message is sent out (in links.updateLinks)
|
||||
res.redirect(url);
|
||||
|
||||
await links.countLink(req.ip, req.headers['user-agent'], req.params.campaign, req.params.list, req.params.subscription, link.id);
|
||||
} else {
|
||||
log.error('Redirect', 'Unresolved URL: <%s>', req.url);
|
||||
throw new interoperableErrors.NotFoundError('Oops, we couldn\'t find a link for the URL you clicked');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
@ -94,5 +94,26 @@ router.postAsync('/campaign-disable/:campaignId', passport.loggedIn, passport.cs
|
|||
return res.json(await campaigns.disable(req.context, castToInteger(req.params.campaignId), null));
|
||||
});
|
||||
|
||||
router.getAsync('/campaign-statistics/:campaignId/overview', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.getStatisticsOverview(req.context, castToInteger(req.params.campaignId)));
|
||||
});
|
||||
|
||||
router.getAsync('/campaign-statistics/:campaignId/opened', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.getStatisticsOpened(req.context, castToInteger(req.params.campaignId)));
|
||||
});
|
||||
|
||||
router.postAsync('/campaigns-subscribers-by-status-table/:campaignId/:status', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.listSentByStatusDTAjax(req.context, castToInteger(req.params.campaignId), castToInteger(req.params.status), req.body));
|
||||
});
|
||||
|
||||
router.postAsync('/campaigns-opens-table/:campaignId', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.listOpensDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
|
||||
});
|
||||
|
||||
router.postAsync('/campaigns-link-clicks-table/:campaignId', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.listLinkClicksDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
|
||||
});
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
|
@ -1102,6 +1102,11 @@ async function migrateCampaigns(knex) {
|
|||
table.integer('send_configuration').unsigned().notNullable().alter();
|
||||
});
|
||||
|
||||
await knex.schema.table('links', table => {
|
||||
table.dropColumn('clicks');
|
||||
table.integer('visits').unsigned().notNullable().defaultTo(0);
|
||||
table.integer('hits').unsigned().notNullable().defaultTo(0);
|
||||
});
|
||||
|
||||
await knex.schema.dropTableIfExists('campaign');
|
||||
await knex.schema.dropTableIfExists('campaign_tracker');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue