diff --git a/ghost/bookshelf-repository/test/BookshelfRepository.test.ts b/ghost/bookshelf-repository/test/BookshelfRepository.test.ts index 231e0b6980..4f102dfe73 100644 --- a/ghost/bookshelf-repository/test/BookshelfRepository.test.ts +++ b/ghost/bookshelf-repository/test/BookshelfRepository.test.ts @@ -30,8 +30,14 @@ class SimpleBookshelfRepository extends BookshelfRepository { + return { + id: 'id', + deleted: 'deleted', + name: 'name', + age: 'age', + birthday: 'birthday' + }; } } diff --git a/ghost/core/core/server/api/endpoints/recommendations-public.js b/ghost/core/core/server/api/endpoints/recommendations-public.js index d5c5e42e0e..c88e2dded3 100644 --- a/ghost/core/core/server/api/endpoints/recommendations-public.js +++ b/ghost/core/core/server/api/endpoints/recommendations-public.js @@ -17,5 +17,47 @@ module.exports = { async query(frame) { return await recommendations.controller.listRecommendations(frame); } + }, + + trackClicked: { + headers: { + cacheInvalidate: false + }, + options: [ + 'id' + ], + validation: { + options: { + id: { + required: true + } + } + }, + permissions: true, + statusCode: 204, + async query(frame) { + await recommendations.controller.trackClicked(frame); + } + }, + + trackSubscribed: { + headers: { + cacheInvalidate: false + }, + options: [ + 'id' + ], + validation: { + options: { + id: { + required: true + } + } + }, + permissions: true, + statusCode: 204, + async query(frame) { + await recommendations.controller.trackSubscribed(frame); + } } }; diff --git a/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js index aacecd9215..dc5bab43df 100644 --- a/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js +++ b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js @@ -4,6 +4,16 @@ class RecommendationServiceWrapper { */ repository; + /** + * @type {import('@tryghost/recommendations').BookshelfClickEventRepository} + */ + clickEventRepository; + + /** + * @type {import('@tryghost/recommendations').BookshelfSubscribeEventRepository} + */ + subscribeEventRepository; + /** * @type {import('@tryghost/recommendations').RecommendationController} */ @@ -29,7 +39,8 @@ class RecommendationServiceWrapper { BookshelfRecommendationRepository, RecommendationService, RecommendationController, - WellknownService + WellknownService, + BookshelfClickEventRepository } = require('@tryghost/recommendations'); const mentions = require('../mentions'); @@ -50,11 +61,21 @@ class RecommendationServiceWrapper { this.repository = new BookshelfRecommendationRepository(models.Recommendation, { sentry }); + + this.clickEventRepository = new BookshelfClickEventRepository(models.RecommendationClickEvent, { + sentry + }); + this.subscribeEventRepository = new BookshelfClickEventRepository(models.RecommendationSubscribeEvent, { + sentry + }); + this.service = new RecommendationService({ repository: this.repository, recommendationEnablerService, wellknownService, - mentionSendingService: mentions.sendingService + mentionSendingService: mentions.sendingService, + clickEventRepository: this.clickEventRepository, + subscribeEventRepository: this.subscribeEventRepository }); this.controller = new RecommendationController({ service: this.service diff --git a/ghost/core/core/server/web/members/app.js b/ghost/core/core/server/web/members/app.js index 456e62b104..fdf0378b11 100644 --- a/ghost/core/core/server/web/members/app.js +++ b/ghost/core/core/server/web/members/app.js @@ -88,6 +88,20 @@ module.exports = function setupMembersApp() { announcementRouter() ); + // Recommendations + membersApp.post( + '/api/recommendations/:id/clicked', + middleware.loadMemberSession, + http(api.recommendationsPublic.trackClicked) + ); + + // Recommendations + membersApp.post( + '/api/recommendations/:id/subscribed', + middleware.loadMemberSession, + http(api.recommendationsPublic.trackSubscribed) + ); + // Allow external systems to read public settings via the members api // Without CORS issues and without a required integration token // 1. Detect if a site is Running Ghost diff --git a/ghost/core/test/e2e-api/members/__snapshots__/recommendations.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/recommendations.test.js.snap new file mode 100644 index 0000000000..7d3a65562d --- /dev/null +++ b/ghost/core/test/e2e-api/members/__snapshots__/recommendations.test.js.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Recommendation Event Tracking Authenticated Can track clicks 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "x-powered-by": "Express", +} +`; + +exports[`Recommendation Event Tracking Authenticated Can track subscribe clicks 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "x-powered-by": "Express", +} +`; + +exports[`Recommendation Event Tracking Authenticated Cannot track invalid types 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "226", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendation Event Tracking Unauthenticated Can not track subscribe clicks 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "206", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendation Event Tracking Unauthenticated Can track clicks 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "x-powered-by": "Express", +} +`; + +exports[`Recommendation Event Tracking Unauthenticated Cannot track invalid types 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "226", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/members/recommendations.test.js b/ghost/core/test/e2e-api/members/recommendations.test.js new file mode 100644 index 0000000000..14c5563db3 --- /dev/null +++ b/ghost/core/test/e2e-api/members/recommendations.test.js @@ -0,0 +1,162 @@ +const assert = require('assert/strict'); +const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = require('../../utils/e2e-framework'); +const {anyEtag} = matchers; +const recommendationsService = require('../../../core/server/services/recommendations'); +const {Recommendation} = require('@tryghost/recommendations'); + +async function testClicked({recommendationId, memberId}, test) { + const before = await recommendationsService.clickEventRepository.getAll({ + filter: 'recommendationId:' + recommendationId + }); + + await test(); + + const after = await recommendationsService.clickEventRepository.getAll({ + filter: 'recommendationId:' + recommendationId + }); + + assert.equal(after.length, before.length + 1); + + // Check member is set + const added = after.find(event => !before.find(e => e.id === event.id)); + assert.equal(added.memberId, memberId); +} + +async function testNotClicked(test) { + const before = await recommendationsService.clickEventRepository.getCount(); + await test(); + const after = await recommendationsService.clickEventRepository.getCount(); + + assert.equal(after, before); +} + +async function testNotSubscribed(test) { + const before = await recommendationsService.subscribeEventRepository.getCount(); + await test(); + const after = await recommendationsService.subscribeEventRepository.getCount(); + + assert.equal(after, before); +} + +async function testSubscribed({recommendationId, memberId}, test) { + const before = await recommendationsService.subscribeEventRepository.getAll({ + filter: 'recommendationId:' + recommendationId + }); + await test(); + const after = await recommendationsService.subscribeEventRepository.getAll({ + filter: 'recommendationId:' + recommendationId + }); + + assert.equal(after.length, before.length + 1); + + // Check member is set + const added = after.find(event => !before.find(e => e.id === event.id)); + assert.equal(added.memberId, memberId); +} + +describe('Recommendation Event Tracking', function () { + let membersAgent, membersAgent2, memberId; + let recommendationId; + let clock; + + before(async function () { + membersAgent = await agentProvider.getMembersAPIAgent(); + membersAgent2 = membersAgent.duplicate(); + await membersAgent2.loginAs('authenticationtest@email.com'); + await fixtureManager.init('posts', 'members'); + + const membersService = require('../../../core/server/services/members'); + const memberRepository = membersService.api.members; + + const member = await memberRepository.get({email: 'authenticationtest@email.com'}); + memberId = member.id; + + // Add recommendation + const recommendation = Recommendation.create({ + title: `Recommendation`, + reason: `Reason`, + url: new URL(`https://recommendation.com`), + favicon: null, + featuredImage: null, + excerpt: null, + oneClickSubscribe: false + }); + + await recommendationsService.repository.save(recommendation); + recommendationId = recommendation.id; + }); + + beforeEach(function () { + mockManager.mockMail(); + }); + + afterEach(async function () { + clock?.restore(); + clock = undefined; + await configUtils.restore(); + mockManager.restore(); + }); + + describe('Authenticated', function () { + it('Can track subscribe clicks', async function () { + await testNotClicked(async () => { + await testSubscribed({recommendationId, memberId}, async () => { + await membersAgent2 + .post('/api/recommendations/' + recommendationId + '/subscribed/') + .body({}) + .expectStatus(204) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expectEmptyBody(); + }); + }); + }); + + it('Can track clicks', async function () { + await testNotSubscribed(async () => { + await testClicked({recommendationId, memberId}, async () => { + await membersAgent2 + .post('/api/recommendations/' + recommendationId + '/clicked/') + .body({}) + .expectStatus(204) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expectEmptyBody(); + }); + }); + }); + }); + + describe('Unauthenticated', function () { + it('Can not track subscribe clicks', async function () { + await testNotClicked(async () => { + await testNotSubscribed(async () => { + await membersAgent + .post('/api/recommendations/' + recommendationId + '/subscribed/') + .body({}) + .expectStatus(401) + .matchHeaderSnapshot({ + etag: anyEtag + }); + }); + }); + }); + + it('Can track clicks', async function () { + await testNotSubscribed(async () => { + await testClicked({recommendationId, memberId: null}, async () => { + await membersAgent + .post('/api/recommendations/' + recommendationId + '/clicked/') + .body({}) + .expectStatus(204) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expectEmptyBody(); + }); + }); + }); + }); +}); diff --git a/ghost/recommendations/src/BookshelfClickEventRepository.ts b/ghost/recommendations/src/BookshelfClickEventRepository.ts new file mode 100644 index 0000000000..6a2ed98d9f --- /dev/null +++ b/ghost/recommendations/src/BookshelfClickEventRepository.ts @@ -0,0 +1,49 @@ +import {BookshelfRepository, ModelClass, ModelInstance} from '@tryghost/bookshelf-repository'; +import logger from '@tryghost/logging'; +import {ClickEvent} from './ClickEvent'; + +type Sentry = { + captureException(err: unknown): void; +} + +export class BookshelfClickEventRepository extends BookshelfRepository { + sentry?: Sentry; + + constructor(Model: ModelClass, deps: {sentry?: Sentry} = {}) { + super(Model); + this.sentry = deps.sentry; + } + + toPrimitive(entity: ClickEvent): object { + return { + id: entity.id, + recommendation_id: entity.recommendationId, + member_id: entity.memberId, + created_at: entity.createdAt + }; + } + + modelToEntity(model: ModelInstance): ClickEvent | null { + try { + return ClickEvent.create({ + id: model.id, + recommendationId: model.get('recommendation_id') as string, + memberId: model.get('member_id') as string | null, + createdAt: model.get('created_at') as Date + }); + } catch (err) { + logger.error(err); + this.sentry?.captureException(err); + return null; + } + } + + getFieldToColumnMap() { + return { + id: 'id', + recommendationId: 'recommendation_id', + memberId: 'member_id', + createdAt: 'created_at' + } as Record; + } +} diff --git a/ghost/recommendations/src/BookshelfRecommendationRepository.ts b/ghost/recommendations/src/BookshelfRecommendationRepository.ts index 99c62c0a4a..5f2b76dbd5 100644 --- a/ghost/recommendations/src/BookshelfRecommendationRepository.ts +++ b/ghost/recommendations/src/BookshelfRecommendationRepository.ts @@ -51,8 +51,8 @@ export class BookshelfRecommendationRepository extends BookshelfRepository; - return mapping[field]; } } diff --git a/ghost/recommendations/src/BookshelfSubscribeEventRepository.ts b/ghost/recommendations/src/BookshelfSubscribeEventRepository.ts new file mode 100644 index 0000000000..17eceafe27 --- /dev/null +++ b/ghost/recommendations/src/BookshelfSubscribeEventRepository.ts @@ -0,0 +1,49 @@ +import {BookshelfRepository, ModelClass, ModelInstance} from '@tryghost/bookshelf-repository'; +import logger from '@tryghost/logging'; +import {SubscribeEvent} from './SubscribeEvent'; + +type Sentry = { + captureException(err: unknown): void; +} + +export class BookshelfSubscribeEventRepository extends BookshelfRepository { + sentry?: Sentry; + + constructor(Model: ModelClass, deps: {sentry?: Sentry} = {}) { + super(Model); + this.sentry = deps.sentry; + } + + toPrimitive(entity: SubscribeEvent): object { + return { + id: entity.id, + recommendation_id: entity.recommendationId, + member_id: entity.memberId, + created_at: entity.createdAt + }; + } + + modelToEntity(model: ModelInstance): SubscribeEvent | null { + try { + return SubscribeEvent.create({ + id: model.id, + recommendationId: model.get('recommendation_id') as string, + memberId: model.get('member_id') as string, + createdAt: model.get('created_at') as Date + }); + } catch (err) { + logger.error(err); + this.sentry?.captureException(err); + return null; + } + } + + getFieldToColumnMap() { + return { + id: 'id', + recommendationId: 'recommendation_id', + memberId: 'member_id', + createdAt: 'created_at' + } as Record; + } +} diff --git a/ghost/recommendations/src/ClickEvent.ts b/ghost/recommendations/src/ClickEvent.ts new file mode 100644 index 0000000000..b5a85f5ede --- /dev/null +++ b/ghost/recommendations/src/ClickEvent.ts @@ -0,0 +1,32 @@ +import ObjectId from 'bson-objectid'; + +export class ClickEvent { + id: string; + recommendationId: string; + memberId: string|null; + createdAt: Date; + + get deleted() { + return false; + } + + private constructor(data: {id: string, recommendationId: string, memberId: string|null, createdAt: Date}) { + this.id = data.id; + this.recommendationId = data.recommendationId; + this.memberId = data.memberId; + this.createdAt = data.createdAt; + } + + static create(data: {id?: string, recommendationId: string, memberId?: string|null, createdAt?: Date}) { + const id = data.id ?? ObjectId().toString(); + + const d = { + id, + recommendationId: data.recommendationId, + memberId: data.memberId ?? null, + createdAt: data.createdAt ?? new Date() + }; + + return new ClickEvent(d); + } +} diff --git a/ghost/recommendations/src/RecommendationController.ts b/ghost/recommendations/src/RecommendationController.ts index 9dd78d8f6e..a292597615 100644 --- a/ghost/recommendations/src/RecommendationController.ts +++ b/ghost/recommendations/src/RecommendationController.ts @@ -6,7 +6,8 @@ import errors from '@tryghost/errors'; type Frame = { data: any, options: any, - user: any + user: any, + member: any, }; function validateString(object: any, key: string, {required = true, nullable = false} = {}): string|undefined|null { @@ -121,6 +122,16 @@ export class RecommendationController { return limit; } + #getFrameMemberId(frame: Frame): string { + if (!frame.options?.context?.member?.id) { + // This is an internal server error because authentication should happen outside this service. + throw new errors.UnauthorizedError({ + message: 'Member not found' + }); + } + return frame.options.context.member.id; + } + #getFrameRecommendation(frame: Frame): AddRecommendation { if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) { throw new errors.BadRequestError(); @@ -238,4 +249,34 @@ export class RecommendationController { } ); } + + async trackClicked(frame: Frame) { + // First get the ID of the recommendation that was clicked + const id = this.#getFrameId(frame); + // Check type of event + let memberId: string | undefined; + try { + memberId = this.#getFrameMemberId(frame); + } catch (e) { + if (e instanceof errors.UnauthorizedError) { + // This is fine, this is not required + } else { + throw e; + } + } + + await this.service.trackClicked({ + id, + memberId + }); + } + async trackSubscribed(frame: Frame) { + // First get the ID of the recommendation that was clicked + const id = this.#getFrameId(frame); + const memberId = this.#getFrameMemberId(frame); + await this.service.trackSubscribed({ + id, + memberId + }); + } } diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts index d003806fa2..41ae3f4664 100644 --- a/ghost/recommendations/src/RecommendationService.ts +++ b/ghost/recommendations/src/RecommendationService.ts @@ -1,9 +1,11 @@ -import {OrderOption} from '@tryghost/bookshelf-repository'; +import {BookshelfRepository, OrderOption} from '@tryghost/bookshelf-repository'; import {AddRecommendation, Recommendation} from './Recommendation'; import {RecommendationRepository} from './RecommendationRepository'; import {WellknownService} from './WellknownService'; import errors from '@tryghost/errors'; import tpl from '@tryghost/tpl'; +import {ClickEvent} from './ClickEvent'; +import {SubscribeEvent} from './SubscribeEvent'; type MentionSendingService = { sendAll(options: {url: URL, links: URL[]}): Promise @@ -20,12 +22,17 @@ const messages = { export class RecommendationService { repository: RecommendationRepository; + clickEventRepository: BookshelfRepository; + subscribeEventRepository: BookshelfRepository; + wellknownService: WellknownService; mentionSendingService: MentionSendingService; recommendationEnablerService: RecommendationEnablerService; constructor(deps: { repository: RecommendationRepository, + clickEventRepository: BookshelfRepository, + subscribeEventRepository: BookshelfRepository, wellknownService: WellknownService, mentionSendingService: MentionSendingService, recommendationEnablerService: RecommendationEnablerService, @@ -34,6 +41,8 @@ export class RecommendationService { this.wellknownService = deps.wellknownService; this.mentionSendingService = deps.mentionSendingService; this.recommendationEnablerService = deps.recommendationEnablerService; + this.clickEventRepository = deps.clickEventRepository; + this.subscribeEventRepository = deps.subscribeEventRepository; } async init() { @@ -126,4 +135,14 @@ export class RecommendationService { async countRecommendations({filter}: { filter?: string }) { return await this.repository.getCount({filter}); } + + async trackClicked({id, memberId}: { id: string, memberId?: string }) { + const clickEvent = ClickEvent.create({recommendationId: id, memberId}); + await this.clickEventRepository.save(clickEvent); + } + + async trackSubscribed({id, memberId}: { id: string, memberId: string }) { + const subscribeEvent = SubscribeEvent.create({recommendationId: id, memberId}); + await this.subscribeEventRepository.save(subscribeEvent); + } } diff --git a/ghost/recommendations/src/SubscribeEvent.ts b/ghost/recommendations/src/SubscribeEvent.ts new file mode 100644 index 0000000000..30638ed9af --- /dev/null +++ b/ghost/recommendations/src/SubscribeEvent.ts @@ -0,0 +1,32 @@ +import ObjectId from 'bson-objectid'; + +export class SubscribeEvent { + id: string; + recommendationId: string; + memberId: string|null; + createdAt: Date; + + get deleted() { + return false; + } + + private constructor(data: {id: string, recommendationId: string, memberId: string|null, createdAt: Date}) { + this.id = data.id; + this.recommendationId = data.recommendationId; + this.memberId = data.memberId; + this.createdAt = data.createdAt; + } + + static create(data: {id?: string, recommendationId: string, memberId: string, createdAt?: Date}) { + const id = data.id ?? ObjectId().toString(); + + const d = { + id, + recommendationId: data.recommendationId, + memberId: data.memberId, + createdAt: data.createdAt ?? new Date() + }; + + return new SubscribeEvent(d); + } +} diff --git a/ghost/recommendations/src/index.ts b/ghost/recommendations/src/index.ts index db9ac91301..82cba44a2b 100644 --- a/ghost/recommendations/src/index.ts +++ b/ghost/recommendations/src/index.ts @@ -5,3 +5,7 @@ export * from './InMemoryRecommendationRepository'; export * from './Recommendation'; export * from './WellknownService'; export * from './BookshelfRecommendationRepository'; +export * from './ClickEvent'; +export * from './BookshelfClickEventRepository'; +export * from './SubscribeEvent'; +export * from './BookshelfSubscribeEventRepository';