Skip to content

Commit

Permalink
Added links API (#15446)
Browse files Browse the repository at this point in the history
closes TryGhost/Product#1927

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

Co-authored-by: Simon Backx <simon@ghost.org>
  • Loading branch information
allouis and SimonBackx committed Sep 22, 2022
1 parent 433842b commit 5fcf509
Show file tree
Hide file tree
Showing 15 changed files with 281 additions and 34 deletions.
52 changes: 52 additions & 0 deletions ghost/admin/app/controllers/posts/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default class AnalyticsController extends Controller {
@service ghostPaths;

@tracked sources = null;
@tracked links = null;

get post() {
return this.model;
Expand All @@ -21,6 +22,7 @@ export default class AnalyticsController extends Controller {
@action
loadData() {
this.fetchReferrersStats();
this.fetchLinks();
}

async fetchReferrersStats() {
Expand All @@ -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}`);
Expand All @@ -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);
}
}
34 changes: 13 additions & 21 deletions ghost/admin/app/templates/posts/analytics.hbs
Original file line number Diff line number Diff line change
@@ -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">
Expand Down Expand Up @@ -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}}
Expand Down
4 changes: 4 additions & 0 deletions ghost/core/core/server/api/endpoints/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ module.exports = {
return apiFramework.pipeline(require('./comments'), localUtils);
},

get links() {
return apiFramework.pipeline(require('./links'), localUtils);
},

/**
* Content API Controllers
*
Expand Down
25 changes: 25 additions & 0 deletions ghost/core/core/server/api/endpoints/links.js
Original file line number Diff line number Diff line change
@@ -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
}
}
};
}
}
};
32 changes: 32 additions & 0 deletions ghost/core/core/server/models/link-redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const {LinkClick} = require('@tryghost/link-tracking');
const ObjectID = require('bson-objectid').default;

module.exports = class LinkClickRepository {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {*} 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 {import('@tryghost/link-tracking/lib/PostLink')} postLink
* @param {PostLink} postLink
* @returns {Promise<void>}
*/
async save(postLink) {
Expand Down
11 changes: 9 additions & 2 deletions ghost/core/core/server/services/link-click-tracking/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const LinkClickRepository = require('./LinkClickRepository');
const PostLinkRepository = require('./PostLinkRepository');
const errors = require('@tryghost/errors');

class LinkTrackingServiceWrapper {
async init() {
Expand All @@ -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({
Expand All @@ -23,7 +30,7 @@ class LinkTrackingServiceWrapper {

// Expose the service
this.service = new LinkTrackingService({
linkRedirectService: require('../link-redirection').service,
linkRedirectService: linkRedirection.service,
linkClickRepository,
postLinkRepository
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand All @@ -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);
}
}
};
Loading

0 comments on commit 5fcf509

Please sign in to comment.