Added links API (#15446)

closes https://github.com/TryGhost/Team/issues/1927

This expose the /links endpoint on the Admin API, which is filterable by Post ID.

Co-authored-by: Simon Backx <simon@ghost.org>
This commit is contained in:
Fabien 'egg' O'Carroll 2022-09-22 07:39:52 -04:00 committed by GitHub
parent 433842b2f2
commit 5fcf5098a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 281 additions and 34 deletions

View File

@ -13,6 +13,7 @@ export default class AnalyticsController extends Controller {
@service ghostPaths;
@tracked sources = null;
@tracked links = null;
get post() {
return this.model;
@ -21,6 +22,7 @@ export default class AnalyticsController extends Controller {
@action
loadData() {
this.fetchReferrersStats();
this.fetchLinks();
}
async fetchReferrersStats() {
@ -30,6 +32,27 @@ export default class AnalyticsController extends Controller {
return this._fetchReferrersStats.perform();
}
cleanURL(url, display = false) {
// Remove our own querystring parameters and protocol
const removeParams = ['rel', 'attribution_id', 'attribution_type'];
const urlObj = new URL(url);
for (const param of removeParams) {
urlObj.searchParams.delete(param);
}
if (!display) {
return urlObj.toString();
}
// Return URL without protocol
return urlObj.host + (urlObj.pathname === '/' ? '' : urlObj.pathname) + (urlObj.search ? '?' + urlObj.search : '');
}
async fetchLinks() {
if (this._fetchLinks.isRunning) {
return this._fetchLinks.last;
}
return this._fetchLinks.perform();
}
@task
*_fetchReferrersStats() {
let statsUrl = this.ghostPaths.url.api(`stats/referrers/posts/${this.post.id}`);
@ -42,4 +65,33 @@ export default class AnalyticsController extends Controller {
};
});
}
@task
*_fetchLinks() {
const filter = `post_id:${this.post.id}`;
let statsUrl = this.ghostPaths.url.api(`links/`) + `?filter=${encodeURIComponent(filter)}`;
let result = yield this.ajax.request(statsUrl);
const links = result.links.map((link) => {
return {
...link,
link: {
...link.link,
to: this.cleanURL(link.link.to, false),
title: this.cleanURL(link.link.to, true)
}
};
});
// Remove duplicates by title ad merge
const linksByTitle = links.reduce((acc, link) => {
if (!acc[link.link.title]) {
acc[link.link.title] = link;
} else {
acc[link.link.title].clicks += link.clicks;
}
return acc;
}, {});
this.links = Object.values(linksByTitle);
}
}

View File

@ -1,4 +1,4 @@
<section class="gh-canvas">
<section class="gh-canvas" {{did-insert this.loadData}}>
<GhCanvasHeader class="gh-canvas-header stacked gh-post-analytics-header">
<div class="gh-canvas-breadcrumb">
@ -61,32 +61,24 @@
</div>
</div>
{{#if (and this.links this.links.length) }}
<h4 class="gh-main-section-header small bn">
Link clicks
</h4>
<div class="gh-post-analytics-box column">
<div class="gh-links-list">
<div class="gh-links-list-item">
<a href="#">https://vanschneider.com/blog/#/portal/signup</a>
<p class="gh-links-list-clicks">18</p>
</div>
<div class="gh-links-list-item">
<a href="#">https://vanschneider.com/blog/an-unsolicited-portfolio-review-featuring-2/</a>
<p class="gh-links-list-clicks">16</p>
</div>
<div class="gh-links-list-item">
<a href="#">https://vanschneider.com/blog/#/portal/unsubscribe</a>
<p class="gh-links-list-clicks">3</p>
</div>
<div class="gh-links-list-item">
<a href="#">https://vanschneider.com/blog/#/portal/signin</a>
<p class="gh-links-list-clicks">0</p>
<div class="gh-post-analytics-box column">
<div class="gh-links-list">
{{#each this.links as |link|}}
<div class="gh-links-list-item">
<a href="{{link.link.to}}">{{link.link.title}}</a>
<p class="gh-links-list-clicks">{{link.count.clicks}}</p>
</div>
{{/each}}
</div>
</div>
</div>
{{/if}}
{{#if (feature 'sourceAttribution')}}
<h4 class="gh-main-section-header small bn" {{did-insert this.loadData}}>
{{#if (and (feature 'sourceAttribution') this.sources this.sources.length)}}
<h4 class="gh-main-section-header small bn">
Source attribution
</h4>
{{#if this.sources}}

View File

@ -185,6 +185,10 @@ module.exports = {
return apiFramework.pipeline(require('./comments'), localUtils);
},
get links() {
return apiFramework.pipeline(require('./links'), localUtils);
},
/**
* Content API Controllers
*

View File

@ -0,0 +1,25 @@
const linkTrackingService = require('../../services/link-click-tracking');
module.exports = {
docName: 'links',
browse: {
options: [
'filter'
],
permissions: false,
async query(frame) {
const links = await linkTrackingService.service.getLinks(frame.options);
return {
data: links,
meta: {
pagination: {
total: links.length,
page: 1,
pages: 1
}
}
};
}
}
};

View File

@ -26,6 +26,38 @@ const LinkRedirect = ghostBookshelf.Model.extend({
return attrs;
}
}, {
orderDefaultRaw(options) {
if (options.withRelated && options.withRelated.includes('count.clicks')) {
return '`count__clicks` DESC, `to` DESC';
}
return '`to` DESC';
},
permittedOptions(methodName) {
let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
const validOptions = {
findAll: ['filter', 'columns', 'withRelated']
};
if (validOptions[methodName]) {
options = options.concat(validOptions[methodName]);
}
return options;
},
countRelations() {
return {
clicks(modelOrCollection) {
modelOrCollection.query('columns', 'link_redirects.*', (qb) => {
qb.countDistinct('members_link_click_events.member_id')
.from('members_link_click_events')
.whereRaw('link_redirects.id = members_link_click_events.link_id')
.as('count__clicks');
});
}
};
}
});
module.exports = {

View File

@ -1,3 +1,4 @@
const {LinkClick} = require('@tryghost/link-tracking');
const ObjectID = require('bson-objectid').default;
module.exports = class LinkClickRepository {
@ -17,8 +18,24 @@ module.exports = class LinkClickRepository {
this.#Member = deps.Member;
}
async getAll(options) {
const collection = await this.#MemberLinkClickEvent.findAll(options);
const result = [];
for (const model of collection.models) {
const member = await this.#Member.findOne({id: model.get('member_id')});
result.push(new LinkClick({
link_id: model.get('link_id'),
member_uuid: member.get('uuid')
}));
}
return result;
}
/**
* @param {import('@tryghost/link-tracking').LinkClick} linkClick
* @param {LinkClick} linkClick
* @returns {Promise<void>}
*/
async save(linkClick) {

View File

@ -1,21 +1,55 @@
const {FullPostLink} = require('@tryghost/link-tracking');
/**
* @typedef {import('bson-objectid').default} ObjectID
* @typedef {import('@tryghost/link-tracking/lib/PostLink')} PostLink
*/
module.exports = class PostLinkRepository {
/** @type {Object} */
#LinkRedirect;
/** @type {Object} */
#linkRedirectRepository;
/**
* @param {object} deps
* @param {object} deps.LinkRedirect Bookshelf Model
* @param {object} deps.linkRedirectRepository Bookshelf Model
*/
constructor(deps) {
this.#LinkRedirect = deps.LinkRedirect;
this.#linkRedirectRepository = deps.linkRedirectRepository;
}
/**
* @param {import('@tryghost/link-tracking/lib/PostLink')} postLink
*
* @param {*} options
* @returns {Promise<InstanceType<FullPostLink>[]>}
*/
async getAll(options) {
const collection = await this.#LinkRedirect.findAll({...options, withRelated: ['count.clicks']});
const result = [];
for (const model of collection.models) {
const link = this.#linkRedirectRepository.fromModel(model);
result.push(
new FullPostLink({
post_id: model.get('post_id'),
link,
count: {
clicks: model.get('count__clicks')
}
})
);
}
return result;
}
/**
* @param {PostLink} postLink
* @returns {Promise<void>}
*/
async save(postLink) {

View File

@ -1,5 +1,6 @@
const LinkClickRepository = require('./LinkClickRepository');
const PostLinkRepository = require('./PostLinkRepository');
const errors = require('@tryghost/errors');
class LinkTrackingServiceWrapper {
async init() {
@ -8,12 +9,18 @@ class LinkTrackingServiceWrapper {
return;
}
const linkRedirection = require('../link-redirection');
if (!linkRedirection.service) {
throw new errors.InternalServerError({message: 'LinkRedirectionService should be initialised before LinkTrackingService'});
}
// Wire up all the dependencies
const models = require('../../models');
const {LinkTrackingService} = require('@tryghost/link-tracking');
const postLinkRepository = new PostLinkRepository({
LinkRedirect: models.LinkRedirect
LinkRedirect: models.LinkRedirect,
linkRedirectRepository: linkRedirection.linkRedirectRepository
});
const linkClickRepository = new LinkClickRepository({
@ -23,7 +30,7 @@ class LinkTrackingServiceWrapper {
// Expose the service
this.service = new LinkTrackingService({
linkRedirectService: require('../link-redirection').service,
linkRedirectService: linkRedirection.service,
linkClickRepository,
postLinkRepository
});

View File

@ -4,13 +4,17 @@ const ObjectID = require('bson-objectid').default;
module.exports = class LinkRedirectRepository {
/** @type {Object} */
#LinkRedirect;
/** @type {Object} */
#urlUtils;
/**
* @param {object} deps
* @param {object} deps.LinkRedirect Bookshelf Model
* @param {object} deps.urlUtils
*/
constructor(deps) {
this.#LinkRedirect = deps.LinkRedirect;
this.#urlUtils = deps.urlUtils;
}
/**
@ -27,6 +31,30 @@ module.exports = class LinkRedirectRepository {
linkRedirect.link_id = ObjectID.createFromHexString(model.id);
}
#trimLeadingSlash(url) {
return url.replace(/^\//, '');
}
fromModel(model) {
return new LinkRedirect({
id: model.id,
from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)),
to: new URL(model.get('to'))
});
}
async getAll(options) {
const collection = await this.#LinkRedirect.findAll(options);
const result = [];
for (const model of collection.models) {
result.push(this.fromModel(model));
}
return result;
}
/**
*
* @param {URL} url
@ -40,11 +68,7 @@ module.exports = class LinkRedirectRepository {
}, {});
if (linkRedirect) {
return new LinkRedirect({
id: linkRedirect.id,
from: url,
to: new URL(linkRedirect.get('to'))
});
return this.fromModel(linkRedirect);
}
}
};

View File

@ -13,13 +13,14 @@ class LinkRedirectsServiceWrapper {
const {LinkRedirectsService} = require('@tryghost/link-redirects');
const linkRedirectRepository = new LinkRedirectRepository({
LinkRedirect: models.LinkRedirect
this.linkRedirectRepository = new LinkRedirectRepository({
LinkRedirect: models.LinkRedirect,
urlUtils
});
// Expose the service
this.service = new LinkRedirectsService({
linkRedirectRepository,
linkRedirectRepository: this.linkRedirectRepository,
config: {
baseURL: new URL(urlUtils.getSiteUrl())
}

View File

@ -3,6 +3,7 @@ const api = require('../../../../api').endpoints;
const {http} = require('@tryghost/api-framework');
const apiMw = require('../../middleware');
const mw = require('./middleware');
const labs = require('../../../../../shared/labs');
const shared = require('../../../shared');
@ -309,5 +310,7 @@ module.exports = function apiRoutes() {
router.put('/newsletters/verifications/', mw.authAdminApi, http(api.newsletters.verifyPropertyUpdate));
router.put('/newsletters/:id', mw.authAdminApi, http(api.newsletters.edit));
router.get('/links', labs.enabledMiddleware('emailClicks'), mw.authAdminApi, http(api.links.browse));
return router;
};

View File

@ -6,6 +6,7 @@ const LinkRedirect = require('./LinkRedirect');
/**
* @typedef {object} ILinkRedirectRepository
* @prop {(url: URL) => Promise<LinkRedirect|undefined>} getByURL
* @prop {({filter: string}) => Promise<LinkRedirect[]>} getAll
* @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
*/

View File

@ -0,0 +1,36 @@
const ObjectID = require('bson-objectid').default;
/**
* @typedef {Object} FullPostLinkCount
* @property {number} clicks
*/
/**
* Stores the connection between a LinkRedirect and a Post
*/
module.exports = class FullPostLink {
/** @type {ObjectID} */
post_id;
/** @type {import('@tryghost/link-redirects/lib/LinkRedirect')} */
link;
/** @type {FullPostLinkCount} */
count;
/**
* @param {object} data
* @param {string|ObjectID} data.post_id
* @param {import('@tryghost/link-redirects/lib/LinkRedirect')} data.link
* @param {FullPostLinkCount} data.count
*/
constructor(data) {
if (typeof data.post_id === 'string') {
this.post_id = ObjectID.createFromHexString(data.post_id);
} else {
this.post_id = data.post_id;
}
this.link = data.link;
this.count = data.count;
}
};

View File

@ -7,6 +7,7 @@ const ObjectID = require('bson-objectid').default;
/**
* @typedef {object} ILinkClickRepository
* @prop {(event: LinkClick) => Promise<void>} save
* @prop {({filter: string}) => Promise<LinkClick[]>} getAll
*/
/**
@ -16,10 +17,15 @@ const ObjectID = require('bson-objectid').default;
* @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
*/
/**
@ -30,6 +36,7 @@ const ObjectID = require('bson-objectid').default;
/**
* @typedef {object} IPostLinkRepository
* @prop {(postLink: PostLink) => Promise<void>} save
* @prop {({filter: string}) => Promise<FullPostLink[]>} getAll
*/
class LinkClickTrackingService {
@ -62,6 +69,17 @@ class LinkClickTrackingService {
this.#initialised = true;
}
/**
* @param {object} options
* @param {string} options.filter
* @return {Promise<FullPostLink[]>}
*/
async getLinks(options) {
return await this.#postLinkRepository.getAll({
filter: options.filter
});
}
/**
* Replace URL with a redirect that redirects to the original URL, and link that redirect with the given post
*/

View File

@ -1,5 +1,6 @@
module.exports = {
LinkTrackingService: require('./LinkClickTrackingService'),
LinkClick: require('./LinkClick'),
PostLink: require('./PostLink')
PostLink: require('./PostLink'),
FullPostLink: require('./FullPostLink')
};