Added click tracking endpoints for recommendations
fixes https://github.com/TryGhost/Product/issues/3853
This commit is contained in:
parent
383069a1e5
commit
82079c1dc5
@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
}
|
||||
`;
|
162
ghost/core/test/e2e-api/members/recommendations.test.js
Normal file
162
ghost/core/test/e2e-api/members/recommendations.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
49
ghost/recommendations/src/BookshelfClickEventRepository.ts
Normal file
49
ghost/recommendations/src/BookshelfClickEventRepository.ts
Normal 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>;
|
||||
}
|
||||
}
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
||||
}
|
32
ghost/recommendations/src/ClickEvent.ts
Normal file
32
ghost/recommendations/src/ClickEvent.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
32
ghost/recommendations/src/SubscribeEvent.ts
Normal file
32
ghost/recommendations/src/SubscribeEvent.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user