diff --git a/core/frontend/web/site.js b/core/frontend/web/site.js
index abb3a5615a..05f8e418b8 100644
--- a/core/frontend/web/site.js
+++ b/core/frontend/web/site.js
@@ -4,6 +4,8 @@ const express = require('../../shared/express');
const cors = require('cors');
const {URL} = require('url');
const errors = require('@tryghost/errors');
+const DomainEvents = require('@tryghost/domain-events');
+const {MemberPageViewEvent} = require('@tryghost/member-events');
// App requires
const config = require('../../shared/config');
@@ -171,6 +173,14 @@ module.exports = function setupSiteApp(options = {}) {
}
});
+ siteApp.use(function (req, res, next) {
+ if (req.member) {
+ // This event needs memberLastSeenAt to avoid doing un-necessary database queries when updating `last_seen_at`
+ DomainEvents.dispatch(MemberPageViewEvent.create({url: req.url, memberId: req.member.id, memberLastSeenAt: req.member.last_seen_at}, new Date()));
+ }
+ next();
+ });
+
debug('General middleware done');
router = siteRoutes(options);
diff --git a/core/server/services/email-analytics/lib/event-processor.js b/core/server/services/email-analytics/lib/event-processor.js
index cafea4eb84..d13d087db8 100644
--- a/core/server/services/email-analytics/lib/event-processor.js
+++ b/core/server/services/email-analytics/lib/event-processor.js
@@ -92,7 +92,7 @@ class GhostEventProcessor extends EventProcessor {
await this.db.knex('members')
.where('email', '=', event.recipientEmail)
.andWhere(builder => builder
- .where('last_seen_at', '<', moment.utc(event.timestamp).tz(timezone).startOf('day').format('YYYY-MM-DD HH:mm:ss'))
+ .where('last_seen_at', '<', moment.utc(event.timestamp).tz(timezone).startOf('day').utc().format('YYYY-MM-DD HH:mm:ss'))
.orWhereNull('last_seen_at')
)
.update({
diff --git a/core/server/services/members/service.js b/core/server/services/members/service.js
index f1c757ad7e..a4ec8e6310 100644
--- a/core/server/services/members/service.js
+++ b/core/server/services/members/service.js
@@ -16,6 +16,8 @@ const models = require('../../models');
const {GhostMailer} = require('../mail');
const jobsService = require('../jobs');
const VerificationTrigger = require('@tryghost/verification-trigger');
+const DomainEvents = require('@tryghost/domain-events');
+const {LastSeenAtUpdater} = require('@tryghost/members-events-service');
const events = require('../../lib/common/events');
const messages = {
@@ -139,7 +141,7 @@ module.exports = {
sendVerificationEmail: ({subject, message, amountImported}) => {
const escalationAddress = config.get('hostSettings:emailVerification:escalationAddress');
const fromAddress = config.get('user_email');
-
+
if (escalationAddress) {
ghostMailer.send({
subject,
@@ -158,6 +160,16 @@ module.exports = {
eventRepository: membersApi.events
});
+ new LastSeenAtUpdater({
+ models: {
+ Member: models.Member
+ },
+ services: {
+ domainEvents: DomainEvents,
+ settingsCache
+ }
+ });
+
(async () => {
try {
const collection = await models.SingleUseToken.fetchAll();
diff --git a/package.json b/package.json
index 04dfc4f66e..34fd975276 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
"@tryghost/custom-theme-settings-service": "0.3.1",
"@tryghost/database-info": "0.1.0",
"@tryghost/debug": "0.1.13",
+ "@tryghost/domain-events": "0.1.8",
"@tryghost/email-analytics-provider-mailgun": "1.0.7",
"@tryghost/email-analytics-service": "1.0.5",
"@tryghost/errors": "1.2.3",
@@ -81,8 +82,9 @@
"@tryghost/limit-service": "1.0.9",
"@tryghost/logging": "2.0.4",
"@tryghost/magic-link": "1.0.19",
+ "@tryghost/member-events": "0.4.0",
"@tryghost/members-api": "5.0.4",
- "@tryghost/members-events-service": "0.1.2",
+ "@tryghost/members-events-service": "0.3.1",
"@tryghost/members-importer": "0.5.2",
"@tryghost/members-offers": "0.10.7",
"@tryghost/members-ssr": "1.0.22",
diff --git a/test/e2e-frontend/members.test.js b/test/e2e-frontend/members.test.js
index 52695cee55..9da504777c 100644
--- a/test/e2e-frontend/members.test.js
+++ b/test/e2e-frontend/members.test.js
@@ -1,3 +1,4 @@
+const assert = require('assert');
const should = require('should');
const sinon = require('sinon');
const supertest = require('supertest');
@@ -5,6 +6,9 @@ const moment = require('moment');
const testUtils = require('../utils');
const configUtils = require('../utils/configUtils');
const settingsCache = require('../../core/shared/settings-cache');
+const DomainEvents = require('@tryghost/domain-events');
+const {MemberPageViewEvent} = require('@tryghost/member-events');
+const models = require('../../core/server/models');
function assertContentIsPresent(res) {
res.text.should.containEql('
markdown
');
@@ -233,6 +237,18 @@ describe('Front-end members behaviour', function () {
.expect(200)
.expect(assertContentIsAbsent);
});
+
+ it('doesn\'t generate a MemberPageView event', async function () {
+ const spy = sinon.spy();
+ DomainEvents.subscribe(MemberPageViewEvent, spy);
+
+ await request
+ .get('/free-to-see/')
+ .expect(200)
+ .expect(assertContentIsPresent);
+
+ assert(spy.notCalled, 'A page view from a non-member shouldn\'t generate a MemberPageViewEvent event');
+ });
});
describe('as free member', function () {
@@ -277,8 +293,9 @@ describe('Front-end members behaviour', function () {
});
describe('as free member with vip label', function () {
+ const email = 'vip@test.com';
before(async function () {
- await loginAsMember('vip@test.com');
+ await loginAsMember(email);
});
it('can read label-only post content', async function () {
@@ -287,15 +304,37 @@ describe('Front-end members behaviour', function () {
.expect(200)
.expect(assertContentIsPresent);
});
+
+ it('generates a MemberPageView event', async function () {
+ const spy = sinon.spy();
+ DomainEvents.subscribe(MemberPageViewEvent, spy);
+
+ // Reset last_seen_at property
+ let member = await models.Member.findOne({email});
+ await models.Member.edit({last_seen_at: null}, {id: member.get('id')});
+
+ member = await models.Member.findOne({email});
+ assert.equal(member.get('last_seen_at'), null, 'The member shouldn\'t have a `last_seen_at` property set before this test.');
+
+ await request
+ .get('/free-to-see/')
+ .expect(200)
+ .expect(assertContentIsPresent);
+
+ assert(spy.calledOnce, 'A page view from a member should generate a MemberPageViewEvent event');
+ member = await models.Member.findOne({email});
+ assert.notEqual(member.get('last_seen_at'), null, 'The member should have a `last_seen_at` property after having visited a page while logged-in.');
+ });
});
describe('as paid member', function () {
+ const email = 'paid@test.com';
before(async function () {
// membersService needs to be required after Ghost start so that settings
// are pre-populated with defaults
const membersService = require('../../core/server/services/members');
- const signinLink = await membersService.api.getMagicLink('paid@test.com');
+ const signinLink = await membersService.api.getMagicLink(email);
const signinURL = new URL(signinLink);
// request needs a relative path rather than full url with host
const signinPath = `${signinURL.pathname}${signinURL.search}`;
@@ -344,6 +383,27 @@ describe('Front-end members behaviour', function () {
.expect(200)
.expect(assertContentIsPresent);
});
+
+ it('generates a MemberPageView event', async function () {
+ const spy = sinon.spy();
+ DomainEvents.subscribe(MemberPageViewEvent, spy);
+
+ // Reset last_seen_at property
+ let member = await models.Member.findOne({email});
+ await models.Member.edit({last_seen_at: null}, {id: member.get('id')});
+
+ member = await models.Member.findOne({email});
+ assert.equal(member.get('last_seen_at'), null, 'The member shouldn\'t have a `last_seen_at` property set before this test.');
+
+ await request
+ .get('/free-to-see/')
+ .expect(200)
+ .expect(assertContentIsPresent);
+
+ assert(spy.calledOnce, 'A page view from a member should generate a MemberPageViewEvent event');
+ member = await models.Member.findOne({email});
+ assert.notEqual(member.get('last_seen_at'), null, 'The member should have a `last_seen_at` property after having visited a page while logged-in.');
+ });
});
describe('as paid member with vip label', function () {
diff --git a/yarn.lock b/yarn.lock
index f718261a98..73c43d4f59 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1920,6 +1920,11 @@
"@tryghost/root-utils" "^0.3.10"
debug "^4.3.1"
+"@tryghost/domain-events@0.1.8":
+ version "0.1.8"
+ resolved "https://registry.yarnpkg.com/@tryghost/domain-events/-/domain-events-0.1.8.tgz#754af77b05336a689135971811ee438edda04876"
+ integrity sha512-PalAdGOADidoxXg54F/QJEc1C9PwlQvWnIF1MksWY2Pp8XiH5mzeHisNnqXfXO3dEEopPPreIj7Lx/y6U9NAyQ==
+
"@tryghost/domain-events@^0.1.7":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@tryghost/domain-events/-/domain-events-0.1.7.tgz#dd6fa48886961c3e27889672a234bb516770d491"
@@ -2252,10 +2257,10 @@
papaparse "5.3.1"
pump "^3.0.0"
-"@tryghost/members-events-service@0.1.2":
- version "0.1.2"
- resolved "https://registry.yarnpkg.com/@tryghost/members-events-service/-/members-events-service-0.1.2.tgz#e8bc9dac1eca98f0cbe0372bc2460573b24f21bf"
- integrity sha512-VbMAejI6daUiTyQCsA3FOhno7bPQNMWugbihAObwfyQayjoas8hYfrjk9/z9QMYh4N0A5JQugvXkL1gDQpbeLA==
+"@tryghost/members-events-service@0.3.1":
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/@tryghost/members-events-service/-/members-events-service-0.3.1.tgz#209758435bd6aebe46426a440e68d76541ce095e"
+ integrity sha512-bpjKYSzp1UjR6IPzzVLjoeSCbKT0rC7dr1SCwziavTD/2IuUA+ZKqxpPBeiGiQW+YFEvY6/NKXME+dr68fO6fw==
dependencies:
"@tryghost/domain-events" "0.1.8"
"@tryghost/member-events" "0.4.0"