Added email track clicks column and cleaned up frontend checks (#15501)

fixes https://github.com/TryGhost/Team/issues/2008

- New column that stores email click tracking at the time it was created
- Improved frontend side checks for when to show analytics
This commit is contained in:
Simon Backx 2022-09-29 16:42:45 +02:00 committed by GitHub
parent 22a75ba144
commit 0cd0fc838d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 65 additions and 33 deletions

View File

@ -117,7 +117,7 @@
{{!-- Opened / Signups column --}}
<LinkTo @route={{this.routeForLink}} @models={{this.modelsForLink}} class="permalink gh-list-data gh-post-list-opens">
{{#if (and @post.email (not-eq this.settings.membersSignupAccess "none") (not-eq this.settings.editorDefaultEmailRecipients "disabled") (not this.session.user.isContributor) (or @post.isSent @post.isPublished) this.settings.emailTrackOpens @post.email.trackOpens this.feature.emailAnalytics (eq @post.displayName "post"))}}
{{#if @post.showEmailOpenAnalytics }}
<LinkTo @route="members" @query={{hash filterParam=(concat "opened_emails.post_id:[" @post.id "]") }} class="flex flex-column gh-post-row-event" {{on "mouseover" (fn (mut this.isOpenStatHovered) true)}} {{on "mouseleave" (fn (mut this.isOpenStatHovered) false)}}>
<span class="gh-content-email-stats-value">
{{#if this.isHovered}}
@ -130,9 +130,7 @@
opened
</span>
</LinkTo>
{{/if}}
{{#if (and this.feature.memberAttribution (not-eq this.settings.membersSignupAccess "none") (and @post.isPage) (not this.session.user.isContributor)) }}
{{else if (and @post.isPage @post.showAttributionAnalytics) }}
<LinkTo @route="members" @query={{hash filterParam=(concat "signup:[" @post.id "]") }} class="flex flex-column gh-post-row-event" {{on "mouseover" (fn (mut this.isOpenStatHovered) true)}} {{on "mouseleave" (fn (mut this.isOpenStatHovered) false)}}>
<span class="gh-content-email-stats-value">
{{@post.count.signups}}
@ -146,7 +144,7 @@
{{!-- Clicked / Conversions column --}}
<LinkTo @route={{this.routeForLink}} @models={{this.modelsForLink}} class="permalink gh-list-data gh-post-list-clicks">
{{#if (and @post.email (not-eq this.settings.membersSignupAccess "none") (not-eq this.settings.editorDefaultEmailRecipients "disabled") (not this.session.user.isContributor) (or @post.isSent @post.isPublished) this.settings.emailTrackClicks this.feature.emailAnalytics)}}
{{#if @post.showEmailClickAnalytics }}
<LinkTo @route="members" @query={{hash filterParam=(concat "clicked_links.post_id:[" @post.id "]") }} class="flex flex-column gh-post-row-event" {{on "mouseover" (fn (mut this.isClickStatHovered) true)}} {{on "mouseleave" (fn (mut this.isClickStatHovered) false)}}>
<span class="gh-content-email-stats-value">
{{#if this.isHovered}}
@ -159,9 +157,7 @@
clicked
</span>
</LinkTo>
{{/if}}
{{#if (and this.feature.memberAttribution (not-eq this.settings.membersSignupAccess "none") (and @post.isPage) (not this.session.user.isContributor)) }}
{{else if (and @post.isPage @post.showPaidAttributionAnalytics) }}
<LinkTo @route="members" @query={{hash filterParam=(concat "conversion:[" @post.id "]") }} class="flex flex-column gh-post-row-event" {{on "mouseover" (fn (mut this.isClickStatHovered) true)}} {{on "mouseleave" (fn (mut this.isClickStatHovered) false)}}>
<span class="gh-content-email-stats-value">
{{@post.count.paid_conversions}}
@ -176,7 +172,7 @@
{{!-- Button column --}}
<LinkTo @route={{this.routeForLink}} @models={{this.modelsForLink}} class="permalink gh-list-data gh-post-list-button">
<div class="gh-list-data-inner">
{{#if this.isAnalytics}}
{{#if @post.hasAnalyticsPage }}
<LinkTo @route={{this.routeForLink}} @models={{this.modelsForLink}} class="gh-post-list-cta stats {{if this.isHovered "is-hovered"}}" title="">
{{svg-jar "stats" title=""}}
</LinkTo>

View File

@ -24,10 +24,6 @@ export default class PostsListItemClicks extends Component {
return text.join(' ');
}
get isAnalytics() {
return this.args.post.hasAnalytics;
}
get routeForLink() {
if (this.isAnalytics) {
return 'posts.analytics';

View File

@ -52,7 +52,7 @@
</LinkTo>
</div>
{{#if (and this.post.email.trackOpens this.settings.emailTrackOpens) }}
{{#if this.post.showEmailOpenAnalytics }}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "opened_emails.post_id:[" this.post.id "]") }}>
<h3>{{this.post.email.openRate}}<sup>%</sup></h3>
@ -61,7 +61,7 @@
</div>
{{/if}}
{{#if this.settings.emailTrackClicks}}
{{#if this.post.showEmailClickAnalytics }}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "clicked_links.post_id:[" this.post.id "]") }}>
<h3>{{this.post.clickRate}}<sup>%</sup></h3>
@ -71,7 +71,7 @@
{{/if}}
{{/if}}
{{#if (and (feature 'memberAttribution') (not this.post.emailOnly)) }}
{{#if this.post.showAttributionAnalytics }}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "signup:[" this.post.id "]") }}>
<h3>{{format-number this.post.count.signups}}</h3>
@ -79,7 +79,7 @@
</LinkTo>
</div>
{{#if this.membersUtils.paidMembersEnabled}}
{{#if this.post.showPaidAttributionAnalytics }}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "conversion:[" this.post.id "]") }}>
<h3>{{format-number this.post.count.paid_conversions}}</h3>

View File

@ -119,11 +119,11 @@ export default class Analytics extends Component {
}
get showLinks() {
return this.settings.get('emailTrackClicks') && (this.post.isSent || (this.post.isPublished && this.post.email));
return this.post.showEmailClickAnalytics;
}
get showSources() {
return this.feature.get('sourceAttribution') && !this.membersUtils.isMembersInviteOnly && !this.post.emailOnly;
return this.feature.get('sourceAttribution') && this.post.showAttributionAnalytics;
}
get isLoaded() {

View File

@ -19,6 +19,7 @@ export default Model.extend({
failedCount: attr('number', {defaultValue: 0}),
trackOpens: attr('boolean'),
trackClicks: attr('boolean'),
createdAtUTC: attr('moment-utc'),
createdBy: attr('string'),

View File

@ -73,6 +73,7 @@ export default Model.extend(Comparable, ValidationEngine, {
ghostPaths: service(),
clock: service(),
settings: service(),
membersUtils: service(),
displayName: 'post',
validationType: 'post',
@ -182,20 +183,44 @@ export default Model.extend(Comparable, ValidationEngine, {
return this.isScheduled && !!this.newsletter && !this.email;
}),
hasAnalytics: computed('isPost', 'isSent', 'isPublished', 'email', function () {
showEmailOpenAnalytics: computed('isPost', 'isSent', 'isPublished', 'email', function () {
return this.isPost
&& !this.session.user.isContributor
&& this.settings.get('membersSignupAccess') !== 'none'
&& this.settings.get('editorDefaultEmailRecipients') !== 'disabled'
&& (this.isSent || this.isPublished)
&& this.email
&& this.email.trackOpens
&& this.settings.get('emailTrackOpens');
}),
showEmailClickAnalytics: computed('isPost', 'isSent', 'isPublished', 'email', function () {
return this.isPost
&& !this.session.user.isContributor
&& this.settings.get('membersSignupAccess') !== 'none'
&& this.settings.get('editorDefaultEmailRecipients') !== 'disabled'
&& (this.isSent || this.isPublished)
&& this.email
&& this.email.trackClicks
&& this.settings.get('emailTrackClicks');
}),
showAttributionAnalytics: computed('isPage', 'emailOnly', 'isPublished', 'membersUtils.isMembersInviteOnly', function () {
return (this.isPage || !this.emailOnly)
&& this.isPublished
&& this.feature.get('memberAttribution')
&& !this.membersUtils.isMembersInviteOnly
&& !this.session.user.isContributor;
}),
showPaidAttributionAnalytics: computed.and('showAttributionAnalytics', 'membersUtils.paidMembersEnabled'),
hasAnalyticsPage: computed('isPost', 'showEmailOpenAnalytics', 'showEmailClickAnalytics', 'showAttributionAnalytics', function () {
return this.isPost
&& (
(
// We have clicks or opens data
(this.isSent || (this.isPublished && this.email))
&& (this.settings.get('emailTrackClicks') || this.settings.get('emailTrackOpens'))
)
|| (
// We have attribution data for pubished posts
this.isPublished && this.feature.get('memberAttribution')
)
this.showEmailOpenAnalytics
|| this.showEmailClickAnalytics
|| this.showAttributionAnalytics
);
}),

View File

@ -0,0 +1,7 @@
const {createAddColumnMigration} = require('../../utils');
module.exports = createAddColumnMigration('emails', 'track_clicks', {
type: 'bool',
nullable: false,
defaultTo: false
});

View File

@ -723,6 +723,7 @@ module.exports = {
html: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
track_opens: {type: 'bool', nullable: false, defaultTo: false},
track_clicks: {type: 'bool', nullable: false, defaultTo: false},
submitted_at: {type: 'dateTime', nullable: false},
newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'},
created_at: {type: 'dateTime', nullable: false},

View File

@ -10,6 +10,7 @@ const Email = ghostBookshelf.Model.extend({
status: 'pending',
recipient_filter: 'status:-free',
track_opens: false,
track_clicks: false,
delivered_count: 0,
opened_count: 0,
failed_count: 0

View File

@ -239,6 +239,7 @@ const addEmail = async (postModel, options) => {
plaintext: emailData.plaintext,
submitted_at: moment().toDate(),
track_opens: !!settingsCache.get('email_track_opens'),
track_clicks: !!settingsCache.get('email_track_clicks'),
recipient_filter: emailRecipientFilter,
newsletter_id: newsletter.id
}, knexOptions);

View File

@ -22,6 +22,7 @@ Object {
"status": "submitted",
"subject": "You got mailed!",
"submitted_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"track_clicks": false,
"track_opens": false,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
@ -45,6 +46,7 @@ Object {
"status": "failed",
"subject": "You got mailed! Again!",
"submitted_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"track_clicks": false,
"track_opens": false,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
@ -67,7 +69,7 @@ exports[`Emails API Can browse emails 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1243",
"content-length": "1285",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -97,6 +99,7 @@ Object {
"status": "submitted",
"subject": "You got mailed!",
"submitted_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"track_clicks": false,
"track_opens": false,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
@ -109,7 +112,7 @@ exports[`Emails API Can read an email 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "561",
"content-length": "582",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -139,6 +142,7 @@ Object {
"status": "pending",
"subject": "You got mailed! Again!",
"submitted_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"track_clicks": false,
"track_opens": false,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
@ -151,7 +155,7 @@ exports[`Emails API Can retry a failed email 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "607",
"content-length": "628",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",

View File

@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '999d625400e6d87efe0fd66e6bda4059';
const currentSchemaHash = '2a59debcacc1e3dc0b15e2f729ca4bdb';
const currentFixturesHash = '8cf221f0ed930ac1fe8030a58e60d64b';
const currentSettingsHash = '2978a5684a2d5fcf089f61f5d368a0c0';
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';