diff --git a/ghost/core/core/server/api/endpoints/links.js b/ghost/core/core/server/api/endpoints/links.js index 9fe82887ab..f9be4e0c90 100644 --- a/ghost/core/core/server/api/endpoints/links.js +++ b/ghost/core/core/server/api/endpoints/links.js @@ -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); + } } }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js index 7dc5768b64..d23d86a4d3 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js @@ -127,5 +127,9 @@ module.exports = { get members_stripe_connect() { return require('./members-stripe-connect'); + }, + + get links() { + return require('./links'); } }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js new file mode 100644 index 0000000000..c030f8c44a --- /dev/null +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js @@ -0,0 +1,5 @@ +module.exports = { + bulkEdit(data, apiConfig, frame) { + frame.response = data; + } +}; diff --git a/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js b/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js index 8359746209..28f3e06010 100644 --- a/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js +++ b/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js @@ -18,7 +18,7 @@ module.exports = class LinkRedirectRepository { } /** - * @param {InstanceType} linkRedirect + * @param {InstanceType} linkRedirect * @returns {Promise} */ 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|undefined>} linkRedirect + * + * @param {URL} url + * @returns {Promise|undefined>} linkRedirect */ async getByURL(url) { // Strip subdirectory from path diff --git a/ghost/core/core/server/services/link-tracking/PostLinkRepository.js b/ghost/core/core/server/services/link-tracking/PostLinkRepository.js index 46c08ba93a..844a10028d 100644 --- a/ghost/core/core/server/services/link-tracking/PostLinkRepository.js +++ b/ghost/core/core/server/services/link-tracking/PostLinkRepository.js @@ -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[]>} */ 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} diff --git a/ghost/core/core/server/services/link-tracking/index.js b/ghost/core/core/server/services/link-tracking/index.js index 7a72c5a87d..a3608eaa9f 100644 --- a/ghost/core/core/server/services/link-tracking/index.js +++ b/ghost/core/core/server/services/link-tracking/index.js @@ -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(); diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 038d695075..ea5cf09892 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -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; }; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap new file mode 100644 index 0000000000..a64e7d793c --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap @@ -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, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "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, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "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, + }, + "link": Object { + "from": Any, + "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, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "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, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "https://example.com/subscribe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "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, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "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, + }, + "link": Object { + "from": Any, + "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, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "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, + }, + "link": Object { + "from": Any, + "link_id": Any, + "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, + }, + "link": Object { + "from": Any, + "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, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "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, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "https://example.com/subscripe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "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", +} +`; diff --git a/ghost/core/test/e2e-api/admin/links.test.js b/ghost/core/test/e2e-api/admin/links.test.js new file mode 100644 index 0000000000..2b05da11f9 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/links.test.js @@ -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 + ] + }); + }); +}); diff --git a/ghost/core/test/unit/server/services/link-tracking/PostLinkRepository.test.js b/ghost/core/test/unit/server/services/link-tracking/PostLinkRepository.test.js new file mode 100644 index 0000000000..d7b1cd732a --- /dev/null +++ b/ghost/core/test/unit/server/services/link-tracking/PostLinkRepository.test.js @@ -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: [] + } + } + }); + }); + }); +}); diff --git a/ghost/core/test/utils/fixture-utils.js b/ghost/core/test/utils/fixture-utils.js index c98d1b69d6..2fdb5dd1ff 100644 --- a/ghost/core/test/utils/fixture-utils.js +++ b/ghost/core/test/utils/fixture-utils.js @@ -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(); } }; diff --git a/ghost/core/test/utils/fixtures/data-generator.js b/ghost/core/test/utils/fixtures/data-generator.js index 6a527ac92a..f27c2032c0 100644 --- a/ghost/core/test/utils/fixtures/data-generator.js +++ b/ghost/core/test/utils/fixtures/data-generator.js @@ -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, diff --git a/ghost/link-redirects/lib/LinkRedirectsService.js b/ghost/link-redirects/lib/LinkRedirectsService.js index 81ddfa2041..d460208dae 100644 --- a/ghost/link-redirects/lib/LinkRedirectsService.js +++ b/ghost/link-redirects/lib/LinkRedirectsService.js @@ -7,6 +7,7 @@ const LinkRedirect = require('./LinkRedirect'); * @typedef {object} ILinkRedirectRepository * @prop {(url: URL) => Promise} getByURL * @prop {({filter: string}) => Promise} getAll + * @prop {({filter: string}) => Promise} getFilteredIds * @prop {(linkRedirect: LinkRedirect) => Promise} save */ @@ -47,6 +48,15 @@ class LinkRedirectsService { return url; } + /** + * @param {Object} options + * + * @returns {Promise} + */ + async getFilteredIds(options) { + return await this.#linkRedirectRepository.getFilteredIds(options); + } + /** * @param {URL} from * @param {URL} to diff --git a/ghost/link-tracking/lib/LinkClickTrackingService.js b/ghost/link-tracking/lib/LinkClickTrackingService.js index 5d6ea16f7f..cf39eacdbb 100644 --- a/ghost/link-tracking/lib/LinkClickTrackingService.js +++ b/ghost/link-tracking/lib/LinkClickTrackingService.js @@ -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} addRedirect * @prop {() => Promise} getSlug * @prop {({filter: string}) => Promise} getAll + * @prop {({filter: string}) => Promise} getFilteredIds */ /** * @typedef {object} IPostLinkRepository * @prop {(postLink: PostLink) => Promise} save * @prop {({filter: string}) => Promise} getAll + * @prop {(linkIds: array, data, options) => Promise} 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 */ diff --git a/ghost/link-tracking/test/LinkClickTrackingService.test.js b/ghost/link-tracking/test/LinkClickTrackingService.test.js index 7b0c36b0ac..32e1ab5940 100644 --- a/ghost/link-tracking/test/LinkClickTrackingService.test.js +++ b/ghost/link-tracking/test/LinkClickTrackingService.test.js @@ -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: [] + }); + }); + }); });