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"