Added click tracking endpoints for recommendations

fixes https://github.com/TryGhost/Product/issues/3853
This commit is contained in:
Simon Backx 2023-09-14 14:29:38 +02:00 committed by Simon Backx
parent 383069a1e5
commit 82079c1dc5
14 changed files with 543 additions and 9 deletions

View File

@ -30,8 +30,14 @@ class SimpleBookshelfRepository extends BookshelfRepository<string, SimpleEntity
};
}
protected entityFieldToColumn(field: keyof SimpleEntity): string {
return field as string;
protected getFieldToColumnMap(): Record<keyof SimpleEntity, string> {
return {
id: 'id',
deleted: 'deleted',
name: 'name',
age: 'age',
birthday: 'birthday'
};
}
}

View File

@ -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);
}
}
};

View File

@ -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

View File

@ -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

View File

@ -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",
}
`;

View File

@ -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();
});
});
});
});
});

View File

@ -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<string, ClickEvent> {
sentry?: Sentry;
constructor(Model: ModelClass<string>, 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<string>): 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<keyof ClickEvent, string>;
}
}

View File

@ -51,8 +51,8 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
}
}
entityFieldToColumn(field: keyof Recommendation): string {
const mapping = {
getFieldToColumnMap() {
return {
id: 'id',
title: 'title',
reason: 'reason',
@ -64,6 +64,5 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
createdAt: 'created_at',
updatedAt: 'updated_at'
} as Record<keyof Recommendation, string>;
return mapping[field];
}
}

View File

@ -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<string, SubscribeEvent> {
sentry?: Sentry;
constructor(Model: ModelClass<string>, 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<string>): 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<keyof SubscribeEvent, string>;
}
}

View File

@ -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);
}
}

View File

@ -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
});
}
}

View File

@ -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<void>
@ -20,12 +22,17 @@ const messages = {
export class RecommendationService {
repository: RecommendationRepository;
clickEventRepository: BookshelfRepository<string, ClickEvent>;
subscribeEventRepository: BookshelfRepository<string, SubscribeEvent>;
wellknownService: WellknownService;
mentionSendingService: MentionSendingService;
recommendationEnablerService: RecommendationEnablerService;
constructor(deps: {
repository: RecommendationRepository,
clickEventRepository: BookshelfRepository<string, ClickEvent>,
subscribeEventRepository: BookshelfRepository<string, SubscribeEvent>,
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);
}
}

View File

@ -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);
}
}

View File

@ -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';