diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 5ca319a6a9..035605cafa 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -179,6 +179,7 @@ const defaultServerConfig: AdminConfig = { EnableCustomGroups: true, SelfHostedPurchase: true, AllowSyncedDrafts: true, + RefreshPostStatsRunTime: '00:00', }, TeamSettings: { SiteName: 'Mattermost', diff --git a/server/channels/app/login.go b/server/channels/app/login.go index 0bfc210803..280b57daba 100644 --- a/server/channels/app/login.go +++ b/server/channels/app/login.go @@ -213,9 +213,14 @@ func (a *App) DoLogin(c request.CTX, w http.ResponseWriter, r *http.Request, use return nil, err } + if updateErr := a.Srv().Store().User().UpdateLastLogin(user.Id, session.CreateAt); updateErr != nil { + return nil, model.NewAppError("DoLogin", "app.login.doLogin.updateLastLogin.error", nil, updateErr.Error(), http.StatusInternalServerError) + } + w.Header().Set(model.HeaderToken, session.Token) c = c.WithSession(session) + if a.Srv().License() != nil && *a.Srv().License().Features.LDAP && a.Ldap() != nil { userVal := *user sessionVal := *session diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 7a834f2b80..4412c328b9 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -55,6 +55,7 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/jobs/plugins" "github.com/mattermost/mattermost/server/v8/channels/jobs/post_persistent_notifications" "github.com/mattermost/mattermost/server/v8/channels/jobs/product_notices" + "github.com/mattermost/mattermost/server/v8/channels/jobs/refresh_post_stats" "github.com/mattermost/mattermost/server/v8/channels/jobs/resend_invitation_email" "github.com/mattermost/mattermost/server/v8/channels/jobs/s3_path_migration" "github.com/mattermost/mattermost/server/v8/channels/product" @@ -1663,10 +1664,16 @@ func (s *Server) initJobs() { s.Jobs.RegisterJobType( model.JobTypeCleanupDesktopTokens, - cleanup_desktop_tokens.MakeWorker(s.Jobs, s.Store()), + cleanup_desktop_tokens.MakeWorker(s.Jobs), cleanup_desktop_tokens.MakeScheduler(s.Jobs), ) + s.Jobs.RegisterJobType( + model.JobTypeRefreshPostStats, + refresh_post_stats.MakeWorker(s.Jobs, *s.platform.Config().SqlSettings.DriverName), + refresh_post_stats.MakeScheduler(s.Jobs, *s.platform.Config().SqlSettings.DriverName), + ) + s.platform.Jobs = s.Jobs } diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index d2c41d3f2e..35aa0c7893 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -226,6 +226,8 @@ channels/db/migrations/mysql/000113_create_retentionidsfordeletion_table.down.sq channels/db/migrations/mysql/000113_create_retentionidsfordeletion_table.up.sql channels/db/migrations/mysql/000114_sharedchannelremotes_drop_nextsyncat_description.down.sql channels/db/migrations/mysql/000114_sharedchannelremotes_drop_nextsyncat_description.up.sql +channels/db/migrations/mysql/000115_user_reporting_changes.down.sql +channels/db/migrations/mysql/000115_user_reporting_changes.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -452,3 +454,5 @@ channels/db/migrations/postgres/000113_create_retentionidsfordeletion_table.down channels/db/migrations/postgres/000113_create_retentionidsfordeletion_table.up.sql channels/db/migrations/postgres/000114_sharedchannelremotes_drop_nextsyncat_description.down.sql channels/db/migrations/postgres/000114_sharedchannelremotes_drop_nextsyncat_description.up.sql +channels/db/migrations/postgres/000115_user_reporting_changes.down.sql +channels/db/migrations/postgres/000115_user_reporting_changes.up.sql diff --git a/server/channels/db/migrations/mysql/000115_user_reporting_changes.down.sql b/server/channels/db/migrations/mysql/000115_user_reporting_changes.down.sql new file mode 100644 index 0000000000..42e358a00d --- /dev/null +++ b/server/channels/db/migrations/mysql/000115_user_reporting_changes.down.sql @@ -0,0 +1,14 @@ +SET @preparedStatement = (SELECT IF( + EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'LastLogin' + ) > 0, + 'ALTER TABLE Users DROP COLUMN LastLogin;', + 'SELECT 1;' +)); + +PREPARE removeColumnIfExists FROM @preparedStatement; +EXECUTE removeColumnIfExists; +DEALLOCATE PREPARE removeColumnIfExists; diff --git a/server/channels/db/migrations/mysql/000115_user_reporting_changes.up.sql b/server/channels/db/migrations/mysql/000115_user_reporting_changes.up.sql new file mode 100644 index 0000000000..71901a8c1e --- /dev/null +++ b/server/channels/db/migrations/mysql/000115_user_reporting_changes.up.sql @@ -0,0 +1,14 @@ +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'LastLogin' + ), + 'ALTER TABLE Users ADD COLUMN LastLogin bigint NOT NULL DEFAULT 0;', + 'SELECT 1;' +)); + +PREPARE addColumnIfNotExists FROM @preparedStatement; +EXECUTE addColumnIfNotExists; +DEALLOCATE PREPARE addColumnIfNotExists; \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000115_user_reporting_changes.down.sql b/server/channels/db/migrations/postgres/000115_user_reporting_changes.down.sql new file mode 100644 index 0000000000..02e2ff4f6f --- /dev/null +++ b/server/channels/db/migrations/postgres/000115_user_reporting_changes.down.sql @@ -0,0 +1,3 @@ +DROP MATERIALIZED VIEW IF EXISTS poststats; + +ALTER TABLE users DROP COLUMN IF EXISTS lastlogin; \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000115_user_reporting_changes.up.sql b/server/channels/db/migrations/postgres/000115_user_reporting_changes.up.sql new file mode 100644 index 0000000000..5c4e2d75f4 --- /dev/null +++ b/server/channels/db/migrations/postgres/000115_user_reporting_changes.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS lastlogin bigint NOT NULL DEFAULT 0; + +CREATE MATERIALIZED VIEW IF NOT EXISTS poststats AS +SELECT userid, to_timestamp(createat/1000)::date as day, COUNT(*) as numposts, MAX(CreateAt) as lastpostdate +FROM posts +GROUP BY userid, day +; diff --git a/server/channels/jobs/cleanup_desktop_tokens/worker.go b/server/channels/jobs/cleanup_desktop_tokens/worker.go index 7a45267216..42c28a1bce 100644 --- a/server/channels/jobs/cleanup_desktop_tokens/worker.go +++ b/server/channels/jobs/cleanup_desktop_tokens/worker.go @@ -9,28 +9,19 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/jobs" - "github.com/mattermost/mattermost/server/v8/channels/store" - "github.com/mattermost/mattermost/server/v8/platform/services/configservice" ) const jobName = "CleanupDesktopTokens" const maxAge = 5 * time.Minute -type AppIface interface { - configservice.ConfigService - ListDirectory(path string) ([]string, *model.AppError) - FileModTime(path string) (time.Time, *model.AppError) - RemoveFile(path string) *model.AppError -} - -func MakeWorker(jobServer *jobs.JobServer, store store.Store) *jobs.SimpleWorker { +func MakeWorker(jobServer *jobs.JobServer) *jobs.SimpleWorker { isEnabled := func(cfg *model.Config) bool { return true } execute := func(logger mlog.LoggerIFace, job *model.Job) error { defer jobServer.HandleJobPanic(logger, job) - return store.DesktopTokens().DeleteOlderThan(time.Now().Add(-maxAge).Unix()) + return jobServer.Store.DesktopTokens().DeleteOlderThan(time.Now().Add(-maxAge).Unix()) } worker := jobs.NewSimpleWorker(jobName, jobServer, execute, isEnabled) return worker diff --git a/server/channels/jobs/refresh_post_stats/scheduler.go b/server/channels/jobs/refresh_post_stats/scheduler.go new file mode 100644 index 0000000000..bfe68baa3b --- /dev/null +++ b/server/channels/jobs/refresh_post_stats/scheduler.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package refresh_post_stats + +import ( + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/jobs" +) + +func MakeScheduler(jobServer *jobs.JobServer, sqlDriverName string) *jobs.DailyScheduler { + startTime := func(cfg *model.Config) *time.Time { + parsedTime, err := time.Parse("15:04", *cfg.ServiceSettings.RefreshPostStatsRunTime) + if err == nil { + return &parsedTime + } + return nil + } + isEnabled := func(cfg *model.Config) bool { + return sqlDriverName == model.DatabaseDriverPostgres + } + return jobs.NewDailyScheduler(jobServer, model.JobTypeRefreshPostStats, startTime, isEnabled) +} diff --git a/server/channels/jobs/refresh_post_stats/worker.go b/server/channels/jobs/refresh_post_stats/worker.go new file mode 100644 index 0000000000..cd5f285149 --- /dev/null +++ b/server/channels/jobs/refresh_post_stats/worker.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package refresh_post_stats + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/jobs" +) + +const jobName = "RefreshPostStats" + +func MakeWorker(jobServer *jobs.JobServer, sqlDriverName string) *jobs.SimpleWorker { + isEnabled := func(cfg *model.Config) bool { + return sqlDriverName == model.DatabaseDriverPostgres + } + execute := func(logger mlog.LoggerIFace, job *model.Job) error { + defer jobServer.HandleJobPanic(logger, job) + + return jobServer.Store.User().RefreshPostStatsForUsers() + } + worker := jobs.NewSimpleWorker(jobName, jobServer, execute, isEnabled) + return worker +} diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 3b03b47349..d535cabd49 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -11921,6 +11921,24 @@ func (s *OpenTracingLayerUserStore) PromoteGuestToUser(userID string) error { return err } +func (s *OpenTracingLayerUserStore) RefreshPostStatsForUsers() error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.RefreshPostStatsForUsers") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.UserStore.RefreshPostStatsForUsers() + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerUserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.ResetAuthDataToEmailForUsers") @@ -12155,6 +12173,24 @@ func (s *OpenTracingLayerUserStore) UpdateFailedPasswordAttempts(userID string, return err } +func (s *OpenTracingLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.UpdateLastLogin") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.UserStore.UpdateLastLogin(userID, lastLogin) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerUserStore) UpdateLastPictureUpdate(userID string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.UpdateLastPictureUpdate") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 1bda4e0ccb..d3b492660d 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -13585,6 +13585,27 @@ func (s *RetryLayerUserStore) PromoteGuestToUser(userID string) error { } +func (s *RetryLayerUserStore) RefreshPostStatsForUsers() error { + + tries := 0 + for { + err := s.UserStore.RefreshPostStatsForUsers() + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) { tries := 0 @@ -13858,6 +13879,27 @@ func (s *RetryLayerUserStore) UpdateFailedPasswordAttempts(userID string, attemp } +func (s *RetryLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error { + + tries := 0 + for { + err := s.UserStore.UpdateLastLogin(userID, lastLogin) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserStore) UpdateLastPictureUpdate(userID string) error { tries := 0 diff --git a/server/channels/store/sqlstore/user_store.go b/server/channels/store/sqlstore/user_store.go index 3c86c880aa..47b81372ff 100644 --- a/server/channels/store/sqlstore/user_store.go +++ b/server/channels/store/sqlstore/user_store.go @@ -54,7 +54,7 @@ func newSqlUserStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) s // note: we are providing field names explicitly here to maintain order of columns (needed when using raw queries) us.usersQuery = us.getQueryBuilder(). Select("u.Id", "u.CreateAt", "u.UpdateAt", "u.DeleteAt", "u.Username", "u.Password", "u.AuthData", "u.AuthService", "u.Email", "u.EmailVerified", "u.Nickname", "u.FirstName", "u.LastName", "u.Position", "u.Roles", "u.AllowMarketing", "u.Props", "u.NotifyProps", "u.LastPasswordUpdate", "u.LastPictureUpdate", "u.FailedAttempts", "u.Locale", "u.Timezone", "u.MfaActive", "u.MfaSecret", - "b.UserId IS NOT NULL AS IsBot", "COALESCE(b.Description, '') AS BotDescription", "COALESCE(b.LastIconUpdate, 0) AS BotLastIconUpdate", "u.RemoteId"). + "b.UserId IS NOT NULL AS IsBot", "COALESCE(b.Description, '') AS BotDescription", "COALESCE(b.LastIconUpdate, 0) AS BotLastIconUpdate", "u.RemoteId", "u.LastLogin"). From("Users u"). LeftJoin("Bots b ON ( b.UserId = u.Id )") @@ -193,6 +193,7 @@ func (us SqlUserStore) Update(user *model.User, trustedUpdateData bool) (*model. user.FailedAttempts = oldUser.FailedAttempts user.MfaSecret = oldUser.MfaSecret user.MfaActive = oldUser.MfaActive + user.LastLogin = oldUser.LastLogin if !trustedUpdateData { user.Roles = oldUser.Roles @@ -222,7 +223,7 @@ func (us SqlUserStore) Update(user *model.User, trustedUpdateData bool) (*model. AllowMarketing=:AllowMarketing, Props=:Props, NotifyProps=:NotifyProps, LastPasswordUpdate=:LastPasswordUpdate, LastPictureUpdate=:LastPictureUpdate, FailedAttempts=:FailedAttempts,Locale=:Locale, Timezone=:Timezone, MfaActive=:MfaActive, - MfaSecret=:MfaSecret, RemoteId=:RemoteId + MfaSecret=:MfaSecret, RemoteId=:RemoteId, LastLogin=:LastLogin WHERE Id=:Id` user.Props = wrapBinaryParamStringMap(us.IsBinaryParamEnabled(), user.Props) @@ -355,6 +356,27 @@ func (us SqlUserStore) UpdateAuthData(userId string, service string, authData *s return userId, nil } +func (us SqlUserStore) UpdateLastLogin(userId string, lastLogin int64) error { + updateAt := model.GetMillis() + + updateQuery := us.getQueryBuilder(). + Update("Users"). + Set("LastLogin", lastLogin). + Set("UpdateAt", updateAt). + Where(sq.Eq{"Id": userId}) + + queryString, args, err := updateQuery.ToSql() + if err != nil { + return errors.Wrap(err, "update_last_login_tosql") + } + + if _, err := us.GetMasterX().Exec(queryString, args...); err != nil { + return errors.Wrapf(err, "failed to update User with userId=%s", userId) + } + + return nil +} + // ResetAuthDataToEmailForUsers resets the AuthData of users whose AuthService // is |service| to their Email. If userIDs is non-empty, only the users whose // IDs are in userIDs will be affected. If dryRun is true, only the number @@ -449,7 +471,7 @@ func (us SqlUserStore) Get(ctx context.Context, id string) (*model.User, error) &user.Nickname, &user.FirstName, &user.LastName, &user.Position, &user.Roles, &user.AllowMarketing, &props, ¬ifyProps, &user.LastPasswordUpdate, &user.LastPictureUpdate, &user.FailedAttempts, &user.Locale, &timezone, &user.MfaActive, &user.MfaSecret, - &user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId) + &user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId, &user.LastLogin) if err != nil { if err == sql.ErrNoRows { return nil, store.NewErrNotFound("User", id) @@ -851,7 +873,7 @@ func (us SqlUserStore) GetAllProfilesInChannel(ctx context.Context, channelID st for rows.Next() { var user model.User var props, notifyProps, timezone []byte - if err = rows.Scan(&user.Id, &user.CreateAt, &user.UpdateAt, &user.DeleteAt, &user.Username, &user.Password, &user.AuthData, &user.AuthService, &user.Email, &user.EmailVerified, &user.Nickname, &user.FirstName, &user.LastName, &user.Position, &user.Roles, &user.AllowMarketing, &props, ¬ifyProps, &user.LastPasswordUpdate, &user.LastPictureUpdate, &user.FailedAttempts, &user.Locale, &timezone, &user.MfaActive, &user.MfaSecret, &user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId); err != nil { + if err = rows.Scan(&user.Id, &user.CreateAt, &user.UpdateAt, &user.DeleteAt, &user.Username, &user.Password, &user.AuthData, &user.AuthService, &user.Email, &user.EmailVerified, &user.Nickname, &user.FirstName, &user.LastName, &user.Position, &user.Roles, &user.AllowMarketing, &props, ¬ifyProps, &user.LastPasswordUpdate, &user.LastPictureUpdate, &user.FailedAttempts, &user.Locale, &timezone, &user.MfaActive, &user.MfaSecret, &user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId, &user.LastLogin); err != nil { return nil, errors.Wrap(err, "failed to scan values from rows into User entity") } if err = json.Unmarshal(props, &user.Props); err != nil { @@ -2231,3 +2253,15 @@ func (us SqlUserStore) GetUsersWithInvalidEmails(page int, perPage int, restrict return users, nil } + +func (us SqlUserStore) RefreshPostStatsForUsers() error { + if us.DriverName() == model.DatabaseDriverPostgres { + if _, err := us.GetReplicaX().Exec("REFRESH MATERIALIZED VIEW poststats"); err != nil { + return errors.Wrap(err, "users_refresh_post_stats_exec") + } + } else { + mlog.Debug("Skipped running refresh post stats, only available on Postgres") + } + + return nil +} diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 5337365b68..48ce75f3f7 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -419,6 +419,7 @@ type UserStore interface { UpdatePassword(userID, newPassword string) error UpdateUpdateAt(userID string) (int64, error) UpdateAuthData(userID string, service string, authData *string, email string, resetMfa bool) (string, error) + UpdateLastLogin(userID string, lastLogin int64) error ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) UpdateMfaSecret(userID, secret string) error UpdateMfaActive(userID string, active bool) error @@ -488,6 +489,7 @@ type UserStore interface { IsEmpty(excludeBots bool) (bool, error) GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error) InsertUsers(users []*model.User) error + RefreshPostStatsForUsers() error } type BotStore interface { diff --git a/server/channels/store/storetest/mocks/UserStore.go b/server/channels/store/storetest/mocks/UserStore.go index 48ef53822d..14cb363c7e 100644 --- a/server/channels/store/storetest/mocks/UserStore.go +++ b/server/channels/store/storetest/mocks/UserStore.go @@ -1308,6 +1308,20 @@ func (_m *UserStore) PromoteGuestToUser(userID string) error { return r0 } +// RefreshPostStatsForUsers provides a mock function with given fields: +func (_m *UserStore) RefreshPostStatsForUsers() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + // ResetAuthDataToEmailForUsers provides a mock function with given fields: service, userIDs, includeDeleted, dryRun func (_m *UserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) { ret := _m.Called(service, userIDs, includeDeleted, dryRun) @@ -1618,6 +1632,20 @@ func (_m *UserStore) UpdateFailedPasswordAttempts(userID string, attempts int) e return r0 } +// UpdateLastLogin provides a mock function with given fields: userID, lastLogin +func (_m *UserStore) UpdateLastLogin(userID string, lastLogin int64) error { + ret := _m.Called(userID, lastLogin) + + var r0 error + if rf, ok := ret.Get(0).(func(string, int64) error); ok { + r0 = rf(userID, lastLogin) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UpdateLastPictureUpdate provides a mock function with given fields: userID func (_m *UserStore) UpdateLastPictureUpdate(userID string) error { ret := _m.Called(userID) diff --git a/server/channels/store/storetest/user_store.go b/server/channels/store/storetest/user_store.go index a866762223..1ef6601ab0 100644 --- a/server/channels/store/storetest/user_store.go +++ b/server/channels/store/storetest/user_store.go @@ -95,6 +95,7 @@ func TestUserStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("ResetLastPictureUpdate", func(t *testing.T) { testUserStoreResetLastPictureUpdate(t, ss) }) t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, ss) }) t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(t, ss) }) + t.Run("UpdateLastLogin", func(t *testing.T) { testUpdateLastLogin(t, ss) }) } func testUserStoreSave(t *testing.T, ss store.Store) { @@ -6169,3 +6170,18 @@ func testGetUsersWithInvalidEmails(t *testing.T, ss store.Store) { require.NoError(t, err) assert.Len(t, users, 1) } + +func testUpdateLastLogin(t *testing.T, ss store.Store) { + u1 := model.User{} + u1.Email = MakeEmail() + _, err := ss.User().Save(&u1) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(u1.Id)) }() + + err = ss.User().UpdateLastLogin(u1.Id, 1234567890) + require.NoError(t, err) + + user, err := ss.User().Get(context.Background(), u1.Id) + require.NoError(t, err) + require.Equal(t, int64(1234567890), user.LastLogin) +} diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index f2d67114d0..89e63f596a 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -10744,6 +10744,22 @@ func (s *TimerLayerUserStore) PromoteGuestToUser(userID string) error { return err } +func (s *TimerLayerUserStore) RefreshPostStatsForUsers() error { + start := time.Now() + + err := s.UserStore.RefreshPostStatsForUsers() + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.RefreshPostStatsForUsers", success, elapsed) + } + return err +} + func (s *TimerLayerUserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) { start := time.Now() @@ -10952,6 +10968,22 @@ func (s *TimerLayerUserStore) UpdateFailedPasswordAttempts(userID string, attemp return err } +func (s *TimerLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error { + start := time.Now() + + err := s.UserStore.UpdateLastLogin(userID, lastLogin) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.UpdateLastLogin", success, elapsed) + } + return err +} + func (s *TimerLayerUserStore) UpdateLastPictureUpdate(userID string) error { start := time.Now() diff --git a/server/i18n/en.json b/server/i18n/en.json index 19c0282d8c..2d8cd07d69 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -6018,6 +6018,10 @@ "id": "app.license.generate_renewal_token.no_license", "translation": "No license present" }, + { + "id": "app.login.doLogin.updateLastLogin.error", + "translation": "Could not update last login timestamp" + }, { "id": "app.member_count", "translation": "error retrieving member count" diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 9022ce80a1..2f97d7b0e6 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -492,6 +492,7 @@ func (ts *TelemetryService) trackConfig() { "persistent_notification_max_recipients": *cfg.ServiceSettings.PersistentNotificationMaxRecipients, "self_hosted_purchase": *cfg.ServiceSettings.SelfHostedPurchase, "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, + "refresh_post_stats_run_time": *cfg.ServiceSettings.RefreshPostStatsRunTime, }) ts.SendTelemetry(TrackConfigTeam, map[string]any{ diff --git a/server/public/model/config.go b/server/public/model/config.go index 4adb681bbe..8422ea56d8 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -394,6 +394,7 @@ type ServiceSettings struct { EnableCustomGroups *bool `access:"site_users_and_teams"` SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` + RefreshPostStatsRunTime *string `access:"site_users_and_teams"` } var MattermostGiphySdkKey string @@ -884,6 +885,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.SelfHostedPurchase == nil { s.SelfHostedPurchase = NewBool(true) } + + if s.RefreshPostStatsRunTime == nil { + s.RefreshPostStatsRunTime = NewString("00:00") + } } type ClusterSettings struct { diff --git a/server/public/model/job.go b/server/public/model/job.go index 0092d95970..a592e7b61d 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -37,6 +37,7 @@ const ( JobTypeS3PathMigration = "s3_path_migration" JobTypeCleanupDesktopTokens = "cleanup_desktop_tokens" JobTypeDeleteEmptyDraftsMigration = "delete_empty_drafts_migration" + JobTypeRefreshPostStats = "refresh_post_stats" JobStatusPending = "pending" JobStatusInProgress = "in_progress" @@ -68,6 +69,7 @@ var AllJobTypes = [...]string{ JobTypeLastAccessiblePost, JobTypeLastAccessibleFile, JobTypeCleanupDesktopTokens, + JobTypeRefreshPostStats, } type Job struct { diff --git a/server/public/model/user.go b/server/public/model/user.go index ef42d5998c..9c936eac21 100644 --- a/server/public/model/user.go +++ b/server/public/model/user.go @@ -106,6 +106,7 @@ type User struct { TermsOfServiceId string `json:"terms_of_service_id,omitempty"` TermsOfServiceCreateAt int64 `json:"terms_of_service_create_at,omitempty"` DisableWelcomeEmail bool `json:"disable_welcome_email"` + LastLogin int64 `json:"last_login,omitempty"` } func (u *User) Auditable() map[string]interface{} { @@ -611,6 +612,7 @@ func (u *User) Sanitize(options map[string]bool) { u.Password = "" u.AuthData = NewString("") u.MfaSecret = "" + u.LastLogin = 0 if len(options) != 0 && !options["email"] { u.Email = "" diff --git a/server/public/model/user_serial_gen.go b/server/public/model/user_serial_gen.go index 3bcb3cf79a..a431749253 100644 --- a/server/public/model/user_serial_gen.go +++ b/server/public/model/user_serial_gen.go @@ -17,8 +17,8 @@ func (z *User) DecodeMsg(dc *msgp.Reader) (err error) { err = msgp.WrapError(err) return } - if zb0001 != 33 { - err = msgp.ArrayError{Wanted: 33, Got: zb0001} + if zb0001 != 34 { + err = msgp.ArrayError{Wanted: 34, Got: zb0001} return } z.Id, err = dc.ReadString() @@ -210,13 +210,18 @@ func (z *User) DecodeMsg(dc *msgp.Reader) (err error) { err = msgp.WrapError(err, "DisableWelcomeEmail") return } + z.LastLogin, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "LastLogin") + return + } return } // EncodeMsg implements msgp.Encodable func (z *User) EncodeMsg(en *msgp.Writer) (err error) { - // array header, size 33 - err = en.Append(0xdc, 0x0, 0x21) + // array header, size 34 + err = en.Append(0xdc, 0x0, 0x22) if err != nil { return } @@ -399,14 +404,19 @@ func (z *User) EncodeMsg(en *msgp.Writer) (err error) { err = msgp.WrapError(err, "DisableWelcomeEmail") return } + err = en.WriteInt64(z.LastLogin) + if err != nil { + err = msgp.WrapError(err, "LastLogin") + return + } return } // MarshalMsg implements msgp.Marshaler func (z *User) MarshalMsg(b []byte) (o []byte, err error) { o = msgp.Require(b, z.Msgsize()) - // array header, size 33 - o = append(o, 0xdc, 0x0, 0x21) + // array header, size 34 + o = append(o, 0xdc, 0x0, 0x22) o = msgp.AppendString(o, z.Id) o = msgp.AppendInt64(o, z.CreateAt) o = msgp.AppendInt64(o, z.UpdateAt) @@ -460,6 +470,7 @@ func (z *User) MarshalMsg(b []byte) (o []byte, err error) { o = msgp.AppendString(o, z.TermsOfServiceId) o = msgp.AppendInt64(o, z.TermsOfServiceCreateAt) o = msgp.AppendBool(o, z.DisableWelcomeEmail) + o = msgp.AppendInt64(o, z.LastLogin) return } @@ -471,8 +482,8 @@ func (z *User) UnmarshalMsg(bts []byte) (o []byte, err error) { err = msgp.WrapError(err) return } - if zb0001 != 33 { - err = msgp.ArrayError{Wanted: 33, Got: zb0001} + if zb0001 != 34 { + err = msgp.ArrayError{Wanted: 34, Got: zb0001} return } z.Id, bts, err = msgp.ReadStringBytes(bts) @@ -662,6 +673,11 @@ func (z *User) UnmarshalMsg(bts []byte) (o []byte, err error) { err = msgp.WrapError(err, "DisableWelcomeEmail") return } + z.LastLogin, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "LastLogin") + return + } o = bts return } @@ -680,7 +696,7 @@ func (z *User) Msgsize() (s int) { } else { s += msgp.StringPrefixSize + len(*z.RemoteId) } - s += msgp.Int64Size + msgp.BoolSize + msgp.StringPrefixSize + len(z.BotDescription) + msgp.Int64Size + msgp.StringPrefixSize + len(z.TermsOfServiceId) + msgp.Int64Size + msgp.BoolSize + s += msgp.Int64Size + msgp.BoolSize + msgp.StringPrefixSize + len(z.BotDescription) + msgp.Int64Size + msgp.StringPrefixSize + len(z.TermsOfServiceId) + msgp.Int64Size + msgp.BoolSize + msgp.Int64Size return } diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 7aada60a25..20c22f5e30 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -2464,6 +2464,17 @@ const AdminDefinition: AdminDefinitionType = { it.licensedForSku(LicenseSkus.Professional), )), }, + { + type: 'text', + key: 'ServiceSettings.RefreshPostStatsRunTime', + label: t('admin.team.refreshPostStatsRunTimeTitle'), + label_default: 'User Statistics Update Time:', + help_text: t('admin.team.refreshPostStatsRunTimeDescription'), + help_text_default: "Set the server time for updating the user post statistics, including each user's total post count and the timestamp of their most recent post. Must be a 24-hour time stamp in the form HH:MM based on the local time of the server.", + placeholder: t('admin.team.refreshPostStatsRunTimeExample'), + placeholder_default: 'E.g.: "00:00"', + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)), + }, ], }, }, diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index a196a96efa..c3687ea327 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2550,6 +2550,9 @@ "admin.team.noBrandImage": "No brand image uploaded", "admin.team.openServerDescription": "When true, anyone can signup for a user account on this server without the need to be invited.", "admin.team.openServerTitle": "Enable Open Server: ", + "admin.team.refreshPostStatsRunTimeDescription": "Set the server time for updating the user post statistics, including each user's total post count and the timestamp of their most recent post. Must be a 24-hour time stamp in the form HH:MM based on the local time of the server.", + "admin.team.refreshPostStatsRunTimeExample": "E.g.: \"00:00\"", + "admin.team.refreshPostStatsRunTimeTitle": "User Statistics Update Time:", "admin.team.removeBrandImage": "Remove brand image", "admin.team.restrict_direct_message_any": "Any user on the Mattermost server", "admin.team.restrict_direct_message_team": "Any member of the team", diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 1924e5c5fc..f0e66db4b0 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -378,6 +378,7 @@ export type ServiceSettings = { PersistentNotificationIntervalMinutes: number; PersistentNotificationMaxCount: number; PersistentNotificationMaxRecipients: number; + RefreshPostStatsRunTime: string; }; export type TeamSettings = {