2022-09-23 11:34:33 +03:00
const { MemberPageViewEvent , MemberCommentEvent , MemberLinkClickEvent } = require ( '@tryghost/member-events' ) ;
2022-03-01 12:28:45 +03:00
const moment = require ( 'moment-timezone' ) ;
2022-05-02 21:07:30 +03:00
const { IncorrectUsageError } = require ( '@tryghost/errors' ) ;
2022-11-29 13:15:19 +03:00
const { EmailOpenedEvent } = require ( '@tryghost/email-events' ) ;
2022-12-05 16:36:23 +03:00
const logging = require ( '@tryghost/logging' ) ;
2022-02-23 21:13:42 +03:00
/ * *
* Listen for ` MemberViewEvent ` to update the ` member.last_seen_at ` timestamp
* /
class LastSeenAtUpdater {
/ * *
* Initializes the event subscriber
* @ param { Object } deps dependencies
2022-03-01 12:28:45 +03:00
* @ param { Object } deps . services The list of service dependencies
* @ param { any } deps . services . settingsCache The settings service
2022-05-02 21:07:30 +03:00
* @ param { ( ) => object } deps . getMembersApi - A function which returns an instance of members - api
2022-12-14 19:50:42 +03:00
* @ param { any } deps . db Database connection
2024-06-19 06:03:32 +03:00
* @ param { any } deps . events The event emitter
2022-02-23 21:13:42 +03:00
* /
2022-03-01 12:28:45 +03:00
constructor ( {
services : {
settingsCache
2022-05-02 21:07:30 +03:00
} ,
2022-12-14 19:50:42 +03:00
getMembersApi ,
2024-06-19 06:03:32 +03:00
db ,
events
2022-03-01 12:28:45 +03:00
} ) {
2022-05-02 21:07:30 +03:00
if ( ! getMembersApi ) {
throw new IncorrectUsageError ( { message : 'Missing option getMembersApi' } ) ;
}
this . _getMembersApi = getMembersApi ;
2022-03-01 12:28:45 +03:00
this . _settingsCacheService = settingsCache ;
2022-12-14 19:50:42 +03:00
this . _db = db ;
2024-06-19 06:03:32 +03:00
this . _events = events ;
2022-09-07 17:41:59 +03:00
}
/ * *
* Subscribe to events of this domainEvents service
* @ param { any } domainEvents The DomainEvents service
* /
subscribe ( domainEvents ) {
domainEvents . subscribe ( MemberPageViewEvent , async ( event ) => {
2022-12-05 16:36:23 +03:00
try {
await this . updateLastSeenAt ( 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 ) ;
}
2022-02-23 21:13:42 +03:00
} ) ;
2022-07-25 18:35:46 +03:00
2022-09-23 11:34:33 +03:00
domainEvents . subscribe ( MemberLinkClickEvent , async ( event ) => {
2022-12-05 16:36:23 +03:00
try {
await this . updateLastSeenAt ( 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 ) ;
}
2022-09-23 11:34:33 +03:00
} ) ;
2022-09-07 17:41:59 +03:00
domainEvents . subscribe ( MemberCommentEvent , async ( event ) => {
2022-12-05 16:36:23 +03:00
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 ) ;
}
2022-07-25 18:35:46 +03:00
} ) ;
2022-11-29 13:15:19 +03:00
domainEvents . subscribe ( EmailOpenedEvent , async ( event ) => {
2022-12-05 16:36:23 +03:00
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 ) ;
}
2022-11-29 13:15:19 +03:00
} ) ;
}
/ * *
* 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 ) {
2022-12-14 19:50:42 +03:00
// 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
} ) ;
2022-02-23 21:13:42 +03:00
}
/ * *
2022-03-01 19:35:02 +03:00
* 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
2022-02-23 21:13:42 +03:00
* @ param { string } memberId The id of the member to be udpated
* @ param { Date } timestamp The event timestamp
* /
async updateLastSeenAt ( memberId , memberLastSeenAt , timestamp ) {
2022-03-01 12:28:45 +03:00
const timezone = this . _settingsCacheService . get ( 'timezone' ) ;
2024-06-19 06:03:32 +03:00
// 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
2022-03-01 15:07:37 +03:00
if ( memberLastSeenAt === null || moment ( moment . utc ( timestamp ) . tz ( timezone ) . startOf ( 'day' ) ) . isAfter ( memberLastSeenAt ) ) {
2022-11-29 13:15:19 +03:00
const membersApi = this . _getMembersApi ( ) ;
2024-06-19 06:03:32 +03:00
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 ) ;
2022-02-23 21:13:42 +03:00
} ) ;
}
}
2022-07-25 18:35:46 +03:00
/ * *
* 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 ) {
2022-11-29 13:15:19 +03:00
const membersApi = this . _getMembersApi ( ) ;
2022-07-25 18:35:46 +03:00
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
} ) ;
}
}
2022-02-23 21:13:42 +03:00
}
module . exports = LastSeenAtUpdater ;