Ghost/ghost/link-tracking/lib/LinkClickTrackingService.js
Fabien "egg" O'Carroll 104f84f252 Added eslint rule for file naming convention
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
2023-05-09 12:34:34 -04:00

241 lines
7.6 KiB
JavaScript

const {RedirectEvent} = require('@tryghost/link-redirects');
const LinkClick = require('./ClickEvent');
const PostLink = require('./PostLink');
const ObjectID = require('bson-objectid').default;
const errors = require('@tryghost/errors');
const nql = require('@tryghost/nql');
const _ = require('lodash');
const tpl = require('@tryghost/tpl');
const moment = require('moment');
/**
* @typedef {object} ILinkClickRepository
* @prop {(event: LinkClick) => Promise<void>} save
* @prop {({filter: string}) => Promise<LinkClick[]>} getAll
*/
/**
* @typedef {object} ILinkRedirect
* @prop {ObjectID} link_id
* @prop {URL} to
* @prop {URL} from
*/
/**
* @typedef {import('./FullPostLink')} FullPostLink
*/
/**
* @typedef {object} ILinkRedirectService
* @prop {(to: URL, slug: string) => Promise<ILinkRedirect>} addRedirect
* @prop {() => Promise<string>} getSlug
* @prop {({filter: string}) => Promise<ILinkRedirect[]>} getAll
* @prop {({filter: string}) => Promise<string[]>} getFilteredIds
*/
/**
* @typedef {object} IPostLinkRepository
* @prop {(postLink: PostLink) => Promise<void>} save
* @prop {({filter: string}) => Promise<FullPostLink[]>} getAll
* @prop {(linkIds: array, data, options) => Promise<FullPostLink[]>} updateLinks
*/
const messages = {
invalidFilter: 'Invalid filter value received',
unsupportedBulkAction: 'Unsupported bulk action',
invalidRedirectUrl: 'Invalid redirect URL value'
};
class LinkClickTrackingService {
#initialised = false;
/** @type ILinkClickRepository */
#linkClickRepository;
/** @type ILinkRedirectService */
#linkRedirectService;
/** @type IPostLinkRepository */
#postLinkRepository;
/** @type DomainEvents */
#DomainEvents;
/** @type {Object} */
#LinkRedirect;
/** @type {Object} */
#urlUtils;
/**
* @param {object} deps
* @param {ILinkClickRepository} deps.linkClickRepository
* @param {ILinkRedirectService} deps.linkRedirectService
* @param {IPostLinkRepository} deps.postLinkRepository
* @param {DomainEvents} deps.DomainEvents
* @param {urlUtils} deps.urlUtils
*/
constructor(deps) {
this.#linkClickRepository = deps.linkClickRepository;
this.#linkRedirectService = deps.linkRedirectService;
this.#postLinkRepository = deps.postLinkRepository;
this.#DomainEvents = deps.DomainEvents;
this.#urlUtils = deps.urlUtils;
}
async init() {
if (this.#initialised) {
return;
}
this.subscribe();
this.#initialised = true;
}
/**
* @param {object} options
* @param {string} options.filter
* @return {Promise<FullPostLink[]>}
*/
async getLinks(options) {
return await this.#postLinkRepository.getAll({
filter: options.filter
});
}
/**
* validate and manage the new redirect url in filter
* `to` url needs decoding and transformation to relative url for comparision
* @param {string} filter
* @returns {Object} parsed filter
* @throws {errors.BadRequestError}
*/
#parseLinkFilter(filter) {
// decode filter to manage any encoded uri components
filter = decodeURIComponent(filter);
try {
const filterJson = nql(filter).parse();
const postId = filterJson?.$and?.[0]?.post_id;
const redirectUrl = new URL(filterJson?.$and?.[1]?.to);
if (!postId || !redirectUrl) {
throw new errors.BadRequestError({
message: tpl(messages.invalidFilter)
});
}
return {
postId,
redirectUrl
};
} catch (e) {
throw new errors.BadRequestError({
message: tpl(messages.invalidFilter),
context: e.message
});
}
}
#getRedirectLinkWithAttribution({newLink, oldLink, postId}) {
const newUrl = new URL(newLink);
const oldUrl = new URL(oldLink);
// append newsletter ref query param from oldUrl to newUrl
if (oldUrl.searchParams.has('ref')) {
newUrl.searchParams.set('ref', oldUrl.searchParams.get('ref'));
}
// append post attribution to site urls
const isSite = this.#urlUtils.isSiteUrl(newUrl);
if (isSite) {
newUrl.searchParams.set('attribution_type', 'post');
newUrl.searchParams.set('attribution_id', postId);
}
return newUrl;
}
async #updateLinks(data, options) {
const filterOptions = _.pick(options, ['transacting', 'context', 'filter']);
// decode and parse filter to manage new redirect url
const {postId, redirectUrl} = this.#parseLinkFilter(filterOptions.filter);
// manages transformation of current url to relative for comparision
const transformedOldUrl = this.#urlUtils.absoluteToTransformReady(redirectUrl.href);
const filterQuery = `post_id:${postId}+to:'${transformedOldUrl}'`;
const updatedFilterOptions = {
...filterOptions,
filter: filterQuery
};
// get new redirect link with proper attribution
const newRedirectUrl = this.#getRedirectLinkWithAttribution({
newLink: data.meta?.link?.to,
oldLink: redirectUrl.href,
postId
});
const linkIds = await this.#linkRedirectService.getFilteredIds(updatedFilterOptions);
const bulkUpdateOptions = _.pick(options, ['transacting']);
const updateData = {
to: this.#urlUtils.absoluteToTransformReady(newRedirectUrl.href),
updated_at: moment().format('YYYY-MM-DD HH:mm:ss')
};
return await this.#postLinkRepository.updateLinks(linkIds, updateData, bulkUpdateOptions);
}
async bulkEdit(data, options) {
if (data.action === 'updateLink') {
return await this.#updateLinks(data, options);
}
throw new errors.IncorrectUsageError({
message: tpl(messages.unsupportedBulkAction)
});
}
/**
* @private (not using # to allow tests)
* Replace URL with a redirect that redirects to the original URL, and link that redirect with the given post
*/
async addRedirectToUrl(url, post) {
// Generate a unique redirect slug
const slugUrl = await this.#linkRedirectService.getSlugUrl();
// Add redirect for link click tracking
const redirect = await this.#linkRedirectService.addRedirect(slugUrl, url);
// Store a reference of the link against the post
const postLink = new PostLink({
link_id: redirect.link_id,
post_id: ObjectID.createFromHexString(post.id)
});
await this.#postLinkRepository.save(postLink);
return redirect.from;
}
/**
* Add tracking to a URL and returns a new URL (if link click tracking is enabled)
* @param {URL} url
* @param {Post} post
* @param {string} memberUuid
* @return {Promise<URL>}
*/
async addTrackingToUrl(url, post, memberUuid) {
url = await this.addRedirectToUrl(url, post);
url.searchParams.set('m', memberUuid);
return url;
}
subscribe() {
this.#DomainEvents.subscribe(RedirectEvent, async (event) => {
const uuid = event.data.url.searchParams.get('m');
if (!uuid) {
return;
}
const click = new LinkClick({
member_uuid: uuid,
link_id: event.data.link.link_id
});
await this.#linkClickRepository.save(click);
});
}
}
module.exports = LinkClickTrackingService;