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
241 lines
7.6 KiB
JavaScript
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;
|