186 lines
9.7 KiB
JavaScript
186 lines
9.7 KiB
JavaScript
|
const {MemberPageViewEvent, MemberCommentEvent, MemberLinkClickEvent} = require('@tryghost/member-events');
|
||
|
const moment = require('moment-timezone');
|
||
|
const {IncorrectUsageError} = require('@tryghost/errors');
|
||
|
const {EmailOpenedEvent} = require('@tryghost/email-events');
|
||
|
const logging = require('@tryghost/logging');
|
||
|
const LastSeenAtCache = require('./LastSeenAtCache');
|
||
|
|
||
|
/**
|
||
|
* Listen for `MemberViewEvent` to update the `member.last_seen_at` timestamp
|
||
|
*/
|
||
|
class LastSeenAtUpdater {
|
||
|
/**
|
||
|
* Initializes the event subscriber
|
||
|
* @param {Object} deps dependencies
|
||
|
* @param {Object} deps.services The list of service dependencies
|
||
|
* @param {any} deps.services.settingsCache The settings service
|
||
|
* @param {() => object} deps.getMembersApi - A function which returns an instance of members-api
|
||
|
* @param {any} deps.db Database connection
|
||
|
* @param {any} deps.events The event emitter
|
||
|
* @param {any} deps.lastSeenAtCache An instance of the last seen at cache
|
||
|
*/
|
||
|
constructor({
|
||
|
services: {
|
||
|
settingsCache
|
||
|
},
|
||
|
getMembersApi,
|
||
|
db,
|
||
|
events,
|
||
|
lastSeenAtCache
|
||
|
}) {
|
||
|
if (!getMembersApi) {
|
||
|
throw new IncorrectUsageError({message: 'Missing option getMembersApi'});
|
||
|
}
|
||
|
|
||
|
this._getMembersApi = getMembersApi;
|
||
|
this._settingsCacheService = settingsCache;
|
||
|
this._db = db;
|
||
|
this._events = events;
|
||
|
this._lastSeenAtCache = lastSeenAtCache || new LastSeenAtCache({services: {settingsCache}});
|
||
|
}
|
||
|
/**
|
||
|
* Subscribe to events of this domainEvents service
|
||
|
* @param {any} domainEvents The DomainEvents service
|
||
|
*/
|
||
|
subscribe(domainEvents) {
|
||
|
domainEvents.subscribe(MemberPageViewEvent, async (event) => {
|
||
|
try {
|
||
|
await this.cachedUpdateLastSeenAt(event.data.memberId, event.data.memberLastSeenAt, event.timestamp);
|
||
|
} catch (err) {
|
||
|
logging.error(`Error in LastSeenAtUpdater.MemberPageViewEvent listener for member ${event.data.memberId}`);
|
||
|
logging.error(err);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
domainEvents.subscribe(MemberLinkClickEvent, async (event) => {
|
||
|
try {
|
||
|
await this.cachedUpdateLastSeenAt(event.data.memberId, event.data.memberLastSeenAt, event.timestamp);
|
||
|
} catch (err) {
|
||
|
logging.error(`Error in LastSeenAtUpdater.MemberLinkClickEvent listener for member ${event.data.memberId}`);
|
||
|
logging.error(err);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
domainEvents.subscribe(MemberCommentEvent, async (event) => {
|
||
|
try {
|
||
|
await this.updateLastCommentedAt(event.data.memberId, event.timestamp);
|
||
|
} catch (err) {
|
||
|
logging.error(`Error in LastSeenAtUpdater.MemberCommentEvent listener for member ${event.data.memberId}`);
|
||
|
logging.error(err);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
domainEvents.subscribe(EmailOpenedEvent, async (event) => {
|
||
|
try {
|
||
|
await this.updateLastSeenAtWithoutKnownLastSeen(event.memberId, event.timestamp);
|
||
|
} catch (err) {
|
||
|
logging.error(`Error in LastSeenAtUpdater.EmailOpenedEvent listener for member ${event.memberId}, emailRecipientId ${event.emailRecipientId}`);
|
||
|
logging.error(err);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
|
||
|
* Example: current time is 2022-02-28 18:00:00
|
||
|
* - memberLastSeenAt is 2022-02-27 23:00:00, timestamp is current time, then `last_seen_at` is set to the current time
|
||
|
* - memberLastSeenAt is 2022-02-28 01:00:00, timestamp is current time, then `last_seen_at` isn't changed
|
||
|
* @param {string} memberId The id of the member to be udpated
|
||
|
* @param {Date} timestamp The event timestamp
|
||
|
*/
|
||
|
async updateLastSeenAtWithoutKnownLastSeen(memberId, timestamp) {
|
||
|
// Note: we are not using Bookshelf / member repository to prevent firing webhooks + to prevent deadlock issues
|
||
|
// If we would use the member repostiory, we would create a forUpdate lock when editing the member, including when fetching the member labels. Creating a possible deadlock if somewhere else we do the reverse in a transaction.
|
||
|
const timezone = this._settingsCacheService.get('timezone') || 'Etc/UTC';
|
||
|
const startOfDayInSiteTimezone = moment.utc(timestamp).tz(timezone).startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
|
||
|
const formattedTimestamp = moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss');
|
||
|
await this._db.knex('members')
|
||
|
.where('id', '=', memberId)
|
||
|
.andWhere(builder => builder
|
||
|
.where('last_seen_at', '<', startOfDayInSiteTimezone)
|
||
|
.orWhereNull('last_seen_at')
|
||
|
)
|
||
|
.update({
|
||
|
last_seen_at: formattedTimestamp
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
|
||
|
*/
|
||
|
async cachedUpdateLastSeenAt(memberId, memberLastSeenAt, timestamp) {
|
||
|
if (this._lastSeenAtCache.shouldUpdateMember(memberId)) {
|
||
|
await this.updateLastSeenAt(memberId, memberLastSeenAt, timestamp);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
|
||
|
* Example: current time is 2022-02-28 18:00:00
|
||
|
* - memberLastSeenAt is 2022-02-27 23:00:00, timestamp is current time, then `last_seen_at` is set to the current time
|
||
|
* - memberLastSeenAt is 2022-02-28 01:00:00, timestamp is current time, then `last_seen_at` isn't changed
|
||
|
* @param {string} memberId The id of the member to be udpated
|
||
|
* @param {Date} timestamp The event timestamp
|
||
|
*/
|
||
|
async updateLastSeenAt(memberId, memberLastSeenAt, timestamp) {
|
||
|
const timezone = this._settingsCacheService.get('timezone');
|
||
|
// First, check if memberLastSeenAt is null or before the beginning of the current day in the publication timezone
|
||
|
// This isn't strictly necessary since we will fetch the member row for update and double check this
|
||
|
// This is an optimization to avoid unnecessary database queries if last_seen_at is already after the beginning of the current day
|
||
|
if (memberLastSeenAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(memberLastSeenAt)) {
|
||
|
try {
|
||
|
// Pre-emptively update local cache so we don't update the same member again in the same day
|
||
|
this._lastSeenAtCache.add(memberId);
|
||
|
const membersApi = this._getMembersApi();
|
||
|
await this._db.knex.transaction(async (trx) => {
|
||
|
// To avoid a race condition, we lock the member row for update, then the last_seen_at field again to prevent simultaneous updates
|
||
|
const currentMember = await membersApi.members.get({id: memberId}, {require: true, transacting: trx, forUpdate: true});
|
||
|
const currentMemberLastSeenAt = currentMember.get('last_seen_at');
|
||
|
if (currentMemberLastSeenAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(currentMemberLastSeenAt)) {
|
||
|
const memberToUpdate = await currentMember.refresh({transacting: trx, forUpdate: false, withRelated: ['labels', 'newsletters']});
|
||
|
const updatedMember = await memberToUpdate.save({last_seen_at: moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss')}, {transacting: trx, patch: true, method: 'update'});
|
||
|
// The standard event doesn't get emitted inside the transaction, so we do it manually
|
||
|
this._events.emit('member.edited', updatedMember);
|
||
|
return Promise.resolve(updatedMember);
|
||
|
}
|
||
|
return Promise.resolve(undefined);
|
||
|
});
|
||
|
} catch (err) {
|
||
|
// Remove the member from the cache if an error occurs
|
||
|
// This is to ensure that the member is updated on the next event if this one fails
|
||
|
this._lastSeenAtCache.remove(memberId);
|
||
|
// Bubble up the error to the event listener
|
||
|
throw err;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
|
||
|
* Example: current time is 2022-02-28 18:00:00
|
||
|
* - memberLastSeenAt is 2022-02-27 23:00:00, timestamp is current time, then `last_seen_at` is set to the current time
|
||
|
* - memberLastSeenAt is 2022-02-28 01:00:00, timestamp is current time, then `last_seen_at` isn't changed
|
||
|
* @param {string} memberId The id of the member to be udpated
|
||
|
* @param {Date} timestamp The event timestamp
|
||
|
*/
|
||
|
async updateLastCommentedAt(memberId, timestamp) {
|
||
|
const membersApi = this._getMembersApi();
|
||
|
const member = await membersApi.members.get({id: memberId}, {require: true});
|
||
|
const timezone = this._settingsCacheService.get('timezone');
|
||
|
|
||
|
const memberLastSeenAt = member.get('last_seen_at');
|
||
|
const memberLastCommentedAt = member.get('last_commented_at');
|
||
|
|
||
|
if (memberLastSeenAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(memberLastSeenAt) || memberLastCommentedAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(memberLastCommentedAt)) {
|
||
|
await membersApi.members.update({
|
||
|
last_seen_at: moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss'),
|
||
|
last_commented_at: moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||
|
}, {
|
||
|
id: memberId
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
module.exports = LastSeenAtUpdater;
|