2022-08-18 18:38:42 +03:00
|
|
|
const UrlHistory = require('./history');
|
2022-11-08 13:24:00 +03:00
|
|
|
const {slugify} = require('@tryghost/string');
|
|
|
|
|
|
|
|
const blacklistedReferrerDomains = [
|
|
|
|
// Facebook has some restrictions on the 'ref' attribute (max 15 chars + restricted character set) that breaks links if we add ?ref=longer-string
|
|
|
|
'facebook.com',
|
|
|
|
'www.facebook.com'
|
|
|
|
];
|
2022-08-18 18:38:42 +03:00
|
|
|
|
|
|
|
class MemberAttributionService {
|
2022-08-22 12:36:24 +03:00
|
|
|
/**
|
2022-08-25 19:21:08 +03:00
|
|
|
*
|
|
|
|
* @param {Object} deps
|
2022-08-22 12:36:24 +03:00
|
|
|
* @param {Object} deps.attributionBuilder
|
|
|
|
* @param {Object} deps.models
|
|
|
|
* @param {Object} deps.models.MemberCreatedEvent
|
|
|
|
* @param {Object} deps.models.SubscriptionCreatedEvent
|
2022-11-07 18:55:17 +03:00
|
|
|
* @param {() => boolean} deps.getTrackingEnabled
|
2023-01-20 15:41:36 +03:00
|
|
|
* @param {() => boolean} deps.getOutboundLinkTaggingEnabled
|
2022-11-08 13:24:00 +03:00
|
|
|
* @param {() => string} deps.getSiteTitle
|
2022-08-22 12:36:24 +03:00
|
|
|
*/
|
2023-01-20 15:41:36 +03:00
|
|
|
constructor({attributionBuilder, models, getTrackingEnabled, getOutboundLinkTaggingEnabled, getSiteTitle}) {
|
2022-08-22 12:36:24 +03:00
|
|
|
this.models = models;
|
|
|
|
this.attributionBuilder = attributionBuilder;
|
2022-11-07 18:55:17 +03:00
|
|
|
this._getTrackingEnabled = getTrackingEnabled;
|
2023-01-20 15:41:36 +03:00
|
|
|
this._getOutboundLinkTaggingEnabled = getOutboundLinkTaggingEnabled;
|
2022-11-08 13:24:00 +03:00
|
|
|
this._getSiteTitle = getSiteTitle;
|
2022-11-07 18:55:17 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
get isTrackingEnabled() {
|
|
|
|
return this._getTrackingEnabled();
|
2022-08-18 18:38:42 +03:00
|
|
|
}
|
|
|
|
|
2023-01-20 15:41:36 +03:00
|
|
|
get isOutboundLinkTaggingEnabled() {
|
|
|
|
return this._getOutboundLinkTaggingEnabled();
|
|
|
|
}
|
|
|
|
|
2022-11-08 13:24:00 +03:00
|
|
|
get siteTitle() {
|
|
|
|
return this._getSiteTitle();
|
|
|
|
}
|
|
|
|
|
2022-09-29 20:01:48 +03:00
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {Object} context instance of ghost framework context object
|
|
|
|
* @returns {Promise<import('./attribution').AttributionResource|null>}
|
|
|
|
*/
|
|
|
|
async getAttributionFromContext(context) {
|
2022-10-27 18:40:03 +03:00
|
|
|
if (!context || !this.isTrackingEnabled) {
|
2022-09-29 20:01:48 +03:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const source = this._resolveContextSource(context);
|
|
|
|
|
|
|
|
// We consider only select internal context sources
|
|
|
|
if (['import', 'api', 'admin'].includes(source)) {
|
|
|
|
let attribution = {
|
|
|
|
id: null,
|
|
|
|
type: null,
|
|
|
|
url: null,
|
|
|
|
title: null,
|
|
|
|
referrerUrl: null,
|
|
|
|
referrerSource: null,
|
|
|
|
referrerMedium: null
|
|
|
|
};
|
|
|
|
if (source === 'import') {
|
|
|
|
attribution.referrerSource = 'Imported';
|
|
|
|
attribution.referrerMedium = 'Member Importer';
|
|
|
|
} else if (source === 'admin') {
|
|
|
|
attribution.referrerSource = 'Created manually';
|
|
|
|
attribution.referrerMedium = 'Ghost Admin';
|
|
|
|
} else if (source === 'api') {
|
|
|
|
attribution.referrerSource = 'Created via API';
|
|
|
|
attribution.referrerMedium = 'Admin API';
|
|
|
|
}
|
|
|
|
|
|
|
|
// If context has integration, set referrer medium as integration anme
|
|
|
|
if (context?.integration?.id) {
|
|
|
|
try {
|
|
|
|
const integration = await this.models.Integration.findOne({id: context.integration.id});
|
|
|
|
attribution.referrerSource = `Integration: ${integration?.get('name')}`;
|
|
|
|
} catch (error) {
|
|
|
|
// ignore error for integration not found
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return attribution;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2022-08-18 18:38:42 +03:00
|
|
|
/**
|
2022-08-25 19:21:08 +03:00
|
|
|
*
|
|
|
|
* @param {import('./history').UrlHistoryArray} historyArray
|
2022-09-14 22:50:54 +03:00
|
|
|
* @returns {Promise<import('./attribution').Attribution>}
|
2022-08-18 18:38:42 +03:00
|
|
|
*/
|
2022-09-14 22:50:54 +03:00
|
|
|
async getAttribution(historyArray) {
|
2022-10-27 18:40:03 +03:00
|
|
|
let history = UrlHistory.create(historyArray);
|
|
|
|
if (!this.isTrackingEnabled) {
|
|
|
|
history = UrlHistory.create([]);
|
|
|
|
}
|
2022-09-14 22:50:54 +03:00
|
|
|
return await this.attributionBuilder.getAttribution(history);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-01-20 15:41:36 +03:00
|
|
|
* Add some parameters to a URL that points to a site, so that site can detect that the traffic is coming from a Ghost site or newsletter.
|
|
|
|
* Note that this is disabled if outboundLinkTagging setting is disabled.
|
2022-09-14 22:50:54 +03:00
|
|
|
* @param {URL} url instance that will get updated
|
2022-11-08 13:24:00 +03:00
|
|
|
* @param {Object} [useNewsletter] Use the newsletter name instead of the site name as referrer source
|
2022-09-14 22:50:54 +03:00
|
|
|
* @returns {URL}
|
|
|
|
*/
|
2023-01-20 15:41:36 +03:00
|
|
|
addOutboundLinkTagging(url, useNewsletter) {
|
2022-09-14 22:50:54 +03:00
|
|
|
// Create a deep copy
|
|
|
|
url = new URL(url);
|
2022-11-08 13:24:00 +03:00
|
|
|
|
2023-01-20 15:41:36 +03:00
|
|
|
if (!this.isOutboundLinkTaggingEnabled) {
|
|
|
|
return url;
|
|
|
|
}
|
|
|
|
|
2022-11-08 13:24:00 +03:00
|
|
|
if (url.searchParams.has('ref') || url.searchParams.has('utm_source') || url.searchParams.has('source')) {
|
|
|
|
// Don't overwrite + keep existing source attribution
|
|
|
|
return url;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check blacklist domains
|
|
|
|
const referrerDomain = url.hostname;
|
|
|
|
if (blacklistedReferrerDomains.includes(referrerDomain)) {
|
|
|
|
return url;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (useNewsletter) {
|
|
|
|
const name = slugify(useNewsletter.get('name'));
|
2023-01-20 15:41:36 +03:00
|
|
|
|
2022-11-08 13:24:00 +03:00
|
|
|
// If newsletter name ends with newsletter, don't add it again
|
|
|
|
const ref = name.endsWith('newsletter') ? name : `${name}-newsletter`;
|
|
|
|
url.searchParams.append('ref', ref);
|
|
|
|
} else {
|
|
|
|
url.searchParams.append('ref', slugify(this.siteTitle));
|
|
|
|
}
|
2022-09-14 22:50:54 +03:00
|
|
|
return url;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add some parameters to a URL so that the frontend script can detect this and add the required records
|
|
|
|
* in the URLHistory.
|
|
|
|
* @param {URL} url instance that will get updated
|
|
|
|
* @param {Object} post The post from which a link was clicked
|
|
|
|
* @returns {URL}
|
|
|
|
*/
|
|
|
|
addPostAttributionTracking(url, post) {
|
|
|
|
// Create a deep copy
|
|
|
|
url = new URL(url);
|
|
|
|
|
2022-11-08 13:24:00 +03:00
|
|
|
if (url.searchParams.has('attribution_id') || url.searchParams.has('attribution_type')) {
|
|
|
|
// Don't overwrite
|
|
|
|
return url;
|
|
|
|
}
|
|
|
|
|
2022-09-14 22:50:54 +03:00
|
|
|
// Post attribution
|
|
|
|
url.searchParams.append('attribution_id', post.id);
|
|
|
|
url.searchParams.append('attribution_type', 'post');
|
|
|
|
return url;
|
2022-08-18 18:38:42 +03:00
|
|
|
}
|
2022-08-19 23:39:18 +03:00
|
|
|
|
2022-08-24 17:11:25 +03:00
|
|
|
/**
|
|
|
|
* Returns the attribution resource for a given event model (MemberCreatedEvent / SubscriptionCreatedEvent), where the model has the required relations already loaded
|
|
|
|
* You need to already load the 'postAttribution', 'userAttribution', and 'tagAttribution' relations
|
|
|
|
* @param {Object} eventModel MemberCreatedEvent or SubscriptionCreatedEvent
|
|
|
|
* @returns {import('./attribution').AttributionResource|null}
|
|
|
|
*/
|
|
|
|
getEventAttribution(eventModel) {
|
|
|
|
const _attribution = this.attributionBuilder.build({
|
|
|
|
id: eventModel.get('attribution_id'),
|
|
|
|
url: eventModel.get('attribution_url'),
|
2022-09-23 18:19:51 +03:00
|
|
|
type: eventModel.get('attribution_type'),
|
2022-09-27 22:28:06 +03:00
|
|
|
referrerSource: eventModel.get('referrer_source'),
|
|
|
|
referrerMedium: eventModel.get('referrer_medium'),
|
|
|
|
referrerUrl: eventModel.get('referrer_url')
|
2022-08-24 17:11:25 +03:00
|
|
|
});
|
|
|
|
|
2022-09-29 20:01:48 +03:00
|
|
|
if (_attribution.type && _attribution.type !== 'url') {
|
2022-08-24 17:11:25 +03:00
|
|
|
// Find the right relation to use to fetch the resource
|
|
|
|
const tryRelations = [
|
|
|
|
eventModel.related('postAttribution'),
|
|
|
|
eventModel.related('userAttribution'),
|
|
|
|
eventModel.related('tagAttribution')
|
|
|
|
];
|
|
|
|
for (const relation of tryRelations) {
|
|
|
|
if (relation && relation.id) {
|
|
|
|
// We need to check the ID, because .related() always returs a model when eager loaded, even when the relation didn't exist
|
|
|
|
return _attribution.getResource(relation);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return _attribution.getResource(null);
|
|
|
|
}
|
|
|
|
|
2022-08-19 23:39:18 +03:00
|
|
|
/**
|
|
|
|
* Returns the parsed attribution for a member creation event
|
2022-08-25 19:21:08 +03:00
|
|
|
* @param {string} memberId
|
2022-08-19 23:39:18 +03:00
|
|
|
* @returns {Promise<import('./attribution').AttributionResource|null>}
|
|
|
|
*/
|
|
|
|
async getMemberCreatedAttribution(memberId) {
|
2022-08-24 17:11:25 +03:00
|
|
|
const memberCreatedEvent = await this.models.MemberCreatedEvent.findOne({member_id: memberId}, {require: false, withRelated: []});
|
2022-09-29 20:01:48 +03:00
|
|
|
if (!memberCreatedEvent) {
|
2022-08-19 23:39:18 +03:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
const attribution = this.attributionBuilder.build({
|
|
|
|
id: memberCreatedEvent.get('attribution_id'),
|
|
|
|
url: memberCreatedEvent.get('attribution_url'),
|
2022-09-23 18:19:51 +03:00
|
|
|
type: memberCreatedEvent.get('attribution_type'),
|
2022-09-27 22:28:06 +03:00
|
|
|
referrerSource: memberCreatedEvent.get('referrer_source'),
|
|
|
|
referrerMedium: memberCreatedEvent.get('referrer_medium'),
|
|
|
|
referrerUrl: memberCreatedEvent.get('referrer_url')
|
2022-08-19 23:39:18 +03:00
|
|
|
});
|
2022-08-24 17:11:25 +03:00
|
|
|
return await attribution.fetchResource();
|
2022-08-19 23:39:18 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the last attribution for a given subscription ID
|
2022-08-25 19:21:08 +03:00
|
|
|
* @param {string} subscriptionId
|
2022-08-19 23:39:18 +03:00
|
|
|
* @returns {Promise<import('./attribution').AttributionResource|null>}
|
|
|
|
*/
|
|
|
|
async getSubscriptionCreatedAttribution(subscriptionId) {
|
2022-08-24 17:11:25 +03:00
|
|
|
const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent.findOne({subscription_id: subscriptionId}, {require: false, withRelated: []});
|
2022-09-29 20:01:48 +03:00
|
|
|
if (!subscriptionCreatedEvent) {
|
2022-08-19 23:39:18 +03:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
const attribution = this.attributionBuilder.build({
|
|
|
|
id: subscriptionCreatedEvent.get('attribution_id'),
|
|
|
|
url: subscriptionCreatedEvent.get('attribution_url'),
|
2022-09-23 18:19:51 +03:00
|
|
|
type: subscriptionCreatedEvent.get('attribution_type'),
|
2022-09-27 22:28:06 +03:00
|
|
|
referrerSource: subscriptionCreatedEvent.get('referrer_source'),
|
|
|
|
referrerMedium: subscriptionCreatedEvent.get('referrer_medium'),
|
|
|
|
referrerUrl: subscriptionCreatedEvent.get('referrer_url')
|
2022-08-19 23:39:18 +03:00
|
|
|
});
|
2022-08-24 17:11:25 +03:00
|
|
|
return await attribution.fetchResource();
|
2022-08-19 23:39:18 +03:00
|
|
|
}
|
2022-09-29 20:01:48 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Maps the framework context to source string
|
|
|
|
* @param {Object} context instance of ghost framework context object
|
|
|
|
* @returns {'import' | 'system' | 'api' | 'admin' | 'member'}
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
_resolveContextSource(context) {
|
|
|
|
let source;
|
|
|
|
|
|
|
|
if (context.import || context.importer) {
|
|
|
|
source = 'import';
|
|
|
|
} else if (context.internal) {
|
|
|
|
source = 'system';
|
|
|
|
} else if (context.api_key) {
|
|
|
|
source = 'api';
|
|
|
|
} else if (context.user) {
|
|
|
|
source = 'admin';
|
|
|
|
} else {
|
|
|
|
source = 'member';
|
|
|
|
}
|
|
|
|
|
|
|
|
return source;
|
|
|
|
}
|
2022-08-18 18:38:42 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = MemberAttributionService;
|