Added endpoint for fixing newsletter links

refs https://github.com/TryGhost/Team/issues/2104

- adds new bulk edit endpoint for links, updates all matching link with the current redirect url and update to new url
This commit is contained in:
Rishabh 2022-10-18 15:33:27 +05:30 committed by Rishabh Garg
parent 40b8a838ae
commit d8bacf12d1
15 changed files with 1123 additions and 12 deletions

View File

@ -21,5 +21,35 @@ module.exports = {
}
};
}
},
bulkEdit: {
statusCode: 200,
headers: {},
options: [
'filter'
],
data: [
'action',
'meta'
],
validation: {
data: {
action: {
required: true,
values: ['updateLink']
}
},
options: {
filter: {
required: true
}
}
},
permissions: {
method: 'edit'
},
async query(frame) {
return await linkTrackingService.service.bulkEdit(frame.data.bulk, frame.options);
}
}
};

View File

@ -127,5 +127,9 @@ module.exports = {
get members_stripe_connect() {
return require('./members-stripe-connect');
},
get links() {
return require('./links');
}
};

View File

@ -0,0 +1,5 @@
module.exports = {
bulkEdit(data, apiConfig, frame) {
frame.response = data;
}
};

View File

@ -18,7 +18,7 @@ module.exports = class LinkRedirectRepository {
}
/**
* @param {InstanceType<LinkRedirect>} linkRedirect
* @param {InstanceType<LinkRedirect>} linkRedirect
* @returns {Promise<void>}
*/
async save(linkRedirect) {
@ -55,10 +55,17 @@ module.exports = class LinkRedirectRepository {
return result;
}
async getFilteredIds(options) {
const linkRows = await this.#LinkRedirect.getFilteredCollectionQuery(options)
.select('redirects.id')
.distinct();
return linkRows.map(row => row.id);
}
/**
*
* @param {URL} url
* @returns {Promise<InstanceType<LinkRedirect>|undefined>} linkRedirect
*
* @param {URL} url
* @returns {Promise<InstanceType<LinkRedirect>|undefined>} linkRedirect
*/
async getByURL(url) {
// Strip subdirectory from path

View File

@ -1,4 +1,5 @@
const {FullPostLink} = require('@tryghost/link-tracking');
const _ = require('lodash');
/**
* @typedef {import('bson-objectid').default} ObjectID
@ -22,8 +23,8 @@ module.exports = class PostLinkRepository {
}
/**
*
* @param {*} options
*
* @param {*} options
* @returns {Promise<InstanceType<FullPostLink>[]>}
*/
async getAll(options) {
@ -48,6 +49,29 @@ module.exports = class PostLinkRepository {
return result;
}
async updateLinks(linkIds, updateData, options) {
const bulkUpdateOptions = _.pick(options, ['transacting']);
const bulkActionResult = await this.#LinkRedirect.bulkEdit(linkIds, 'redirects', {
...bulkUpdateOptions,
data: updateData
});
return {
bulk: {
action: 'updateLink',
meta: {
stats: {
successful: bulkActionResult.successful,
unsuccessful: bulkActionResult.unsuccessful
},
errors: bulkActionResult.errors,
unsuccessfulData: bulkActionResult.unsuccessfulData
}
}
};
}
/**
* @param {PostLink} postLink
* @returns {Promise<void>}

View File

@ -1,6 +1,7 @@
const LinkClickRepository = require('./LinkClickRepository');
const PostLinkRepository = require('./PostLinkRepository');
const errors = require('@tryghost/errors');
const urlUtils = require('../../../shared/url-utils');
class LinkTrackingServiceWrapper {
async init() {
@ -38,7 +39,8 @@ class LinkTrackingServiceWrapper {
linkRedirectService: linkRedirection.service,
linkClickRepository: this.linkClickRepository,
postLinkRepository,
DomainEvents
DomainEvents,
urlUtils
});
await this.service.init();

View File

@ -310,6 +310,7 @@ module.exports = function apiRoutes() {
router.put('/newsletters/:id', mw.authAdminApi, http(api.newsletters.edit));
router.get('/links', mw.authAdminApi, http(api.links.browse));
router.put('/links/bulk', mw.authAdminApi, http(api.links.bulkEdit));
return router;
};

View File

@ -0,0 +1,557 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Links API Can browse all links 1: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can browse all links 2: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can browse all links 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "885",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can browse all links 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "885",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can bulk update links with external redirect 1: [body] 1`] = `
Object {
"bulk": Object {
"action": "updateLink",
"meta": Object {
"errors": Array [],
"stats": Object {
"successful": 1,
"unsuccessful": 0,
},
"unsuccessfulData": Array [],
},
},
}
`;
exports[`Links API Can bulk update links with external redirect 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "117",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can bulk update links with external redirect 3: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": "https://example.com/subscribe?ref=Test-newsletter",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can bulk update links with external redirect 4: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": "https://example.com/subscribe?ref=Test-newsletter",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can bulk update links with external redirect 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "885",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can bulk update links with external redirect 5: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "885",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can bulk update multiple links with same site redirect 1: [body] 1`] = `
Object {
"bulk": Object {
"action": "updateLink",
"meta": Object {
"errors": Array [],
"stats": Object {
"successful": 2,
"unsuccessful": 0,
},
"unsuccessfulData": Array [],
},
},
}
`;
exports[`Links API Can bulk update multiple links with same site redirect 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "117",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can bulk update multiple links with same site redirect 3: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can bulk update multiple links with same site redirect 4: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can bulk update multiple links with same site redirect 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "841",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can bulk update multiple links with same site redirect 5: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "841",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can call bulk update link with 0 matches 1: [body] 1`] = `
Object {
"bulk": Object {
"action": "updateLink",
"meta": Object {
"errors": Array [],
"stats": Object {
"successful": 0,
"unsuccessful": 0,
},
"unsuccessfulData": Array [],
},
},
}
`;
exports[`Links API Can call bulk update link with 0 matches 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "117",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can call bulk update link with 0 matches 3: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": "https://example.com/subscripe?ref=Test-newsletter",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can call bulk update link with 0 matches 4: [body] 1`] = `
Object {
"links": Array [
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": "https://example.com/subscripe?ref=Test-newsletter",
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
Object {
"count": Object {
"clicks": Any<Number>,
},
"link": Object {
"from": Any<String>,
"link_id": Any<String>,
"to": Any<String>,
},
"post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"meta": Object {
"pagination": Object {
"page": 1,
"pages": 1,
"total": 3,
},
},
}
`;
exports[`Links API Can call bulk update link with 0 matches 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "885",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Links API Can call bulk update link with 0 matches 5: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "885",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;

View File

@ -0,0 +1,215 @@
const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {anyObjectId, anyString, anyEtag, anyNumber} = matchers;
const matchLink = {
post_id: anyObjectId,
link: {
link_id: anyObjectId,
from: anyString,
to: anyString
},
count: {
clicks: anyNumber
}
};
describe('Links API', function () {
let agent;
beforeEach(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts', 'links');
await agent.loginAsOwner();
});
it('Can browse all links', async function () {
await agent
.get('links')
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
links: new Array(3).fill(matchLink)
});
});
it('Can bulk update multiple links with same site redirect', async function () {
const req = await agent.get('links');
const siteLink = req.body.links.find((link) => {
return link.link.to.includes('/email/');
});
const postId = siteLink.post_id;
const originalTo = siteLink.link.to;
const filter = `post_id:${postId}+to:'${originalTo}'`;
await agent
.put(`links/bulk/?filter=${encodeURIComponent(filter)}`)
.body({
bulk: {
action: 'updateLink',
meta: {
link: {
to: 'http://127.0.0.1:2369/blog/emails/test?example=1'
}
}
}
})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
action: 'updateLink',
meta: {
stats: {
successful: 2,
unsuccessful: 0
},
errors: [],
unsuccessfulData: []
}
}
})
.matchHeaderSnapshot({
etag: anyEtag
});
await agent
.get('links')
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
links: [
matchLink,
{
...matchLink,
link: {
...matchLink.link,
to: 'http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df'
}
},
{
...matchLink,
link: {
...matchLink.link,
to: 'http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df'
}
}
]
});
});
it('Can bulk update links with external redirect', async function () {
const req = await agent.get('links');
const siteLink = req.body.links.find((link) => {
return link.link.to.includes('subscripe');
});
const postId = siteLink.post_id;
const originalTo = siteLink.link.to;
const filter = `post_id:${postId}+to:'${originalTo}'`;
await agent
.put(`links/bulk/?filter=${encodeURIComponent(filter)}`)
.body({
bulk: {
action: 'updateLink',
meta: {
link: {
to: 'https://example.com/subscribe?ref=Test-newsletter'
}
}
}
})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
action: 'updateLink',
meta: {
stats: {
successful: 1,
unsuccessful: 0
},
errors: [],
unsuccessfulData: []
}
}
})
.matchHeaderSnapshot({
etag: anyEtag
});
await agent
.get('links')
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
links: [
{
...matchLink,
link: {
...matchLink.link,
to: 'https://example.com/subscribe?ref=Test-newsletter'
}
},
matchLink,
matchLink
]
});
});
it('Can call bulk update link with 0 matches', async function () {
const req = await agent.get('links');
const siteLink = req.body.links.find((link) => {
return link.link.to.includes('subscripe');
});
const postId = siteLink.post_id;
const originalTo = 'https://empty.example.com';
const filter = `post_id:${postId}+to:'${originalTo}'`;
await agent
.put(`links/bulk/?filter=${encodeURIComponent(filter)}`)
.body({
bulk: {
action: 'updateLink',
meta: {
link: {
to: 'https://example.com/subscribe?ref=Test-newsletter'
}
}
}
})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
action: 'updateLink',
meta: {
stats: {
successful: 0,
unsuccessful: 0
},
errors: [],
unsuccessfulData: []
}
}
})
.matchHeaderSnapshot({
etag: anyEtag
});
await agent
.get('links')
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
links: [
{
...matchLink,
link: {
...matchLink.link,
to: 'https://example.com/subscripe?ref=Test-newsletter'
}
},
matchLink,
matchLink
]
});
});
});

View File

@ -0,0 +1,56 @@
const should = require('should');
const sinon = require('sinon');
const PostLinkRepository = require('../../../../../core/server/services/link-tracking/PostLinkRepository');
describe('UNIT: PostLinkRepository class', function () {
let postLinkRepository;
before(function () {
postLinkRepository = new PostLinkRepository({
LinkRedirect: {
getFilteredCollectionQuery: sinon.stub().returns({
select: sinon.stub().returns({
distinct: sinon.stub().returns([])
})
}),
bulkEdit: sinon.stub().returns({
successful: 0,
unsuccessful: 0,
errors: [],
unsuccessfulData: []
})
},
linkRedirectRepository: {}
});
});
beforeEach(function () {
});
afterEach(function () {
sinon.restore();
});
describe('updateLinks', function () {
it('should return correct response format', async function () {
const links = await postLinkRepository.updateLinks(['abc'], {
to: 'https://example.com',
updated_at: new Date('2022-10-20T00:00:00.000Z')
}, {});
should(links).eql({
bulk: {
action: 'updateLink',
meta: {
stats: {
successful: 0,
unsuccessful: 0
},
errors: [],
unsuccessfulData: []
}
}
});
});
});
});

View File

@ -455,6 +455,12 @@ const fixtures = {
});
},
insertLinks: function insertLinks() {
return Promise.map(DataGenerator.forKnex.links, function (link) {
return models.Redirect.add(link, context.internal);
});
},
insertEmails: function insertEmails() {
return Promise.map(DataGenerator.forKnex.emails, function (email) {
return models.Email.add(email, context.internal);
@ -803,6 +809,9 @@ const toDoList = {
},
feedback: function insertFeedback() {
return fixtures.insertFeedback();
},
links: function insertLinks() {
return fixtures.insertLinks();
}
};

View File

@ -838,6 +838,30 @@ DataGenerator.Content = {
parent_id: '6195c6a1e792de832cd08144',
member_index: 1
}
],
links: [
{
id: ObjectId().toHexString(),
from: '/r/70b0129a',
to: '__GHOST_URL__/blog/email/01cd4df3-83fa-4921-83be-3bb9a465ef83/?ref=Test-newsletter&attribution_id=6343994e7216ffcbce491716&attribution_type=post',
created_at: null,
updated_at: null
},
{
id: ObjectId().toHexString(),
from: '/r/a0b0129a',
to: '__GHOST_URL__/blog/email/01cd4df3-83fa-4921-83be-3bb9a465ef83/?ref=Test-newsletter&attribution_id=6343994e7216ffcbce491716&attribution_type=post',
created_at: null,
updated_at: null
},
{
id: ObjectId().toHexString(),
from: '/r/20b0129a',
to: 'https://example.com/subscripe?ref=Test-newsletter',
created_at: null,
updated_at: null
}
]
};
@ -874,6 +898,9 @@ DataGenerator.Content.members_stripe_customers[4].member_id = DataGenerator.Cont
DataGenerator.Content.members_paid_subscription_events[0].member_id = DataGenerator.Content.members[2].id;
DataGenerator.Content.members_paid_subscription_events[1].member_id = DataGenerator.Content.members[3].id;
DataGenerator.Content.members_paid_subscription_events[2].member_id = DataGenerator.Content.members[4].id;
DataGenerator.Content.links[0].post_id = DataGenerator.Content.posts[0].id;
DataGenerator.Content.links[1].post_id = DataGenerator.Content.posts[0].id;
DataGenerator.Content.links[2].post_id = DataGenerator.Content.posts[0].id;
DataGenerator.forKnex = (function () {
function createBasic(overrides) {
@ -1268,6 +1295,14 @@ DataGenerator.forKnex = (function () {
});
}
function createLink(overrides) {
const newObj = _.cloneDeep(overrides);
return _.defaults(newObj, {
created_at: new Date(),
updated_at: new Date()
});
}
const posts = [
createPost(DataGenerator.Content.posts[0]),
createPost(DataGenerator.Content.posts[1]),
@ -1736,6 +1771,12 @@ DataGenerator.forKnex = (function () {
createComment(DataGenerator.Content.comments[1])
];
const links = [
createLink(DataGenerator.Content.links[0]),
createLink(DataGenerator.Content.links[1]),
createLink(DataGenerator.Content.links[2])
];
return {
createPost,
createGenericPost,
@ -1797,6 +1838,7 @@ DataGenerator.forKnex = (function () {
custom_theme_settings,
comments,
redirects,
links,
members_paid_subscription_events,
members_created_events,

View File

@ -7,6 +7,7 @@ const LinkRedirect = require('./LinkRedirect');
* @typedef {object} ILinkRedirectRepository
* @prop {(url: URL) => Promise<LinkRedirect|undefined>} getByURL
* @prop {({filter: string}) => Promise<LinkRedirect[]>} getAll
* @prop {({filter: string}) => Promise<String[]>} getFilteredIds
* @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
*/
@ -47,6 +48,15 @@ class LinkRedirectsService {
return url;
}
/**
* @param {Object} options
*
* @returns {Promise<String[]>}
*/
async getFilteredIds(options) {
return await this.#linkRedirectRepository.getFilteredIds(options);
}
/**
* @param {URL} from
* @param {URL} to

View File

@ -2,6 +2,10 @@ const {RedirectEvent} = require('@tryghost/link-redirects');
const LinkClick = require('./LinkClick');
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');
/**
* @typedef {object} ILinkClickRepository
@ -25,14 +29,22 @@ const ObjectID = require('bson-objectid').default;
* @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;
@ -44,6 +56,10 @@ class LinkClickTrackingService {
#postLinkRepository;
/** @type DomainEvents */
#DomainEvents;
/** @type {Object} */
#LinkRedirect;
/** @type {Object} */
#urlUtils;
/**
* @param {object} deps
@ -51,12 +67,14 @@ class LinkClickTrackingService {
* @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() {
@ -78,7 +96,97 @@ class LinkClickTrackingService {
});
}
/**
/**
* 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: new Date()
};
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
*/

View File

@ -1,5 +1,6 @@
const LinkClickTrackingService = require('../lib/LinkClickTrackingService');
const sinon = require('sinon');
const should = require('should');
const assert = require('assert');
const ObjectID = require('bson-objectid').default;
const PostLink = require('../lib/PostLink');
@ -34,10 +35,10 @@ describe('LinkClickTrackingService', function () {
}
});
const links = await service.getLinks({filter: 'post_id:1'});
// Check called with filter
assert.ok(getAll.calledOnceWithExactly({filter: 'post_id:1'}));
// Check returned value
assert.deepStrictEqual(links, ['test']);
});
@ -66,7 +67,7 @@ describe('LinkClickTrackingService', function () {
// Check getSlugUrl called
assert(getSlugUrl.calledOnce);
// Check save called
assert(
save.calledOnceWithExactly(
@ -102,7 +103,7 @@ describe('LinkClickTrackingService', function () {
// Check getSlugUrl called
assert(getSlugUrl.calledOnce);
// Check save called
assert(
save.calledOnceWithExactly(
@ -168,4 +169,44 @@ describe('LinkClickTrackingService', function () {
assert.equal(save.firstCall.args[0].link_id, linkId);
});
});
describe('bulkEdit', function () {
it('returns the result of updating links', async function () {
const service = new LinkClickTrackingService({
urlUtils: {
absoluteToTransformReady: (d) => {
return d;
},
isSiteUrl: sinon.stub().returns(true)
},
postLinkRepository: {
updateLinks: sinon.stub().resolves({
successful: 0,
unsuccessful: 0,
errors: [],
unsuccessfulData: []
})
},
linkRedirectService: {
getFilteredIds: sinon.stub().resolves([])
}
});
const options = {
filter: `post_id:1+to:'https://test.com'`
};
const result = await service.bulkEdit({
action: 'updateLink',
meta: {
link: {to: 'https://example.com'}
}
}, options);
should(result).eql({
successful: 0,
unsuccessful: 0,
errors: [],
unsuccessfulData: []
});
});
});
});