[MM-55014][MM-55015] Add last login timestamp for users, add materialized view and refresh job to keep track of post stats for Postgres (#25152)

* [MM-55014][MM-55015] Add last login timestamp for users, add materialized view and refresh job for Postgres

* Check fixes

* Fix type issue

* Add verification that lastlogin was updated

* PR feedback

* Morge'd

* Morge'd again

* Merge'd

* Update admin setting strings

* WIP

* PR feedback

* Oops

* Fix i18n

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Devin Binnie 2023-11-14 11:26:27 -05:00 committed by GitHub
parent 41c08a3715
commit 1bd72bdb99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 356 additions and 25 deletions

View File

@ -179,6 +179,7 @@ const defaultServerConfig: AdminConfig = {
EnableCustomGroups: true,
SelfHostedPurchase: true,
AllowSyncedDrafts: true,
RefreshPostStatsRunTime: '00:00',
},
TeamSettings: {
SiteName: 'Mattermost',

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,3 @@
DROP MATERIALIZED VIEW IF EXISTS poststats;
ALTER TABLE users DROP COLUMN IF EXISTS lastlogin;

View File

@ -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
;

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")

View File

@ -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

View File

@ -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, &notifyProps, &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, &notifyProps, &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, &notifyProps, &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
}

View File

@ -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 {

View File

@ -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)

View File

@ -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)
}

View File

@ -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()

View File

@ -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"

View File

@ -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{

View File

@ -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 {

View File

@ -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 {

View File

@ -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 = ""

View File

@ -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
}

View File

@ -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)),
},
],
},
},

View File

@ -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",

View File

@ -378,6 +378,7 @@ export type ServiceSettings = {
PersistentNotificationIntervalMinutes: number;
PersistentNotificationMaxCount: number;
PersistentNotificationMaxRecipients: number;
RefreshPostStatsRunTime: string;
};
export type TeamSettings = {