104f84f252
As discussed with the product team we want to enforce kebab-case file names for all files, with the exception of files which export a single class, in which case they should be PascalCase and reflect the class which they export. This will help find classes faster, and should push better naming for them too. Some files and packages have been excluded from this linting, specifically when a library or framework depends on the naming of a file for the functionality e.g. Ember, knex-migrator, adapter-manager
207 lines
7.8 KiB
JavaScript
207 lines
7.8 KiB
JavaScript
const UrlHistory = require('./UrlHistory');
|
|
|
|
class MemberAttributionService {
|
|
/**
|
|
*
|
|
* @param {Object} deps
|
|
* @param {Object} deps.attributionBuilder
|
|
* @param {Object} deps.models
|
|
* @param {Object} deps.models.MemberCreatedEvent
|
|
* @param {Object} deps.models.SubscriptionCreatedEvent
|
|
* @param {() => boolean} deps.getTrackingEnabled
|
|
*/
|
|
constructor({attributionBuilder, models, getTrackingEnabled}) {
|
|
this.models = models;
|
|
this.attributionBuilder = attributionBuilder;
|
|
this._getTrackingEnabled = getTrackingEnabled;
|
|
}
|
|
|
|
get isTrackingEnabled() {
|
|
return this._getTrackingEnabled();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Object} context instance of ghost framework context object
|
|
* @returns {Promise<import('./AttributionBuilder').AttributionResource|null>}
|
|
*/
|
|
async getAttributionFromContext(context) {
|
|
if (!context || !this.isTrackingEnabled) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {import('./UrlHistory').UrlHistoryArray} historyArray
|
|
* @returns {Promise<import('./AttributionBuilder').Attribution>}
|
|
*/
|
|
async getAttribution(historyArray) {
|
|
let history = UrlHistory.create(historyArray);
|
|
if (!this.isTrackingEnabled) {
|
|
history = UrlHistory.create([]);
|
|
}
|
|
return await this.attributionBuilder.getAttribution(history);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
if (url.searchParams.has('attribution_id') || url.searchParams.has('attribution_type')) {
|
|
// Don't overwrite
|
|
return url;
|
|
}
|
|
|
|
// Post attribution
|
|
url.searchParams.append('attribution_id', post.id);
|
|
url.searchParams.append('attribution_type', 'post');
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* 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('./AttributionBuilder').AttributionResource|null}
|
|
*/
|
|
getEventAttribution(eventModel) {
|
|
const _attribution = this.attributionBuilder.build({
|
|
id: eventModel.get('attribution_id'),
|
|
url: eventModel.get('attribution_url'),
|
|
type: eventModel.get('attribution_type'),
|
|
referrerSource: eventModel.get('referrer_source'),
|
|
referrerMedium: eventModel.get('referrer_medium'),
|
|
referrerUrl: eventModel.get('referrer_url')
|
|
});
|
|
|
|
if (_attribution.type && _attribution.type !== 'url') {
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Returns the parsed attribution for a member creation event
|
|
* @param {string} memberId
|
|
* @returns {Promise<import('./AttributionBuilder').AttributionResource|null>}
|
|
*/
|
|
async getMemberCreatedAttribution(memberId) {
|
|
const memberCreatedEvent = await this.models.MemberCreatedEvent.findOne({member_id: memberId}, {require: false, withRelated: []});
|
|
if (!memberCreatedEvent) {
|
|
return null;
|
|
}
|
|
const attribution = this.attributionBuilder.build({
|
|
id: memberCreatedEvent.get('attribution_id'),
|
|
url: memberCreatedEvent.get('attribution_url'),
|
|
type: memberCreatedEvent.get('attribution_type'),
|
|
referrerSource: memberCreatedEvent.get('referrer_source'),
|
|
referrerMedium: memberCreatedEvent.get('referrer_medium'),
|
|
referrerUrl: memberCreatedEvent.get('referrer_url')
|
|
});
|
|
return await attribution.fetchResource();
|
|
}
|
|
|
|
/**
|
|
* Returns the last attribution for a given subscription ID
|
|
* @param {string} subscriptionId
|
|
* @returns {Promise<import('./AttributionBuilder').AttributionResource|null>}
|
|
*/
|
|
async getSubscriptionCreatedAttribution(subscriptionId) {
|
|
const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent.findOne({subscription_id: subscriptionId}, {require: false, withRelated: []});
|
|
if (!subscriptionCreatedEvent) {
|
|
return null;
|
|
}
|
|
const attribution = this.attributionBuilder.build({
|
|
id: subscriptionCreatedEvent.get('attribution_id'),
|
|
url: subscriptionCreatedEvent.get('attribution_url'),
|
|
type: subscriptionCreatedEvent.get('attribution_type'),
|
|
referrerSource: subscriptionCreatedEvent.get('referrer_source'),
|
|
referrerMedium: subscriptionCreatedEvent.get('referrer_medium'),
|
|
referrerUrl: subscriptionCreatedEvent.get('referrer_url')
|
|
});
|
|
return await attribution.fetchResource();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
module.exports = MemberAttributionService;
|