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:
parent
433842b2f2
commit
5fcf5098a8
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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}}
|
||||
|
@ -185,6 +185,10 @@ module.exports = {
|
||||
return apiFramework.pipeline(require('./comments'), localUtils);
|
||||
},
|
||||
|
||||
get links() {
|
||||
return apiFramework.pipeline(require('./links'), localUtils);
|
||||
},
|
||||
|
||||
/**
|
||||
* Content API Controllers
|
||||
*
|
||||
|
25
ghost/core/core/server/api/endpoints/links.js
Normal file
25
ghost/core/core/server/api/endpoints/links.js
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
@ -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 = {
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
|
||||
*/
|
||||
|
||||
|
36
ghost/link-tracking/lib/FullPostLink.js
Normal file
36
ghost/link-tracking/lib/FullPostLink.js
Normal 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;
|
||||
}
|
||||
};
|
@ -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
|
||||
*/
|
||||
|
@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
LinkTrackingService: require('./LinkClickTrackingService'),
|
||||
LinkClick: require('./LinkClick'),
|
||||
PostLink: require('./PostLink')
|
||||
PostLink: require('./PostLink'),
|
||||
FullPostLink: require('./FullPostLink')
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user