diff --git a/ghost/members-events-service/.eslintrc.js b/ghost/members-events-service/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/members-events-service/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/members-events-service/LICENSE b/ghost/members-events-service/LICENSE new file mode 100644 index 0000000000..19bcb01bef --- /dev/null +++ b/ghost/members-events-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2022 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/members-events-service/README.md b/ghost/members-events-service/README.md new file mode 100644 index 0000000000..4860b6497a --- /dev/null +++ b/ghost/members-events-service/README.md @@ -0,0 +1,44 @@ +# Members events service + + +Contains member services that listen on specific events to perform actions: + +- Last-seen-at updater: Updates `members.last_seen_at` on a page/post view event + +## Install + +`npm install @tryghost/members-events-service --save` + +or + +`yarn add @tryghost/members-events-service` + + +## Usage + + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/ghost/members-events-service/index.js b/ghost/members-events-service/index.js new file mode 100644 index 0000000000..bb0a047c4f --- /dev/null +++ b/ghost/members-events-service/index.js @@ -0,0 +1 @@ +module.exports = require('./lib'); diff --git a/ghost/members-events-service/lib/index.js b/ghost/members-events-service/lib/index.js new file mode 100644 index 0000000000..e6896e3c97 --- /dev/null +++ b/ghost/members-events-service/lib/index.js @@ -0,0 +1,3 @@ +module.exports = { + LastSeenAtUpdater: require('./last-seen-at-updater') +}; diff --git a/ghost/members-events-service/lib/last-seen-at-updater.js b/ghost/members-events-service/lib/last-seen-at-updater.js new file mode 100644 index 0000000000..0ccecf843c --- /dev/null +++ b/ghost/members-events-service/lib/last-seen-at-updater.js @@ -0,0 +1,38 @@ +const DomainEvents = require('@tryghost/domain-events'); +const {MemberPageViewEvent} = require('@tryghost/member-events'); +const moment = require('moment'); + +/** + * Listen for `MemberViewEvent` to update the `member.last_seen_at` timestamp + */ +class LastSeenAtUpdater { + /** + * Initializes the event subscriber + * @param {Object} deps dependencies + * @param {any} deps.memberModel The member model + */ + constructor({memberModel}) { + this._memberModel = memberModel; + DomainEvents.subscribe(MemberPageViewEvent, async (event) => { + await this.updateLastSeenAt(event.data.memberId, event.data.memberLastSeenAt, event.timestamp); + }); + } + + /** + * Updates the member.last_seen_at field if it was updated more than 24 hours ago + * @param {string} memberId The id of the member to be udpated + * @param {string} memberLastSeenAt The previous last_seen_at property value for the current member + * @param {Date} timestamp The event timestamp + */ + async updateLastSeenAt(memberId, memberLastSeenAt, timestamp) { + if (memberLastSeenAt === null || moment(timestamp).diff(memberLastSeenAt, 'hours') > 24) { + await this._memberModel.update({ + last_seen_at: moment(timestamp).utc().format('YYYY-MM-DD HH:mm:ss') + }, { + id: memberId + }); + } + } +} + +module.exports = LastSeenAtUpdater; diff --git a/ghost/members-events-service/package.json b/ghost/members-events-service/package.json new file mode 100644 index 0000000000..9ebf99b939 --- /dev/null +++ b/ghost/members-events-service/package.json @@ -0,0 +1,32 @@ +{ + "name": "@tryghost/members-events-service", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Members/tree/main/packages/members-events-service", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing c8 --all --check-coverage mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "c8": "7.11.0", + "mocha": "9.2.1", + "should": "13.2.3", + "sinon": "13.0.1" + }, + "dependencies": { + "@tryghost/domain-events": "^0.1.7", + "@tryghost/member-events": "^0.3.5", + "moment": "^2.29.1" + } +} diff --git a/ghost/members-events-service/test/.eslintrc.js b/ghost/members-events-service/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/members-events-service/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/members-events-service/test/last-seen-at-updater.test.js b/ghost/members-events-service/test/last-seen-at-updater.test.js new file mode 100644 index 0000000000..e05ca80e3d --- /dev/null +++ b/ghost/members-events-service/test/last-seen-at-updater.test.js @@ -0,0 +1,52 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const assert = require('assert'); +const sinon = require('sinon'); +const LastSeenAtUpdater = require('../lib/last-seen-at-updater'); +const DomainEvents = require('@tryghost/domain-events'); +const {MemberPageViewEvent, MemberSubscribeEvent} = require('@tryghost/member-events'); + +describe('LastSeenAtUpdater', function () { + it('Fires on MemberPageViewEvent events', async function () { + const now = new Date(); + const previousLastSeen = new Date(now.getTime() - 48 * 3600 * 1000).toISOString(); // 48 hours + const spy = sinon.spy(); + new LastSeenAtUpdater({ + memberModel: { + update: spy + } + }); + DomainEvents.dispatch(MemberPageViewEvent.create({memberId: '1', memberLastSeenAt: previousLastSeen, url: '/'}, now)); + assert(spy.calledOnceWithExactly({ + last_seen_at: now.toISOString().replace('T', ' ').replace(/\..+/, '') + }, { + id: '1' + }), 'The LastSeenAtUpdater should attempt a member update with the current date.'); + }); + + it('Doesn\'t update when last_seen_at is too recent', async function () { + const now = new Date(); + const previousLastSeen = new Date(now.getTime() - 1 * 3600 * 1000).toISOString(); // 1 hour + const spy = sinon.spy(); + new LastSeenAtUpdater({ + memberModel: { + update: spy + } + }); + DomainEvents.dispatch(MemberPageViewEvent.create({memberId: '1', memberLastSeenAt: previousLastSeen, url: '/'}, now)); + assert(spy.notCalled, 'The LastSeenAtUpdater should\'t update a member when the previous last_seen_at is close to the event timestamp.'); + }); + + it('Doesn\'t fire on other events', async function () { + const spy = sinon.spy(); + new LastSeenAtUpdater({ + memberModel: { + update: spy + } + }); + DomainEvents.dispatch(MemberSubscribeEvent.create({memberId: '1', source: 'api'}, new Date())); + assert(spy.notCalled, 'The LastSeenAtUpdater should never fire on MemberPageViewEvent events.'); + }); +}); diff --git a/ghost/members-events-service/test/utils/assertions.js b/ghost/members-events-service/test/utils/assertions.js new file mode 100644 index 0000000000..7364ee8aa1 --- /dev/null +++ b/ghost/members-events-service/test/utils/assertions.js @@ -0,0 +1,11 @@ +/** + * Custom Should Assertions + * + * Add any custom assertions to this file. + */ + +// Example Assertion +// should.Assertion.add('ExampleAssertion', function () { +// this.params = {operator: 'to be a valid Example Assertion'}; +// this.obj.should.be.an.Object; +// }); diff --git a/ghost/members-events-service/test/utils/index.js b/ghost/members-events-service/test/utils/index.js new file mode 100644 index 0000000000..0d67d86ff8 --- /dev/null +++ b/ghost/members-events-service/test/utils/index.js @@ -0,0 +1,11 @@ +/** + * Test Utilities + * + * Shared utils for writing tests + */ + +// Require overrides - these add globals for tests +require('./overrides'); + +// Require assertions - adds custom should assertions +require('./assertions'); diff --git a/ghost/members-events-service/test/utils/overrides.js b/ghost/members-events-service/test/utils/overrides.js new file mode 100644 index 0000000000..90203424ee --- /dev/null +++ b/ghost/members-events-service/test/utils/overrides.js @@ -0,0 +1,10 @@ +// This file is required before any test is run + +// Taken from the should wiki, this is how to make should global +// Should is a global in our eslint test config +global.should = require('should').noConflict(); +should.extend(); + +// Sinon is a simple case +// Sinon is a global in our eslint test config +global.sinon = require('sinon');