From a11b27d1f2366c55748248e7071326c3d87de84d Mon Sep 17 00:00:00 2001 From: Ben Cooke Date: Thu, 7 Dec 2023 16:39:10 -0500 Subject: [PATCH 1/3] [MM-54594] Adding new global relay type (#24672) * adding new global relay type, custom --- .../support/server/default_config.ts | 2 + server/i18n/en.json | 6 +- server/public/model/config.go | 23 +++++-- .../message_export_settings.test.jsx.snap | 8 +++ .../message_export_settings.test.jsx | 4 ++ .../admin_console/message_export_settings.tsx | 63 +++++++++++++++++++ webapp/channels/src/i18n/en.json | 7 +++ webapp/platform/types/src/config.ts | 2 + 8 files changed, 108 insertions(+), 7 deletions(-) diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 1a1a0f0740101..d8b9eca856418 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -630,6 +630,8 @@ const defaultServerConfig: AdminConfig = { SMTPPassword: '', EmailAddress: '', SMTPServerTimeout: 1800, + CustomSMTPServerName: '', + CustomSMTPPort: '25', }, }, JobSettings: { diff --git a/server/i18n/en.json b/server/i18n/en.json index 19beee848ae5b..78cffd3694e18 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -8848,7 +8848,11 @@ }, { "id": "model.config.is_valid.message_export.global_relay.customer_type.app_error", - "translation": "Message export GlobalRelaySettings.CustomerType must be set to one of either 'A9' or 'A10'." + "translation": "Message export GlobalRelaySettings.CustomerType must be set to one of either 'A9', 'A10' or 'CUSTOM." + }, + { + "id": "model.config.is_valid.message_export.global_relay.customer_type_custom.app_error", + "translation": "If GlobalRelaySettings.CustomerType is 'CUSTOM', then GlobalRelaySettings.CustomSMTPServerName and GlobalRelaySettings.CustomSMTPPort must be set." }, { "id": "model.config.is_valid.message_export.global_relay.email_address.app_error", diff --git a/server/public/model/config.go b/server/public/model/config.go index 030f939e5c854..a13385a7ae199 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -226,6 +226,7 @@ const ( ComplianceExportTypeGlobalrelayZip = "globalrelay-zip" GlobalrelayCustomerTypeA9 = "A9" GlobalrelayCustomerTypeA10 = "A10" + GlobalrelayCustomerTypeCustom = "CUSTOM" ClientSideCertCheckPrimaryAuth = "primary" ClientSideCertCheckSecondaryAuth = "secondary" @@ -3108,11 +3109,13 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) { } type GlobalRelayMessageExportSettings struct { - CustomerType *string `access:"compliance_compliance_export"` // must be either A9 or A10, dictates SMTP server url - SMTPUsername *string `access:"compliance_compliance_export"` - SMTPPassword *string `access:"compliance_compliance_export"` - EmailAddress *string `access:"compliance_compliance_export"` // the address to send messages to - SMTPServerTimeout *int `access:"compliance_compliance_export"` + CustomerType *string `access:"compliance_compliance_export"` // must be either A9, A10 or CUSTOM, dictates SMTP server url + SMTPUsername *string `access:"compliance_compliance_export"` + SMTPPassword *string `access:"compliance_compliance_export"` + EmailAddress *string `access:"compliance_compliance_export"` // the address to send messages to + SMTPServerTimeout *int `access:"compliance_compliance_export"` + CustomSMTPServerName *string `access:"compliance_compliance_export"` + CustomSMTPPort *string `access:"compliance_compliance_export"` } func (s *GlobalRelayMessageExportSettings) SetDefaults() { @@ -3131,6 +3134,12 @@ func (s *GlobalRelayMessageExportSettings) SetDefaults() { if s.SMTPServerTimeout == nil || *s.SMTPServerTimeout == 0 { s.SMTPServerTimeout = NewInt(1800) } + if s.CustomSMTPServerName == nil { + s.CustomSMTPServerName = NewString("") + } + if s.CustomSMTPPort == nil { + s.CustomSMTPPort = NewString("25") + } } type MessageExportSettings struct { @@ -4105,8 +4114,10 @@ func (s *MessageExportSettings) isValid() *AppError { if *s.ExportFormat == ComplianceExportTypeGlobalrelay { if s.GlobalRelaySettings == nil { return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.config_missing.app_error", nil, "", http.StatusBadRequest) - } else if s.GlobalRelaySettings.CustomerType == nil || (*s.GlobalRelaySettings.CustomerType != GlobalrelayCustomerTypeA9 && *s.GlobalRelaySettings.CustomerType != GlobalrelayCustomerTypeA10) { + } else if s.GlobalRelaySettings.CustomerType == nil || (*s.GlobalRelaySettings.CustomerType != GlobalrelayCustomerTypeA9 && *s.GlobalRelaySettings.CustomerType != GlobalrelayCustomerTypeA10 && *s.GlobalRelaySettings.CustomerType != GlobalrelayCustomerTypeCustom) { return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.customer_type.app_error", nil, "", http.StatusBadRequest) + } else if *s.GlobalRelaySettings.CustomerType == GlobalrelayCustomerTypeCustom && ((s.GlobalRelaySettings.CustomSMTPServerName == nil || *s.GlobalRelaySettings.CustomSMTPServerName == "") || (s.GlobalRelaySettings.CustomSMTPPort == nil || *s.GlobalRelaySettings.CustomSMTPPort == "")) { + return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.customer_type_custom.app_error", nil, "", http.StatusBadRequest) } else if s.GlobalRelaySettings.EmailAddress == nil || !strings.Contains(*s.GlobalRelaySettings.EmailAddress, "@") { // validating email addresses is hard - just make sure it contains an '@' sign // see https://stackoverflow.com/questions/201323/using-a-regular-expression-to-validate-an-email-address diff --git a/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.jsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.jsx.snap index e44cb8abdf809..ab26dc6f560f8 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.jsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.jsx.snap @@ -316,6 +316,10 @@ exports[`components/MessageExportSettings should match snapshot, disabled, globa "text": "A10/Type 10", "value": "A10", }, + Object { + "text": "Custom", + "value": "CUSTOM", + }, ] } /> @@ -757,6 +761,10 @@ exports[`components/MessageExportSettings should match snapshot, enabled, global "text": "A10/Type 10", "value": "A10", }, + Object { + "text": "Custom", + "value": "CUSTOM", + }, ] } /> diff --git a/webapp/channels/src/components/admin_console/message_export_settings.test.jsx b/webapp/channels/src/components/admin_console/message_export_settings.test.jsx index e842362cbe1d2..9902cdfe4ca23 100644 --- a/webapp/channels/src/components/admin_console/message_export_settings.test.jsx +++ b/webapp/channels/src/components/admin_console/message_export_settings.test.jsx @@ -82,6 +82,8 @@ describe('components/MessageExportSettings', () => { SMTPUsername: 'globalRelayUser', SMTPPassword: 'globalRelayPassword', EmailAddress: 'globalRelay@mattermost.com', + CustomSMTPServerName: '', + CustomSMTPPort: '25', }, }, }; @@ -126,6 +128,8 @@ describe('components/MessageExportSettings', () => { SMTPUsername: 'globalRelayUser', SMTPPassword: 'globalRelayPassword', EmailAddress: 'globalRelay@mattermost.com', + CustomSMTPServerName: '', + CustomSMTPPort: '25', }, }, }; diff --git a/webapp/channels/src/components/admin_console/message_export_settings.tsx b/webapp/channels/src/components/admin_console/message_export_settings.tsx index f8590a1fbb953..43b9b515acab0 100644 --- a/webapp/channels/src/components/admin_console/message_export_settings.tsx +++ b/webapp/channels/src/components/admin_console/message_export_settings.tsx @@ -32,6 +32,8 @@ interface State extends BaseState { globalRelaySMTPUsername: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['SMTPUsername']; globalRelaySMTPPassword: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['SMTPPassword']; globalRelayEmailAddress: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['EmailAddress']; + globalRelayCustomSMTPServerName: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['CustomSMTPServerName']; + globalRelayCustomSMTPPort: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['CustomSMTPPort']; globalRelaySMTPServerTimeout: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['SMTPServerTimeout']; } @@ -47,6 +49,8 @@ export default class MessageExportSettings extends AdminSettings ); + const globalRelaySMTPServerName = ( + + } + placeholder={Utils.localizeMessage('admin.complianceExport.globalRelayCustomSMTPServerName.example', 'E.g.: "feeds.globalrelay.com"')} + helpText={ + + } + value={this.state.globalRelayCustomSMTPServerName ? this.state.globalRelayCustomSMTPServerName : ''} + onChange={this.handleChange} + setByEnv={this.isSetByEnv('DataRetentionSettings.GlobalRelaySettings.CustomSMTPServerName')} + disabled={this.props.isDisabled || !this.state.enableComplianceExport} + /> + ); + + const globalRelaySMTPPort = ( + + } + placeholder={Utils.localizeMessage('admin.complianceExport.globalRelayCustomSMTPPort.example', 'E.g.: "25"')} + helpText={ + + } + value={this.state.globalRelayCustomSMTPPort ? this.state.globalRelayCustomSMTPPort : ''} + onChange={this.handleChange} + setByEnv={this.isSetByEnv('DataRetentionSettings.GlobalRelaySettings.CustomSMTPPort')} + disabled={this.props.isDisabled || !this.state.enableComplianceExport} + /> + ); + globalRelaySettings = ( {globalRelayCustomerType} {globalRelaySMTPUsername} {globalRelaySMTPPassword} {globalRelayEmail} + { + this.state.globalRelayCustomerType === 'CUSTOM' && + globalRelaySMTPServerName + } + { + this.state.globalRelayCustomerType === 'CUSTOM' && + globalRelaySMTPPort + } ); } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index b1cc1ea9342e7..d2a025db3fc63 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -607,8 +607,15 @@ "admin.complianceExport.exportJobStartTime.title": "Compliance Export time:", "admin.complianceExport.globalRelayCustomerType.a10.description": "A10/Type 10", "admin.complianceExport.globalRelayCustomerType.a9.description": "A9/Type 9", + "admin.complianceExport.globalRelayCustomerType.custom.description": "Custom", "admin.complianceExport.globalRelayCustomerType.description": "Type of Global Relay customer account your organization has.", "admin.complianceExport.globalRelayCustomerType.title": "Global Relay Customer Account:", + "admin.complianceExport.globalRelayCustomSMTPPort.description": "The SMTP server port that will receive your Global Relay EML.", + "admin.complianceExport.globalRelayCustomSMTPPort.example": "E.g.: \"25\"", + "admin.complianceExport.globalRelayCustomSMTPPort.title": "SMTP Server Port:", + "admin.complianceExport.globalRelayCustomSMTPServerName.description": "The SMTP server name that will receive your Global Relay EML.", + "admin.complianceExport.globalRelayCustomSMTPServerName.example": "E.g.: \"feeds.globalrelay.com\"", + "admin.complianceExport.globalRelayCustomSMTPServerName.title": "SMTP Server Name:", "admin.complianceExport.globalRelayEmailAddress.description": "The email address your Global Relay server monitors for incoming compliance exports.", "admin.complianceExport.globalRelayEmailAddress.example": "E.g.: \"globalrelay@mattermost.com\"", "admin.complianceExport.globalRelayEmailAddress.title": "Global Relay Email Address:", diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 83fb2923b0ede..e2bdb51996f9f 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -833,6 +833,8 @@ export type MessageExportSettings = { SMTPPassword: string; EmailAddress: string; SMTPServerTimeout: number; + CustomSMTPServerName: string; + CustomSMTPPort: string; }; }; From 7afc14de36307b4a3e19662dbba56539479a731b Mon Sep 17 00:00:00 2001 From: Syed Ali Abbas Zaidi <88369802+Syed-Ali-Abbas-Zaidi@users.noreply.github.com> Date: Fri, 8 Dec 2023 19:37:09 +0500 Subject: [PATCH 2/3] [MM-56005] Convert `./components/admin_console/permission_schemes_settings/permission_team_scheme_settings/team_in_list/team_in_list.tsx` from Class Component to Function Component (#25646) * [MM-56005] Convert `./components/admin_console/permission_schemes_settings/permission_team_scheme_settings/team_in_list/team_in_list.tsx` from Class Component to Function Component * refactor: use classNames to apply class --------- Co-authored-by: Mattermost Build --- .../team_in_list/team_in_list.tsx | 69 ++++++++++--------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_team_scheme_settings/team_in_list/team_in_list.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_team_scheme_settings/team_in_list/team_in_list.tsx index d845659d67779..58aa5855151f9 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_team_scheme_settings/team_in_list/team_in_list.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_team_scheme_settings/team_in_list/team_in_list.tsx @@ -1,7 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import classNames from 'classnames'; +import React, {memo, useCallback} from 'react'; import {FormattedMessage} from 'react-intl'; import type {Team} from '@mattermost/types/teams'; @@ -16,41 +17,43 @@ type Props = { isDisabled: boolean; } -export default class TeamInList extends React.PureComponent { - handleRemoveTeam = () => { - const {team, isDisabled, onRemoveTeam} = this.props; +const TeamInList = ({ + team, + isDisabled, + onRemoveTeam, +}: Props) => { + const handleRemoveTeam = useCallback(() => { if (isDisabled) { return; } onRemoveTeam(team.id); - }; - - render() { - const {team, isDisabled} = this.props; - return ( -
-
- -
-
{team.display_name}
-
+ }, [isDisabled, team.id, onRemoveTeam]); + + return ( +
+
+ +
+
{team.display_name}
- - -
- ); - } -} + + + +
+ ); +}; + +export default memo(TeamInList); From 109f4643c66766f5bbd5e81504fe5b43117164e0 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Fri, 8 Dec 2023 10:30:08 -0500 Subject: [PATCH 3/3] [MM-55017] Add API method to get users for Admin Reporting (#25499) * Add store method to get reporting data * Some store changes * Added app layer * Added API call, some miscellaneous fixes * Fix lint * Fix serialized check * Add API docs * Fix user store tests leaking users * Fix test * PR feedback * Add filtering for role/team/activated user, filter out bot users * Fix mock * Fix test * Oops * Switch to using struct filter * More PR feedback * Fix gen * Fix test * Fix API docs * Fix test * Fix possible SQL injection, some query optimization * Fix migrations * Oops * Add role to API * Fix check * Add Client4 API call for load testing * Fix test * Update server/channels/store/storetest/user_store.go Co-authored-by: Ibrahim Serdar Acikgoz * PR feedback --------- Co-authored-by: Mattermost Build Co-authored-by: Ibrahim Serdar Acikgoz --- api/v4/source/definitions.yaml | 34 + api/v4/source/users.yaml | 94 ++ server/channels/api4/user.go | 58 + server/channels/app/app_iface.go | 1 + .../app/opentracing/opentracing_layer.go | 22 + server/channels/app/user.go | 34 + server/channels/app/user_test.go | 93 ++ server/channels/db/migrations/migrations.list | 4 + .../000118_create_index_poststats.down.sql | 1 + .../000118_create_index_poststats.up.sql | 1 + .../000118_create_index_poststats.down.sql | 1 + .../000118_create_index_poststats.up.sql | 2 + .../opentracinglayer/opentracinglayer.go | 18 + .../channels/store/retrylayer/retrylayer.go | 21 + server/channels/store/sqlstore/user_store.go | 100 ++ server/channels/store/store.go | 1 + .../store/storetest/mocks/UserStore.go | 26 + server/channels/store/storetest/user_store.go | 298 ++++ .../channels/store/timerlayer/timerlayer.go | 16 + server/i18n/en.json | 24 + server/public/model/client4.go | 48 + server/public/model/user.go | 87 ++ server/public/model/user_serial_gen.go | 1372 +++++++++++++++++ webapp/platform/client/src/client4.ts | 11 +- webapp/platform/types/src/client4.ts | 20 + webapp/platform/types/src/users.ts | 13 + 26 files changed, 2399 insertions(+), 1 deletion(-) create mode 100644 server/channels/db/migrations/mysql/000118_create_index_poststats.down.sql create mode 100644 server/channels/db/migrations/mysql/000118_create_index_poststats.up.sql create mode 100644 server/channels/db/migrations/postgres/000118_create_index_poststats.down.sql create mode 100644 server/channels/db/migrations/postgres/000118_create_index_poststats.up.sql diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 5679a878e2cb6..5d780a5223ab1 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3524,6 +3524,40 @@ components: Description: description: A description for the CIDRBlock type: string + UserReport: + type: object + properties: + id: + type: string + create_at: + description: The time in milliseconds a user was created + type: integer + format: int64 + username: + type: string + email: + type: string + display_name: + type: string + description: Calculated display name based on user + last_login_at: + description: Last time the user was logged in + type: integer + format: int64 + last_status_at: + description: Last time the user's status was updated + type: integer + format: int64 + last_post_date: + description: Last time the user made a post within the given date range + type: integer + format: int64 + days_active: + description: Total number of days a user posted within the given date range + type: integer + total_posts: + description: Total number of posts made by a user within the given date range + type: integer Installation: type: object properties: diff --git a/api/v4/source/users.yaml b/api/v4/source/users.yaml index 44aaf4b72bb95..e2c5b803ad072 100644 --- a/api/v4/source/users.yaml +++ b/api/v4/source/users.yaml @@ -3251,3 +3251,97 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/users/report: + get: + tags: + - users + summary: Get a list of paged and sorted users for admin reporting purposes + description: > + Get a list of paged users for admin reporting purposes, based on provided parameters. + + Must be a system admin to invoke this API. + + ##### Permissions + + Requires `sysconsole_read_user_management_users`. + + operationId: GetUsersForReporting + parameters: + - name: sort_column + in: query + description: The column to sort the users by. Must be one of ("CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email") or the API will return an error. + schema: + type: string + default: 'Username' + - name: sort_direction + in: query + description: The sorting direction. Must be one of ("asc", "desc"). Will default to 'asc' if not specified or the input is invalid. + schema: + type: string + default: 'asc' + - name: page_size + in: query + description: The maximum number of users to return. + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 + - name: last_column_value + in: query + description: The value of the sorted column belonging to the last user returned in the page. Should be blank for the first page asked for. + schema: + type: string + - name: last_id + in: query + description: The value of the user id belonging to the last user returned in the page. Should be blank for the first page asked for. + schema: + type: string + - name: date_range + in: query + description: The date range of the post statistics to display. Must be one of ("last30days", "previousmonth", "last6months", "alltime"). Will default to 'alltime' if the input is not valid. + schema: + type: string + default: 'alltime' + - name: role_filter + in: query + description: Filter users by their role. + schema: + type: string + - name: team_filter + in: query + description: Filter users by a specified team ID. + schema: + type: string + - name: has_no_team + in: query + description: If true, show only users that have no team. Will ignore provided "team_filter" if true. + schema: + type: boolean + - name: hide_active + in: query + description: If true, show only users that are inactive. Cannot be used at the same time as "hide_inactive" + schema: + type: boolean + - name: hide_inactive + in: query + description: If true, show only users that are active. Cannot be used at the same time as "hide_active" + schema: + type: boolean + responses: + "200": + description: User page retrieval successful + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UserReport" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index a91f58f45e61c..8a9770a8efa7b 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -108,6 +108,8 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/notify-admin", api.APISessionRequired(handleNotifyAdmin)).Methods("POST") api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods("POST") + + api.BaseRoutes.Users.Handle("/report", api.APISessionRequired(getUsersForReporting)).Methods("GET") } func createUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -3443,3 +3445,59 @@ func getUsersWithInvalidEmails(c *Context, w http.ResponseWriter, r *http.Reques c.Logger.Warn("Error writing response", mlog.Err(err)) } } + +func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) { + if !(c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)) { + c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers) + return + } + + sortColumn := "Username" + if r.URL.Query().Get("sort_column") != "" { + sortColumn = r.URL.Query().Get("sort_column") + } + + pageSize := 50 + if pageSizeStr, err := strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 64); err == nil { + pageSize = int(pageSizeStr) + } + + teamFilter := r.URL.Query().Get("team_filter") + if !(teamFilter == "" || model.IsValidId(teamFilter)) { + c.Err = model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_team_filter", nil, "", http.StatusBadRequest) + return + } + + hideActive := r.URL.Query().Get("hide_active") == "true" + hideInactive := r.URL.Query().Get("hide_inactive") == "true" + if hideActive && hideInactive { + c.Err = model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_active_filter", nil, "", http.StatusBadRequest) + return + } + + options := &model.UserReportOptionsAPI{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: sortColumn, + SortDesc: r.URL.Query().Get("sort_direction") == "desc", + PageSize: pageSize, + Team: teamFilter, + LastSortColumnValue: r.URL.Query().Get("last_column_value"), + LastUserId: r.URL.Query().Get("last_id"), + Role: r.URL.Query().Get("role_filter"), + HasNoTeam: r.URL.Query().Get("has_no_team") == "true", + HideActive: hideActive, + HideInactive: hideInactive, + }, + DateRange: r.URL.Query().Get("date_range"), + } + + userReports, err := c.App.GetUsersForReporting(options.ToBaseOptions(time.Now())) + if err != nil { + c.Err = err + return + } + + if jsonErr := json.NewEncoder(w).Encode(userReports); jsonErr != nil { + c.Logger.Warn("Error writing response", mlog.Err(jsonErr)) + } +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 7f105f732255f..1b261582e280c 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -828,6 +828,7 @@ type AppIface interface { GetUsersByIds(userIDs []string, options *store.UserGetByIdsOpts) ([]*model.User, *model.AppError) GetUsersByUsernames(usernames []string, asAdmin bool, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError) GetUsersEtag(restrictionsHash string) string + GetUsersForReporting(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, *model.AppError) GetUsersInChannel(options *model.UserGetOptions) ([]*model.User, *model.AppError) GetUsersInChannelByAdmin(options *model.UserGetOptions) ([]*model.User, *model.AppError) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 44908ad5bb275..1dcec6f0e617b 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -10704,6 +10704,28 @@ func (a *OpenTracingAppLayer) GetUsersEtag(restrictionsHash string) string { return resultVar0 } +func (a *OpenTracingAppLayer) GetUsersForReporting(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUsersForReporting") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetUsersForReporting(filter) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUsersFromProfiles") diff --git a/server/channels/app/user.go b/server/channels/app/user.go index 336cda5987243..731c7e7864ac0 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -24,6 +24,7 @@ import ( "github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" + pUtils "github.com/mattermost/mattermost/server/public/utils" "github.com/mattermost/mattermost/server/v8/channels/app/email" "github.com/mattermost/mattermost/server/v8/channels/app/imaging" "github.com/mattermost/mattermost/server/v8/channels/app/users" @@ -2817,3 +2818,36 @@ func (a *App) UserIsFirstAdmin(user *model.User) bool { return true } + +func (a *App) GetUsersForReporting(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) { + // Don't allow fetching more than 100 users at a time from the normal query endpoint + if filter.PageSize <= 0 || filter.PageSize > 100 { + return nil, model.NewAppError("GetUsersForReporting", "app.user.get_users_for_reporting.invalid_page_size", nil, "", http.StatusBadRequest) + } + + // Validate date range + if filter.EndAt > 0 && filter.StartAt > filter.EndAt { + return nil, model.NewAppError("GetUsersForReporting", "app.user.get_users_for_reporting.bad_date_range", nil, "", http.StatusBadRequest) + } + + return a.getUserReport(filter) +} + +func (a *App) getUserReport(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) { + // Validate against the columns we allow sorting for + if !pUtils.Contains(model.UserReportSortColumns, filter.SortColumn) { + return nil, model.NewAppError("GetUsersForReporting", "app.user.get_user_report.invalid_sort_column", nil, "", http.StatusBadRequest) + } + + userReportQuery, err := a.Srv().Store().User().GetUserReport(filter) + if err != nil { + return nil, model.NewAppError("GetUsersForReporting", "app.user.get_user_report.store_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + userReports := make([]*model.UserReport, len(userReportQuery)) + for i, user := range userReportQuery { + userReports[i] = user.ToReport() + } + + return userReports, nil +} diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 453cd62a8eb47..7006d51c7289c 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -1932,3 +1932,96 @@ func TestSendSubscriptionHistoryEvent(t *testing.T) { require.Equal(t, 10, subscriptionHistoryEvent.Seats, "Number of seats doesn't match") }) } + +func TestGetUsersForReporting(t *testing.T) { + t.Run("should throw error on invalid page size", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 999, + }, + }) + require.Error(t, err) + require.Nil(t, userReports) + }) + + t.Run("should throw error on invalid date range", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + }, + StartAt: 1000, + EndAt: 500, + }) + require.Error(t, err) + require.Nil(t, userReports) + }) + + t.Run("should throw error on bad sort column", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "FakeColumn", + PageSize: 50, + }, + }) + require.Error(t, err) + require.Nil(t, userReports) + }) + + t.Run("should return some formatted reporting data", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + // Mock to get the user count + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockUserStore := storemocks.UserStore{} + mockUserStore.On("GetUserReport", + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + ).Return([]*model.UserReportQuery{ + { + User: model.User{ + Id: "some-id", + CreateAt: 1000, + FirstName: "Bob", + LastName: "Bobson", + }, + UserPostStats: model.UserPostStats{ + LastLogin: 1500, + }, + }, + }, nil) + + mockStore.On("User").Return(&mockUserStore) + + userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + }, + }) + require.Nil(t, err) + require.NotNil(t, userReports) + require.Equal(t, "Bob Bobson", userReports[0].DisplayName) + }) +} diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index bed6fe45e15f7..5d4755c80a0b0 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -232,6 +232,8 @@ channels/db/migrations/mysql/000116_create_outgoing_oauth_connections.down.sql channels/db/migrations/mysql/000116_create_outgoing_oauth_connections.up.sql channels/db/migrations/mysql/000117_msteams_shared_channels.down.sql channels/db/migrations/mysql/000117_msteams_shared_channels.up.sql +channels/db/migrations/mysql/000118_create_index_poststats.down.sql +channels/db/migrations/mysql/000118_create_index_poststats.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 @@ -464,3 +466,5 @@ channels/db/migrations/postgres/000116_create_outgoing_oauth_connections.down.sq channels/db/migrations/postgres/000116_create_outgoing_oauth_connections.up.sql channels/db/migrations/postgres/000117_msteams_shared_channels.down.sql channels/db/migrations/postgres/000117_msteams_shared_channels.up.sql +channels/db/migrations/postgres/000118_create_index_poststats.down.sql +channels/db/migrations/postgres/000118_create_index_poststats.up.sql diff --git a/server/channels/db/migrations/mysql/000118_create_index_poststats.down.sql b/server/channels/db/migrations/mysql/000118_create_index_poststats.down.sql new file mode 100644 index 0000000000000..9b2cb2bffef3a --- /dev/null +++ b/server/channels/db/migrations/mysql/000118_create_index_poststats.down.sql @@ -0,0 +1 @@ +-- Nothing to do for MySQL \ No newline at end of file diff --git a/server/channels/db/migrations/mysql/000118_create_index_poststats.up.sql b/server/channels/db/migrations/mysql/000118_create_index_poststats.up.sql new file mode 100644 index 0000000000000..9b2cb2bffef3a --- /dev/null +++ b/server/channels/db/migrations/mysql/000118_create_index_poststats.up.sql @@ -0,0 +1 @@ +-- Nothing to do for MySQL \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000118_create_index_poststats.down.sql b/server/channels/db/migrations/postgres/000118_create_index_poststats.down.sql new file mode 100644 index 0000000000000..588cb8034fc2b --- /dev/null +++ b/server/channels/db/migrations/postgres/000118_create_index_poststats.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_poststats_userid; \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000118_create_index_poststats.up.sql b/server/channels/db/migrations/postgres/000118_create_index_poststats.up.sql new file mode 100644 index 0000000000000..a223c0bf2ca44 --- /dev/null +++ b/server/channels/db/migrations/postgres/000118_create_index_poststats.up.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_poststats_userid ON poststats(userid) \ No newline at end of file diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 67f41ad6aca01..4d7f01142c881 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -11892,6 +11892,24 @@ func (s *OpenTracingLayerUserStore) GetUnreadCountForChannel(userID string, chan return result, err } +func (s *OpenTracingLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetUserReport") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.UserStore.GetUserReport(filter) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetUsersBatchForIndexing") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 6f04215221b84..e3cbf142d62e4 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -13577,6 +13577,27 @@ func (s *RetryLayerUserStore) GetUnreadCountForChannel(userID string, channelID } +func (s *RetryLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { + + tries := 0 + for { + result, err := s.UserStore.GetUserReport(filter) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { tries := 0 diff --git a/server/channels/store/sqlstore/user_store.go b/server/channels/store/sqlstore/user_store.go index 39bf31f0f0297..a5fedc08d61dc 100644 --- a/server/channels/store/sqlstore/user_store.go +++ b/server/channels/store/sqlstore/user_store.go @@ -10,6 +10,7 @@ import ( "fmt" "sort" "strings" + "time" "unicode/utf8" sq "github.com/mattermost/squirrel" @@ -2266,3 +2267,102 @@ func (us SqlUserStore) RefreshPostStatsForUsers() error { return nil } + +func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { + isPostgres := us.DriverName() == model.DatabaseDriverPostgres + selectColumns := []string{"u.Id", "u.LastLogin", "MAX(s.LastActivityAt) AS LastStatusAt"} + for _, column := range model.UserReportSortColumns { + selectColumns = append(selectColumns, "u."+column) + } + if isPostgres { + selectColumns = append(selectColumns, + "MAX(ps.LastPostDate) AS LastPostDate", + "COUNT(ps.Day) AS DaysActive", + "SUM(ps.NumPosts) AS TotalPosts", + ) + } else { + selectColumns = append(selectColumns, + "MAX(p.CreateAt) AS LastPostDate", + "COUNT(DATE(FROM_UNIXTIME(p.CreateAt / 1000))) AS DaysActive", + "COUNT(p.Id) AS TotalPosts", + ) + } + + sortColumnValue := filter.SortColumn + if filter.SortDesc { + sortColumnValue += " DESC" + } + + query := us.getQueryBuilder(). + Select(selectColumns...). + From("Users u"). + LeftJoin("Status s ON s.UserId = u.Id"). + Where(sq.Or{ + sq.Gt{filter.SortColumn: filter.LastSortColumnValue}, + sq.And{ + sq.Eq{filter.SortColumn: filter.LastSortColumnValue}, + sq.Gt{"u.Id": filter.LastUserId}, + }, + }). + Where(sq.Expr("u.Id NOT IN (SELECT UserId FROM Bots)")). + GroupBy("u.Id"). + OrderBy(sortColumnValue, "u.Id") + + if filter.PageSize > 0 { + query = query.Limit(uint64(filter.PageSize)) + } + + if isPostgres { + query = query.LeftJoin("PostStats ps ON ps.UserId = u.Id") + if filter.StartAt > 0 { + startDate := time.UnixMilli(filter.StartAt) + query = query.Where(sq.Or{ + sq.Expr("ps.UserId IS NULL"), + sq.GtOrEq{"ps.Day": startDate.Format("2006-01-02")}, + }) + } + if filter.EndAt > 0 { + endDate := time.UnixMilli(filter.EndAt) + query = query.Where(sq.Or{ + sq.Expr("ps.UserId IS NULL"), + sq.Lt{"ps.Day": endDate.Format("2006-01-02")}, + }) + } + } else { + query = query.LeftJoin("Posts p on p.UserId = u.Id") + if filter.StartAt > 0 { + query = query.Where(sq.Or{ + sq.Expr("p.UserId IS NULL"), + sq.GtOrEq{"p.CreateAt": filter.StartAt}, + }) + } + if filter.EndAt > 0 { + query = query.Where(sq.Or{ + sq.Expr("p.UserId IS NULL"), + sq.Lt{"p.CreateAt": filter.EndAt}, + }) + } + } + + query = applyRoleFilter(query, filter.Role, isPostgres) + if filter.HasNoTeam { + query = query.Where(sq.Expr("u.Id NOT IN (SELECT UserId FROM TeamMembers WHERE DeleteAt = 0)")) + } else if filter.Team != "" { + query = query.Join("TeamMembers tm ON (tm.UserId = u.Id AND tm.DeleteAt = 0)"). + Where(sq.Eq{"tm.TeamId": filter.Team}) + } + if filter.HideActive { + query = query.Where(sq.Gt{"u.DeleteAt": 0}) + } + if filter.HideInactive { + query = query.Where(sq.Eq{"u.DeleteAt": 0}) + } + + userResults := []*model.UserReportQuery{} + err := us.GetReplicaX().SelectBuilder(&userResults, query) + if err != nil { + return nil, errors.Wrap(err, "failed to get users for reporting") + } + + return userResults, nil +} diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 7048fac517f01..0a6c6fe42774a 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -493,6 +493,7 @@ type UserStore interface { GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error) InsertUsers(users []*model.User) error RefreshPostStatsForUsers() error + GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) } type BotStore interface { diff --git a/server/channels/store/storetest/mocks/UserStore.go b/server/channels/store/storetest/mocks/UserStore.go index ef706cc0c4230..7a957d712613c 100644 --- a/server/channels/store/storetest/mocks/UserStore.go +++ b/server/channels/store/storetest/mocks/UserStore.go @@ -1153,6 +1153,32 @@ func (_m *UserStore) GetUnreadCountForChannel(userID string, channelID string) ( return r0, r1 } +// GetUserReport provides a mock function with given fields: filter +func (_m *UserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { + ret := _m.Called(filter) + + var r0 []*model.UserReportQuery + var r1 error + if rf, ok := ret.Get(0).(func(*model.UserReportOptions) ([]*model.UserReportQuery, error)); ok { + return rf(filter) + } + if rf, ok := ret.Get(0).(func(*model.UserReportOptions) []*model.UserReportQuery); ok { + r0 = rf(filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.UserReportQuery) + } + } + + if rf, ok := ret.Get(1).(func(*model.UserReportOptions) error); ok { + r1 = rf(filter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetUsersBatchForIndexing provides a mock function with given fields: startTime, startFileID, limit func (_m *UserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { ret := _m.Called(startTime, startFileID, limit) diff --git a/server/channels/store/storetest/user_store.go b/server/channels/store/storetest/user_store.go index da4531fbfcc2b..6736c11365015 100644 --- a/server/channels/store/storetest/user_store.go +++ b/server/channels/store/storetest/user_store.go @@ -96,6 +96,7 @@ func TestUserStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, rctx, ss) }) t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(t, rctx, ss) }) t.Run("UpdateLastLogin", func(t *testing.T) { testUpdateLastLogin(t, rctx, ss) }) + t.Run("GetUserReport", func(t *testing.T) { testGetUserReport(t, rctx, ss) }) } func testUserStoreSave(t *testing.T, rctx request.CTX, ss store.Store) { @@ -4904,6 +4905,7 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, rctx request.CTX, ss st CreateAt: model.GetMillis(), }) require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(u1.Id)) }() time.Sleep(time.Millisecond) @@ -4913,6 +4915,7 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, rctx request.CTX, ss st CreateAt: model.GetMillis(), }) require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(u2.Id)) }() _, nErr = ss.Team().SaveMember(&model.TeamMember{ UserId: u2.Id, TeamId: t1.Id, @@ -4939,6 +4942,7 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, rctx request.CTX, ss st CreateAt: model.GetMillis(), }) require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(u3.Id)) }() _, nErr = ss.Team().SaveMember(&model.TeamMember{ UserId: u3.Id, TeamId: t1.Id, @@ -6182,3 +6186,297 @@ func testUpdateLastLogin(t *testing.T, rctx request.CTX, ss store.Store) { require.NoError(t, err) require.Equal(t, int64(1234567890), user.LastLogin) } + +func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store) { + now := time.Now() + + u1 := &model.User{Username: "u1" + model.NewId(), DeleteAt: 0} + u1.Email = MakeEmail() + u1, err := ss.User().Save(u1) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(u1.Id)) }() + + for i := 0; i < 5; i++ { + p := model.Post{UserId: u1.Id, ChannelId: model.NewId(), Message: NewTestId(), CreateAt: now.AddDate(0, 0, -i).UnixMilli()} + _, err = ss.Post().Save(&p) + require.NoError(t, err) + } + + u2 := &model.User{Username: "u2" + model.NewId(), Roles: "system", DeleteAt: 0} + u2.Email = MakeEmail() + u2, err = ss.User().Save(u2) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(u2.Id)) }() + + for i := 0; i < 5; i++ { + p := model.Post{UserId: u2.Id, ChannelId: model.NewId(), Message: NewTestId(), CreateAt: now.AddDate(0, 0, -i).UnixMilli()} + _, err = ss.Post().Save(&p) + require.NoError(t, err) + } + + u3 := &model.User{Username: "u3" + model.NewId(), Roles: "guest", DeleteAt: now.UnixMilli()} + u3.Email = MakeEmail() + u3, err = ss.User().Save(u3) + require.NoError(t, err) + + team, err := ss.Team().Save(&model.Team{ + DisplayName: "DisplayName", + Name: NewTestId(), + Email: MakeEmail(), + Type: model.TeamOpen, + }) + require.NoError(t, err) + _, err = ss.Team().SaveMember(&model.TeamMember{UserId: u3.Id, TeamId: team.Id}, 1) + require.NoError(t, err) + defer func() { + require.NoError(t, ss.Team().RemoveMember(rctx, team.Id, u3.Id)) + require.NoError(t, ss.Team().PermanentDelete(team.Id)) + }() + + defer func() { require.NoError(t, ss.User().PermanentDelete(u3.Id)) }() + + for i := 0; i < 5; i++ { + p := model.Post{UserId: u3.Id, ChannelId: model.NewId(), Message: NewTestId(), CreateAt: now.AddDate(0, 0, -i).UnixMilli()} + _, err = ss.Post().Save(&p) + require.NoError(t, err) + } + + err = ss.User().RefreshPostStatsForUsers() + require.NoError(t, err) + + t.Run("should return info for all the users", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + }, + }) + require.NoError(t, err) + require.NotNil(t, userReport) + require.Len(t, userReport, 3) + + require.NotNil(t, userReport[0]) + require.Equal(t, u1.Username, userReport[0].Username) + + require.NotNil(t, userReport[1]) + require.Equal(t, u2.Username, userReport[1].Username) + + require.NotNil(t, userReport[2]) + require.Equal(t, u3.Username, userReport[2].Username) + }) + + t.Run("should return in the correct order", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + SortDesc: true, + PageSize: 50, + }, + }) + require.NoError(t, err) + require.NotNil(t, userReport) + require.Equal(t, 3, len(userReport)) + + require.NotNil(t, userReport[0]) + require.Equal(t, u3.Username, userReport[0].Username) + + require.NotNil(t, userReport[1]) + require.Equal(t, u2.Username, userReport[1].Username) + + require.NotNil(t, userReport[2]) + require.Equal(t, u1.Username, userReport[2].Username) + }) + + t.Run("should fail on invalid sort column", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "FakeColumn", + SortDesc: true, + PageSize: 50, + }, + }) + require.Error(t, err) + require.Nil(t, userReport) + }) + + t.Run("should only return amount of users in page", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 2, + }, + }) + require.NoError(t, err) + require.NotNil(t, userReport) + require.Equal(t, 2, len(userReport)) + + require.NotNil(t, userReport[0]) + require.Equal(t, u1.Username, userReport[0].Username) + + require.NotNil(t, userReport[1]) + require.Equal(t, u2.Username, userReport[1].Username) + }) + + t.Run("should return correct paging", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + LastSortColumnValue: u2.Username, + LastUserId: u2.Id, + }, + }) + require.NoError(t, err) + require.NotNil(t, userReport) + require.Equal(t, 1, len(userReport)) + + require.NotNil(t, userReport[0]) + require.Equal(t, u3.Username, userReport[0].Username) + }) + + t.Run("should return accurate post stats for various date ranges", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + }, + }) + require.NoError(t, err) + require.Len(t, userReport, 3) + + require.Equal(t, 5, *userReport[0].TotalPosts) + require.Equal(t, 5, *userReport[0].DaysActive) + require.Equal(t, now.UnixMilli(), *userReport[0].LastPostDate) + require.Equal(t, 5, *userReport[1].TotalPosts) + require.Equal(t, 5, *userReport[1].DaysActive) + require.Equal(t, now.UnixMilli(), *userReport[1].LastPostDate) + require.Equal(t, 5, *userReport[2].TotalPosts) + require.Equal(t, 5, *userReport[2].DaysActive) + require.Equal(t, now.UnixMilli(), *userReport[2].LastPostDate) + + userReport, err = ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + }, + StartAt: now.AddDate(0, 0, -2).UnixMilli(), + }) + require.NoError(t, err) + require.Len(t, userReport, 3) + + require.Equal(t, 3, *userReport[0].TotalPosts) + require.Equal(t, 3, *userReport[0].DaysActive) + require.Equal(t, now.UnixMilli(), *userReport[0].LastPostDate) + require.Equal(t, 3, *userReport[1].TotalPosts) + require.Equal(t, 3, *userReport[1].DaysActive) + require.Equal(t, now.UnixMilli(), *userReport[1].LastPostDate) + require.Equal(t, 3, *userReport[2].TotalPosts) + require.Equal(t, 3, *userReport[2].DaysActive) + require.Equal(t, now.UnixMilli(), *userReport[2].LastPostDate) + + userReport, err = ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + }, + EndAt: now.AddDate(0, 0, -2).UnixMilli(), + }) + require.NoError(t, err) + require.Len(t, userReport, 3) + + require.Equal(t, 2, *userReport[0].TotalPosts) + require.Equal(t, 2, *userReport[0].DaysActive) + require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[0].LastPostDate) + require.Equal(t, 2, *userReport[1].TotalPosts) + require.Equal(t, 2, *userReport[1].DaysActive) + require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[1].LastPostDate) + require.Equal(t, 2, *userReport[2].TotalPosts) + require.Equal(t, 2, *userReport[2].DaysActive) + require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[2].LastPostDate) + + userReport, err = ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + }, + StartAt: now.AddDate(0, 0, -3).UnixMilli(), + EndAt: now.AddDate(0, 0, -2).UnixMilli(), + }) + require.NoError(t, err) + require.Len(t, userReport, 3) + + require.Equal(t, 1, *userReport[0].TotalPosts) + require.Equal(t, 1, *userReport[0].DaysActive) + require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[0].LastPostDate) + require.Equal(t, 1, *userReport[1].TotalPosts) + require.Equal(t, 1, *userReport[1].DaysActive) + require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[1].LastPostDate) + require.Equal(t, 1, *userReport[2].TotalPosts) + require.Equal(t, 1, *userReport[2].DaysActive) + require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[2].LastPostDate) + }) + + t.Run("should filter on roles", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + Role: "system", + }, + }) + require.NoError(t, err) + require.Len(t, userReport, 1) + require.Equal(t, u2.Id, userReport[0].Id) + require.Equal(t, u2.Roles, "system") + }) + + t.Run("should filter on teams", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + HasNoTeam: true, + }, + }) + require.NoError(t, err) + require.Len(t, userReport, 2) + require.Equal(t, u1.Id, userReport[0].Id) + require.Equal(t, u2.Id, userReport[1].Id) + + userReport, err = ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + Team: team.Id, + }, + }) + require.NoError(t, err) + require.Len(t, userReport, 1) + require.Equal(t, u3.Id, userReport[0].Id) + }) + + t.Run("should filter on activation", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + HideInactive: true, + }, + }) + require.NoError(t, err) + require.Len(t, userReport, 2) + require.Equal(t, u1.Id, userReport[0].Id) + require.Equal(t, u2.Id, userReport[1].Id) + + userReport, err = ss.User().GetUserReport(&model.UserReportOptions{ + UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{ + SortColumn: "Username", + PageSize: 50, + HideActive: true, + }, + }) + require.NoError(t, err) + require.Len(t, userReport, 1) + require.Equal(t, u3.Id, userReport[0].Id) + }) +} diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index f5d5211fd137f..fcab4b4d32467 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -10709,6 +10709,22 @@ func (s *TimerLayerUserStore) GetUnreadCountForChannel(userID string, channelID return result, err } +func (s *TimerLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { + start := time.Now() + + result, err := s.UserStore.GetUserReport(filter) + + 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.GetUserReport", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { start := time.Now() diff --git a/server/i18n/en.json b/server/i18n/en.json index 78cffd3694e18..a20573e58943c 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2016,6 +2016,14 @@ "id": "api.getThreadsForUser.bad_params", "translation": "Before and After parameters to getThreadsForUser are mutually exclusive" }, + { + "id": "api.getUsersForReporting.invalid_active_filter", + "translation": "Cannot hide both active and inactive users." + }, + { + "id": "api.getUsersForReporting.invalid_team_filter", + "translation": "Invalid team id provided." + }, { "id": "api.image.get.app_error", "translation": "Requested image url cannot be parsed." @@ -7054,10 +7062,26 @@ "id": "app.user.get_unread_count.app_error", "translation": "We could not get the unread message count for the user." }, + { + "id": "app.user.get_user_report.invalid_sort_column", + "translation": "Provided sort column is not valid." + }, + { + "id": "app.user.get_user_report.store_error", + "translation": "There was an error querying the user report." + }, { "id": "app.user.get_users_batch_for_indexing.get_users.app_error", "translation": "Unable to get the users batch for indexing." }, + { + "id": "app.user.get_users_for_reporting.bad_date_range", + "translation": "Date range provided is invalid." + }, + { + "id": "app.user.get_users_for_reporting.invalid_page_size", + "translation": "Page size is invalid or too large." + }, { "id": "app.user.missing_account.const", "translation": "Unable to find the user." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index ce06429f7f0c8..b43af1f13298e 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -1918,6 +1918,54 @@ func (c *Client4) EnableUserAccessToken(ctx context.Context, tokenId string) (*R return BuildResponse(r), nil } +func (c *Client4) GetUsersForReporting(ctx context.Context, options *UserReportOptionsAPI) ([]*UserReport, *Response, error) { + values := url.Values{} + if options.SortColumn != "" { + values.Set("sort_column", options.SortColumn) + } + if options.PageSize > 0 { + values.Set("page_size", strconv.Itoa(options.PageSize)) + } + if options.Team != "" { + values.Set("team_filter", options.Team) + } + if options.HideActive { + values.Set("hide_active", "true") + } + if options.HideInactive { + values.Set("hide_inactive", "true") + } + if options.SortDesc { + values.Set("sort_direction", "desc") + } + if options.LastSortColumnValue != "" { + values.Set("last_column_value", options.LastSortColumnValue) + } + if options.LastUserId != "" { + values.Set("last_id", options.LastUserId) + } + if options.Role != "" { + values.Set("role_filter", options.Role) + } + if options.HasNoTeam { + values.Set("has_no_team", "true") + } + if options.DateRange != "" { + values.Set("date_range", options.DateRange) + } + + r, err := c.DoAPIGet(ctx, c.usersRoute()+"/report?"+values.Encode(), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var list []*UserReport + if err := json.NewDecoder(r.Body).Decode(&list); err != nil { + return nil, nil, NewAppError("GetUsersForReporting", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return list, BuildResponse(r), nil +} + // Bots section // CreateBot creates a bot in the system based on the provided bot struct. diff --git a/server/public/model/user.go b/server/public/model/user.go index 9c936eac210df..956914abae9e1 100644 --- a/server/public/model/user.go +++ b/server/public/model/user.go @@ -48,6 +48,10 @@ const ( PushThreadsNotifyProp = "push_threads" EmailThreadsNotifyProp = "email_threads" + ReportDurationLast30Days = "last_30_days" + ReportDurationPreviousMonth = "previous_month" + ReportDurationLast6Months = "last_6_months" + DefaultLocale = "en" UserAuthServiceEmail = "email" @@ -67,6 +71,10 @@ const ( DesktopTokenTTL = time.Minute * 3 ) +var ( + UserReportSortColumns = []string{"CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email", "Roles"} +) + //msgp:tuple User // User contains the details about the user. @@ -1007,3 +1015,82 @@ type UsersWithGroupsAndCount struct { Users []*UserWithGroups `json:"users"` Count int64 `json:"total_count"` } + +type UserPostStats struct { + LastLogin int64 `json:"last_login_at,omitempty"` + LastStatusAt *int64 `json:"last_status_at,omitempty"` + LastPostDate *int64 `json:"last_post_date,omitempty"` + DaysActive *int `json:"days_active,omitempty"` + TotalPosts *int `json:"total_posts,omitempty"` +} + +type UserReportQuery struct { + User + UserPostStats +} + +type UserReport struct { + Id string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + CreateAt int64 `json:"create_at,omitempty"` + DisplayName string `json:"display_name"` + Roles string `json:"roles"` + UserPostStats +} + +type UserReportOptionsWithoutDateRange struct { + SortColumn string + SortDesc bool + PageSize int + LastSortColumnValue string + LastUserId string + Role string + Team string + HasNoTeam bool + HideActive bool + HideInactive bool +} + +type UserReportOptions struct { + UserReportOptionsWithoutDateRange + StartAt int64 + EndAt int64 +} + +type UserReportOptionsAPI struct { + UserReportOptionsWithoutDateRange + DateRange string +} + +func (u *UserReportOptionsAPI) ToBaseOptions(now time.Time) *UserReportOptions { + startAt := int64(0) + endAt := int64(0) + if u.DateRange == ReportDurationLast30Days { + startAt = now.AddDate(0, 0, -30).UnixMilli() + } else if u.DateRange == ReportDurationPreviousMonth { + startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) + startAt = startOfMonth.AddDate(0, -1, 0).UnixMilli() + endAt = startOfMonth.UnixMilli() + } else if u.DateRange == ReportDurationLast6Months { + startAt = now.AddDate(0, -6, -0).UnixMilli() + } + + return &UserReportOptions{ + UserReportOptionsWithoutDateRange: u.UserReportOptionsWithoutDateRange, + StartAt: startAt, + EndAt: endAt, + } +} + +func (u *UserReportQuery) ToReport() *UserReport { + return &UserReport{ + Id: u.Id, + Username: u.Username, + Email: u.Email, + CreateAt: u.CreateAt, + DisplayName: u.GetDisplayName(ShowNicknameFullName), + Roles: u.Roles, + UserPostStats: u.UserPostStats, + } +} diff --git a/server/public/model/user_serial_gen.go b/server/public/model/user_serial_gen.go index a43174925378b..4551ba60dc801 100644 --- a/server/public/model/user_serial_gen.go +++ b/server/public/model/user_serial_gen.go @@ -856,3 +856,1375 @@ func (z UserMap) Msgsize() (s int) { } return } + +// DecodeMsg implements msgp.Decodable +func (z *UserPostStats) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "LastLogin": + z.LastLogin, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "LastLogin") + return + } + case "LastStatusAt": + if dc.IsNil() { + err = dc.ReadNil() + if err != nil { + err = msgp.WrapError(err, "LastStatusAt") + return + } + z.LastStatusAt = nil + } else { + if z.LastStatusAt == nil { + z.LastStatusAt = new(int64) + } + *z.LastStatusAt, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "LastStatusAt") + return + } + } + case "LastPostDate": + if dc.IsNil() { + err = dc.ReadNil() + if err != nil { + err = msgp.WrapError(err, "LastPostDate") + return + } + z.LastPostDate = nil + } else { + if z.LastPostDate == nil { + z.LastPostDate = new(int64) + } + *z.LastPostDate, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "LastPostDate") + return + } + } + case "DaysActive": + if dc.IsNil() { + err = dc.ReadNil() + if err != nil { + err = msgp.WrapError(err, "DaysActive") + return + } + z.DaysActive = nil + } else { + if z.DaysActive == nil { + z.DaysActive = new(int) + } + *z.DaysActive, err = dc.ReadInt() + if err != nil { + err = msgp.WrapError(err, "DaysActive") + return + } + } + case "TotalPosts": + if dc.IsNil() { + err = dc.ReadNil() + if err != nil { + err = msgp.WrapError(err, "TotalPosts") + return + } + z.TotalPosts = nil + } else { + if z.TotalPosts == nil { + z.TotalPosts = new(int) + } + *z.TotalPosts, err = dc.ReadInt() + if err != nil { + err = msgp.WrapError(err, "TotalPosts") + return + } + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *UserPostStats) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 5 + // write "LastLogin" + err = en.Append(0x85, 0xa9, 0x4c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e) + if err != nil { + return + } + err = en.WriteInt64(z.LastLogin) + if err != nil { + err = msgp.WrapError(err, "LastLogin") + return + } + // write "LastStatusAt" + err = en.Append(0xac, 0x4c, 0x61, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x41, 0x74) + if err != nil { + return + } + if z.LastStatusAt == nil { + err = en.WriteNil() + if err != nil { + return + } + } else { + err = en.WriteInt64(*z.LastStatusAt) + if err != nil { + err = msgp.WrapError(err, "LastStatusAt") + return + } + } + // write "LastPostDate" + err = en.Append(0xac, 0x4c, 0x61, 0x73, 0x74, 0x50, 0x6f, 0x73, 0x74, 0x44, 0x61, 0x74, 0x65) + if err != nil { + return + } + if z.LastPostDate == nil { + err = en.WriteNil() + if err != nil { + return + } + } else { + err = en.WriteInt64(*z.LastPostDate) + if err != nil { + err = msgp.WrapError(err, "LastPostDate") + return + } + } + // write "DaysActive" + err = en.Append(0xaa, 0x44, 0x61, 0x79, 0x73, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65) + if err != nil { + return + } + if z.DaysActive == nil { + err = en.WriteNil() + if err != nil { + return + } + } else { + err = en.WriteInt(*z.DaysActive) + if err != nil { + err = msgp.WrapError(err, "DaysActive") + return + } + } + // write "TotalPosts" + err = en.Append(0xaa, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x50, 0x6f, 0x73, 0x74, 0x73) + if err != nil { + return + } + if z.TotalPosts == nil { + err = en.WriteNil() + if err != nil { + return + } + } else { + err = en.WriteInt(*z.TotalPosts) + if err != nil { + err = msgp.WrapError(err, "TotalPosts") + return + } + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *UserPostStats) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 5 + // string "LastLogin" + o = append(o, 0x85, 0xa9, 0x4c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e) + o = msgp.AppendInt64(o, z.LastLogin) + // string "LastStatusAt" + o = append(o, 0xac, 0x4c, 0x61, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x41, 0x74) + if z.LastStatusAt == nil { + o = msgp.AppendNil(o) + } else { + o = msgp.AppendInt64(o, *z.LastStatusAt) + } + // string "LastPostDate" + o = append(o, 0xac, 0x4c, 0x61, 0x73, 0x74, 0x50, 0x6f, 0x73, 0x74, 0x44, 0x61, 0x74, 0x65) + if z.LastPostDate == nil { + o = msgp.AppendNil(o) + } else { + o = msgp.AppendInt64(o, *z.LastPostDate) + } + // string "DaysActive" + o = append(o, 0xaa, 0x44, 0x61, 0x79, 0x73, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65) + if z.DaysActive == nil { + o = msgp.AppendNil(o) + } else { + o = msgp.AppendInt(o, *z.DaysActive) + } + // string "TotalPosts" + o = append(o, 0xaa, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x50, 0x6f, 0x73, 0x74, 0x73) + if z.TotalPosts == nil { + o = msgp.AppendNil(o) + } else { + o = msgp.AppendInt(o, *z.TotalPosts) + } + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *UserPostStats) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "LastLogin": + z.LastLogin, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "LastLogin") + return + } + case "LastStatusAt": + if msgp.IsNil(bts) { + bts, err = msgp.ReadNilBytes(bts) + if err != nil { + return + } + z.LastStatusAt = nil + } else { + if z.LastStatusAt == nil { + z.LastStatusAt = new(int64) + } + *z.LastStatusAt, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "LastStatusAt") + return + } + } + case "LastPostDate": + if msgp.IsNil(bts) { + bts, err = msgp.ReadNilBytes(bts) + if err != nil { + return + } + z.LastPostDate = nil + } else { + if z.LastPostDate == nil { + z.LastPostDate = new(int64) + } + *z.LastPostDate, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "LastPostDate") + return + } + } + case "DaysActive": + if msgp.IsNil(bts) { + bts, err = msgp.ReadNilBytes(bts) + if err != nil { + return + } + z.DaysActive = nil + } else { + if z.DaysActive == nil { + z.DaysActive = new(int) + } + *z.DaysActive, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "DaysActive") + return + } + } + case "TotalPosts": + if msgp.IsNil(bts) { + bts, err = msgp.ReadNilBytes(bts) + if err != nil { + return + } + z.TotalPosts = nil + } else { + if z.TotalPosts == nil { + z.TotalPosts = new(int) + } + *z.TotalPosts, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "TotalPosts") + return + } + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *UserPostStats) Msgsize() (s int) { + s = 1 + 10 + msgp.Int64Size + 13 + if z.LastStatusAt == nil { + s += msgp.NilSize + } else { + s += msgp.Int64Size + } + s += 13 + if z.LastPostDate == nil { + s += msgp.NilSize + } else { + s += msgp.Int64Size + } + s += 11 + if z.DaysActive == nil { + s += msgp.NilSize + } else { + s += msgp.IntSize + } + s += 11 + if z.TotalPosts == nil { + s += msgp.NilSize + } else { + s += msgp.IntSize + } + return +} + +// DecodeMsg implements msgp.Decodable +func (z *UserReport) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "Id": + z.Id, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Id") + return + } + case "Username": + z.Username, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Username") + return + } + case "Email": + z.Email, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Email") + return + } + case "CreateAt": + z.CreateAt, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "CreateAt") + return + } + case "DisplayName": + z.DisplayName, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "DisplayName") + return + } + case "Roles": + z.Roles, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Roles") + return + } + case "UserPostStats": + err = z.UserPostStats.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *UserReport) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 7 + // write "Id" + err = en.Append(0x87, 0xa2, 0x49, 0x64) + if err != nil { + return + } + err = en.WriteString(z.Id) + if err != nil { + err = msgp.WrapError(err, "Id") + return + } + // write "Username" + err = en.Append(0xa8, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65) + if err != nil { + return + } + err = en.WriteString(z.Username) + if err != nil { + err = msgp.WrapError(err, "Username") + return + } + // write "Email" + err = en.Append(0xa5, 0x45, 0x6d, 0x61, 0x69, 0x6c) + if err != nil { + return + } + err = en.WriteString(z.Email) + if err != nil { + err = msgp.WrapError(err, "Email") + return + } + // write "CreateAt" + err = en.Append(0xa8, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x74) + if err != nil { + return + } + err = en.WriteInt64(z.CreateAt) + if err != nil { + err = msgp.WrapError(err, "CreateAt") + return + } + // write "DisplayName" + err = en.Append(0xab, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65) + if err != nil { + return + } + err = en.WriteString(z.DisplayName) + if err != nil { + err = msgp.WrapError(err, "DisplayName") + return + } + // write "Roles" + err = en.Append(0xa5, 0x52, 0x6f, 0x6c, 0x65, 0x73) + if err != nil { + return + } + err = en.WriteString(z.Roles) + if err != nil { + err = msgp.WrapError(err, "Roles") + return + } + // write "UserPostStats" + err = en.Append(0xad, 0x55, 0x73, 0x65, 0x72, 0x50, 0x6f, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73) + if err != nil { + return + } + err = z.UserPostStats.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *UserReport) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 7 + // string "Id" + o = append(o, 0x87, 0xa2, 0x49, 0x64) + o = msgp.AppendString(o, z.Id) + // string "Username" + o = append(o, 0xa8, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65) + o = msgp.AppendString(o, z.Username) + // string "Email" + o = append(o, 0xa5, 0x45, 0x6d, 0x61, 0x69, 0x6c) + o = msgp.AppendString(o, z.Email) + // string "CreateAt" + o = append(o, 0xa8, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x74) + o = msgp.AppendInt64(o, z.CreateAt) + // string "DisplayName" + o = append(o, 0xab, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65) + o = msgp.AppendString(o, z.DisplayName) + // string "Roles" + o = append(o, 0xa5, 0x52, 0x6f, 0x6c, 0x65, 0x73) + o = msgp.AppendString(o, z.Roles) + // string "UserPostStats" + o = append(o, 0xad, 0x55, 0x73, 0x65, 0x72, 0x50, 0x6f, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73) + o, err = z.UserPostStats.MarshalMsg(o) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *UserReport) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "Id": + z.Id, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Id") + return + } + case "Username": + z.Username, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Username") + return + } + case "Email": + z.Email, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Email") + return + } + case "CreateAt": + z.CreateAt, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "CreateAt") + return + } + case "DisplayName": + z.DisplayName, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "DisplayName") + return + } + case "Roles": + z.Roles, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Roles") + return + } + case "UserPostStats": + bts, err = z.UserPostStats.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *UserReport) Msgsize() (s int) { + s = 1 + 3 + msgp.StringPrefixSize + len(z.Id) + 9 + msgp.StringPrefixSize + len(z.Username) + 6 + msgp.StringPrefixSize + len(z.Email) + 9 + msgp.Int64Size + 12 + msgp.StringPrefixSize + len(z.DisplayName) + 6 + msgp.StringPrefixSize + len(z.Roles) + 14 + z.UserPostStats.Msgsize() + return +} + +// DecodeMsg implements msgp.Decodable +func (z *UserReportOptions) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "UserReportOptionsWithoutDateRange": + err = z.UserReportOptionsWithoutDateRange.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + case "StartAt": + z.StartAt, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "StartAt") + return + } + case "EndAt": + z.EndAt, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "EndAt") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *UserReportOptions) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 3 + // write "UserReportOptionsWithoutDateRange" + err = en.Append(0x83, 0xd9, 0x21, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x44, 0x61, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65) + if err != nil { + return + } + err = z.UserReportOptionsWithoutDateRange.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + // write "StartAt" + err = en.Append(0xa7, 0x53, 0x74, 0x61, 0x72, 0x74, 0x41, 0x74) + if err != nil { + return + } + err = en.WriteInt64(z.StartAt) + if err != nil { + err = msgp.WrapError(err, "StartAt") + return + } + // write "EndAt" + err = en.Append(0xa5, 0x45, 0x6e, 0x64, 0x41, 0x74) + if err != nil { + return + } + err = en.WriteInt64(z.EndAt) + if err != nil { + err = msgp.WrapError(err, "EndAt") + return + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *UserReportOptions) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 3 + // string "UserReportOptionsWithoutDateRange" + o = append(o, 0x83, 0xd9, 0x21, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x44, 0x61, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65) + o, err = z.UserReportOptionsWithoutDateRange.MarshalMsg(o) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + // string "StartAt" + o = append(o, 0xa7, 0x53, 0x74, 0x61, 0x72, 0x74, 0x41, 0x74) + o = msgp.AppendInt64(o, z.StartAt) + // string "EndAt" + o = append(o, 0xa5, 0x45, 0x6e, 0x64, 0x41, 0x74) + o = msgp.AppendInt64(o, z.EndAt) + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *UserReportOptions) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "UserReportOptionsWithoutDateRange": + bts, err = z.UserReportOptionsWithoutDateRange.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + case "StartAt": + z.StartAt, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "StartAt") + return + } + case "EndAt": + z.EndAt, bts, err = msgp.ReadInt64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "EndAt") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *UserReportOptions) Msgsize() (s int) { + s = 1 + 35 + z.UserReportOptionsWithoutDateRange.Msgsize() + 8 + msgp.Int64Size + 6 + msgp.Int64Size + return +} + +// DecodeMsg implements msgp.Decodable +func (z *UserReportOptionsAPI) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "UserReportOptionsWithoutDateRange": + err = z.UserReportOptionsWithoutDateRange.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + case "DateRange": + z.DateRange, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "DateRange") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *UserReportOptionsAPI) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 2 + // write "UserReportOptionsWithoutDateRange" + err = en.Append(0x82, 0xd9, 0x21, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x44, 0x61, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65) + if err != nil { + return + } + err = z.UserReportOptionsWithoutDateRange.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + // write "DateRange" + err = en.Append(0xa9, 0x44, 0x61, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65) + if err != nil { + return + } + err = en.WriteString(z.DateRange) + if err != nil { + err = msgp.WrapError(err, "DateRange") + return + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *UserReportOptionsAPI) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 2 + // string "UserReportOptionsWithoutDateRange" + o = append(o, 0x82, 0xd9, 0x21, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x44, 0x61, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65) + o, err = z.UserReportOptionsWithoutDateRange.MarshalMsg(o) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + // string "DateRange" + o = append(o, 0xa9, 0x44, 0x61, 0x74, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65) + o = msgp.AppendString(o, z.DateRange) + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *UserReportOptionsAPI) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "UserReportOptionsWithoutDateRange": + bts, err = z.UserReportOptionsWithoutDateRange.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "UserReportOptionsWithoutDateRange") + return + } + case "DateRange": + z.DateRange, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "DateRange") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *UserReportOptionsAPI) Msgsize() (s int) { + s = 1 + 35 + z.UserReportOptionsWithoutDateRange.Msgsize() + 10 + msgp.StringPrefixSize + len(z.DateRange) + return +} + +// DecodeMsg implements msgp.Decodable +func (z *UserReportOptionsWithoutDateRange) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "SortColumn": + z.SortColumn, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "SortColumn") + return + } + case "SortDesc": + z.SortDesc, err = dc.ReadBool() + if err != nil { + err = msgp.WrapError(err, "SortDesc") + return + } + case "PageSize": + z.PageSize, err = dc.ReadInt() + if err != nil { + err = msgp.WrapError(err, "PageSize") + return + } + case "LastSortColumnValue": + z.LastSortColumnValue, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "LastSortColumnValue") + return + } + case "LastUserId": + z.LastUserId, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "LastUserId") + return + } + case "Role": + z.Role, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Role") + return + } + case "Team": + z.Team, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Team") + return + } + case "HasNoTeam": + z.HasNoTeam, err = dc.ReadBool() + if err != nil { + err = msgp.WrapError(err, "HasNoTeam") + return + } + case "HideActive": + z.HideActive, err = dc.ReadBool() + if err != nil { + err = msgp.WrapError(err, "HideActive") + return + } + case "HideInactive": + z.HideInactive, err = dc.ReadBool() + if err != nil { + err = msgp.WrapError(err, "HideInactive") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *UserReportOptionsWithoutDateRange) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 10 + // write "SortColumn" + err = en.Append(0x8a, 0xaa, 0x53, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e) + if err != nil { + return + } + err = en.WriteString(z.SortColumn) + if err != nil { + err = msgp.WrapError(err, "SortColumn") + return + } + // write "SortDesc" + err = en.Append(0xa8, 0x53, 0x6f, 0x72, 0x74, 0x44, 0x65, 0x73, 0x63) + if err != nil { + return + } + err = en.WriteBool(z.SortDesc) + if err != nil { + err = msgp.WrapError(err, "SortDesc") + return + } + // write "PageSize" + err = en.Append(0xa8, 0x50, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65) + if err != nil { + return + } + err = en.WriteInt(z.PageSize) + if err != nil { + err = msgp.WrapError(err, "PageSize") + return + } + // write "LastSortColumnValue" + err = en.Append(0xb3, 0x4c, 0x61, 0x73, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65) + if err != nil { + return + } + err = en.WriteString(z.LastSortColumnValue) + if err != nil { + err = msgp.WrapError(err, "LastSortColumnValue") + return + } + // write "LastUserId" + err = en.Append(0xaa, 0x4c, 0x61, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64) + if err != nil { + return + } + err = en.WriteString(z.LastUserId) + if err != nil { + err = msgp.WrapError(err, "LastUserId") + return + } + // write "Role" + err = en.Append(0xa4, 0x52, 0x6f, 0x6c, 0x65) + if err != nil { + return + } + err = en.WriteString(z.Role) + if err != nil { + err = msgp.WrapError(err, "Role") + return + } + // write "Team" + err = en.Append(0xa4, 0x54, 0x65, 0x61, 0x6d) + if err != nil { + return + } + err = en.WriteString(z.Team) + if err != nil { + err = msgp.WrapError(err, "Team") + return + } + // write "HasNoTeam" + err = en.Append(0xa9, 0x48, 0x61, 0x73, 0x4e, 0x6f, 0x54, 0x65, 0x61, 0x6d) + if err != nil { + return + } + err = en.WriteBool(z.HasNoTeam) + if err != nil { + err = msgp.WrapError(err, "HasNoTeam") + return + } + // write "HideActive" + err = en.Append(0xaa, 0x48, 0x69, 0x64, 0x65, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65) + if err != nil { + return + } + err = en.WriteBool(z.HideActive) + if err != nil { + err = msgp.WrapError(err, "HideActive") + return + } + // write "HideInactive" + err = en.Append(0xac, 0x48, 0x69, 0x64, 0x65, 0x49, 0x6e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65) + if err != nil { + return + } + err = en.WriteBool(z.HideInactive) + if err != nil { + err = msgp.WrapError(err, "HideInactive") + return + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *UserReportOptionsWithoutDateRange) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 10 + // string "SortColumn" + o = append(o, 0x8a, 0xaa, 0x53, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e) + o = msgp.AppendString(o, z.SortColumn) + // string "SortDesc" + o = append(o, 0xa8, 0x53, 0x6f, 0x72, 0x74, 0x44, 0x65, 0x73, 0x63) + o = msgp.AppendBool(o, z.SortDesc) + // string "PageSize" + o = append(o, 0xa8, 0x50, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65) + o = msgp.AppendInt(o, z.PageSize) + // string "LastSortColumnValue" + o = append(o, 0xb3, 0x4c, 0x61, 0x73, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65) + o = msgp.AppendString(o, z.LastSortColumnValue) + // string "LastUserId" + o = append(o, 0xaa, 0x4c, 0x61, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64) + o = msgp.AppendString(o, z.LastUserId) + // string "Role" + o = append(o, 0xa4, 0x52, 0x6f, 0x6c, 0x65) + o = msgp.AppendString(o, z.Role) + // string "Team" + o = append(o, 0xa4, 0x54, 0x65, 0x61, 0x6d) + o = msgp.AppendString(o, z.Team) + // string "HasNoTeam" + o = append(o, 0xa9, 0x48, 0x61, 0x73, 0x4e, 0x6f, 0x54, 0x65, 0x61, 0x6d) + o = msgp.AppendBool(o, z.HasNoTeam) + // string "HideActive" + o = append(o, 0xaa, 0x48, 0x69, 0x64, 0x65, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65) + o = msgp.AppendBool(o, z.HideActive) + // string "HideInactive" + o = append(o, 0xac, 0x48, 0x69, 0x64, 0x65, 0x49, 0x6e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65) + o = msgp.AppendBool(o, z.HideInactive) + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *UserReportOptionsWithoutDateRange) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "SortColumn": + z.SortColumn, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "SortColumn") + return + } + case "SortDesc": + z.SortDesc, bts, err = msgp.ReadBoolBytes(bts) + if err != nil { + err = msgp.WrapError(err, "SortDesc") + return + } + case "PageSize": + z.PageSize, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "PageSize") + return + } + case "LastSortColumnValue": + z.LastSortColumnValue, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "LastSortColumnValue") + return + } + case "LastUserId": + z.LastUserId, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "LastUserId") + return + } + case "Role": + z.Role, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Role") + return + } + case "Team": + z.Team, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Team") + return + } + case "HasNoTeam": + z.HasNoTeam, bts, err = msgp.ReadBoolBytes(bts) + if err != nil { + err = msgp.WrapError(err, "HasNoTeam") + return + } + case "HideActive": + z.HideActive, bts, err = msgp.ReadBoolBytes(bts) + if err != nil { + err = msgp.WrapError(err, "HideActive") + return + } + case "HideInactive": + z.HideInactive, bts, err = msgp.ReadBoolBytes(bts) + if err != nil { + err = msgp.WrapError(err, "HideInactive") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *UserReportOptionsWithoutDateRange) Msgsize() (s int) { + s = 1 + 11 + msgp.StringPrefixSize + len(z.SortColumn) + 9 + msgp.BoolSize + 9 + msgp.IntSize + 20 + msgp.StringPrefixSize + len(z.LastSortColumnValue) + 11 + msgp.StringPrefixSize + len(z.LastUserId) + 5 + msgp.StringPrefixSize + len(z.Role) + 5 + msgp.StringPrefixSize + len(z.Team) + 10 + msgp.BoolSize + 11 + msgp.BoolSize + 13 + msgp.BoolSize + return +} + +// DecodeMsg implements msgp.Decodable +func (z *UserReportQuery) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "User": + err = z.User.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "User") + return + } + case "UserPostStats": + err = z.UserPostStats.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *UserReportQuery) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 2 + // write "User" + err = en.Append(0x82, 0xa4, 0x55, 0x73, 0x65, 0x72) + if err != nil { + return + } + err = z.User.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "User") + return + } + // write "UserPostStats" + err = en.Append(0xad, 0x55, 0x73, 0x65, 0x72, 0x50, 0x6f, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73) + if err != nil { + return + } + err = z.UserPostStats.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + return +} + +// MarshalMsg implements msgp.Marshaler +func (z *UserReportQuery) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 2 + // string "User" + o = append(o, 0x82, 0xa4, 0x55, 0x73, 0x65, 0x72) + o, err = z.User.MarshalMsg(o) + if err != nil { + err = msgp.WrapError(err, "User") + return + } + // string "UserPostStats" + o = append(o, 0xad, 0x55, 0x73, 0x65, 0x72, 0x50, 0x6f, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73) + o, err = z.UserPostStats.MarshalMsg(o) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *UserReportQuery) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "User": + bts, err = z.User.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "User") + return + } + case "UserPostStats": + bts, err = z.UserPostStats.UnmarshalMsg(bts) + if err != nil { + err = msgp.WrapError(err, "UserPostStats") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *UserReportQuery) Msgsize() (s int) { + s = 1 + 5 + z.User.Msgsize() + 14 + z.UserPostStats.Msgsize() + return +} diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 05f1c31ec308a..0465fa706eece 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -52,7 +52,7 @@ import { ChannelSearchOpts, ServerChannel, } from '@mattermost/types/channels'; -import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions} from '@mattermost/types/client4'; +import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions, UserReportOptions} from '@mattermost/types/client4'; import {Compliance} from '@mattermost/types/compliance'; import { ClientConfig, @@ -133,6 +133,7 @@ import { UserStatus, GetFilteredUsersStatsOpts, UserCustomStatus, + UserReport, } from '@mattermost/types/users'; import {DeepPartial, RelationOneToOne} from '@mattermost/types/utilities'; import {ProductNotices} from '@mattermost/types/product_notices'; @@ -982,6 +983,14 @@ export default class Client4 { ); }; + getUsersForReporting = (filter: UserReportOptions) => { + const queryString = buildQueryString(filter); + return this.doFetch( + `${this.getUsersRoute()}/report${queryString}`, + {method: 'get'}, + ); + } + /** * @deprecated */ diff --git a/webapp/platform/types/src/client4.ts b/webapp/platform/types/src/client4.ts index 150d9cf0c9d51..a61117bf76310 100644 --- a/webapp/platform/types/src/client4.ts +++ b/webapp/platform/types/src/client4.ts @@ -37,3 +37,23 @@ export type FetchPaginatedThreadOptions = { fromCreateAt?: number; fromPost?: string; } + +export enum ReportDuration { + Last30Days = 'last_30_days', + PreviousMonth = 'previous_month', + Last6Months = 'last_6_months', +} + +export type UserReportOptions = { + sort_column: 'CreateAt' | 'Username' | 'FirstName' | 'LastName' | 'Nickname' | 'Email', + page_size: number, + sort_direction?: 'asc' | 'desc', + date_range?: ReportDuration, + last_column_value?: string, + last_id?: string, + role_filter?: string, + has_no_team?: boolean, + team_filter?: string, + hide_active?: boolean, + hide_inactive?: boolean, +} diff --git a/webapp/platform/types/src/users.ts b/webapp/platform/types/src/users.ts index 9c291130da86b..e122e2f831e9e 100644 --- a/webapp/platform/types/src/users.ts +++ b/webapp/platform/types/src/users.ts @@ -143,3 +143,16 @@ export type GetFilteredUsersStatsOpts = { export type AuthChangeResponse = { follow_link: string; }; + +export type UserReport = { + id: string; + username: string; + email: string; + create_at: number; + display_name: string; + last_login_at: number; + last_status_at?: number; + last_post_date?: number; + days_active?: number; + total_posts?: number; +}