mattermost/server/public/model/report.go
Maria A Nunez 2efee7ec28
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Add single-channel guests filter and channel count column to System Console Users (#35517)
* Add single-channel guests filter and channel count column to System Console Users

- Add guest_filter query parameter to Reports API with store-level
  filtering by guest channel membership count (all, single_channel,
  multi_channel)
- Add channel_count field to user report responses and CSV exports
- Add grouped guest role filter options in the filter popover
- Add toggleable Channel count column to the users table
- Add GuestFilter and SearchTerm to Go client GetUsersForReporting
- Add tests: API parsing, API integration, app job dedup, webapp utils,
  E2E column data rendering

Made-with: Cursor

* Fix gofmt alignment and isolate guest store tests

- Align GuestFilter constants to satisfy gofmt
- Move guest user/channel setup into a nested sub-test to avoid
  breaking existing ordering and role filter assertions

Made-with: Cursor

* Exclude archived channels from guest filter queries and ChannelCount

The ChannelMembers subqueries for guest_filter (single/multi channel)
and the ChannelCount column did not join with Channels to check
DeleteAt = 0. Since channel archival soft-deletes (sets DeleteAt) but
leaves ChannelMembers rows intact, archived channel memberships were
incorrectly counted, potentially misclassifying guests between
single-channel and multi-channel filters and inflating ChannelCount.

- Join ChannelMembers with Channels (DeleteAt = 0) in all three
  subqueries in applyUserReportFilter and GetUserReport
- Add store test covering archived channel exclusion
- Tighten existing guest filter test assertions with found-flags
  and exact count checks

Made-with: Cursor

* Exclude DM/GM from guest channel counts, validate GuestFilter, fix dropdown divider

- Scope ChannelCount and guest filter subqueries to Open/Private channel
  types only (exclude DM and GM), so a guest with one team channel plus
  a DM is correctly classified as single-channel
- Add GuestFilter validation in UserReportOptions.IsValid with
  AllowedGuestFilters whitelist
- Add API test for invalid guest_filter rejection (400)
- Add store regression test for DM/GM exclusion
- Fix role filter dropdown: hide the divider above the first group
  heading via CSS rule on DropDown__group:first-child
- Update E2E test label to match "Guests in a single channel" wording

Made-with: Cursor

* Add store test coverage for private and GM channel types

Private channels (type P) should be counted in ChannelCount and guest
filters, while GM channels (type G) should not. Add a test that creates
a guest with memberships in an open channel, a private channel, and a
GM, then asserts ChannelCount = 2, multi-channel filter includes the
guest, and single-channel filter excludes them.

Made-with: Cursor

* Add server i18n translation for invalid_guest_filter error

The new error ID model.user_report_options.is_valid.invalid_guest_filter
was missing from server/i18n/en.json, causing CI to fail.

Made-with: Cursor

* Make filter dropdown dividers full width

Remove the horizontal inset from grouped dropdown separators so the
system user role filter dividers span edge to edge across the menu.
Leave the unrelated webapp/package-lock.json change uncommitted.

Made-with: Cursor

* Optimize guest channel report filters.

Use per-user channel count subqueries for the single- and multi-channel guest filters so the report avoids aggregating all channel memberships before filtering guests.
2026-03-12 12:50:53 -04:00

181 lines
4.4 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"slices"
"strconv"
"time"
)
const (
ReportDurationAllTime = "all_time"
ReportDurationLast30Days = "last_30_days"
ReportDurationPreviousMonth = "previous_month"
ReportDurationLast6Months = "last_6_months"
ReportingMaxPageSize = 100
GuestFilterAll = "all"
GuestFilterSingleChannel = "single_channel"
GuestFilterMultipleChannel = "multi_channel"
)
var (
ReportExportFormats = []string{"csv"}
UserReportSortColumns = []string{"CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email", "Roles"}
AllowedGuestFilters = []string{GuestFilterAll, GuestFilterSingleChannel, GuestFilterMultipleChannel}
)
type ReportableObject interface {
ToReport() []string
}
type ReportingBaseOptions struct {
SortDesc bool
Direction string // Accepts only "prev" or "next"
PageSize int
SortColumn string
FromColumnValue string
FromId string
DateRange string
StartAt int64
EndAt int64
}
func GetReportDateRange(dateRange string, now time.Time) (int64, int64) {
startAt := int64(0)
endAt := int64(0)
if dateRange == ReportDurationLast30Days {
startAt = now.AddDate(0, 0, -30).UnixMilli()
} else if 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 dateRange == ReportDurationLast6Months {
startAt = now.AddDate(0, -6, -0).UnixMilli()
}
return startAt, endAt
}
func (options *ReportingBaseOptions) PopulateDateRange(now time.Time) {
startAt, endAt := GetReportDateRange(options.DateRange, now)
options.StartAt = startAt
options.EndAt = endAt
}
func (options *ReportingBaseOptions) IsValid() *AppError {
if options.EndAt > 0 && options.StartAt > options.EndAt {
return NewAppError("ReportingBaseOptions.IsValid", "model.reporting_base_options.is_valid.bad_date_range", nil, "", http.StatusBadRequest)
}
return nil
}
type UserReportQuery struct {
User
UserPostStats
ChannelCount *int
}
type UserReport struct {
User
UserPostStats
ChannelCount *int `json:"channel_count,omitempty"`
}
func (u *UserReport) ToReport() []string {
lastStatusAt := ""
if u.LastStatusAt != nil {
lastStatusAt = time.UnixMilli(*u.LastStatusAt).String()
}
lastPostDate := ""
if u.LastPostDate != nil {
lastPostDate = time.UnixMilli(*u.LastPostDate).String()
}
daysActive := ""
if u.DaysActive != nil {
daysActive = strconv.Itoa(*u.DaysActive)
}
totalPosts := ""
if u.TotalPosts != nil {
totalPosts = strconv.Itoa(*u.TotalPosts)
}
channelCount := ""
if u.ChannelCount != nil {
channelCount = strconv.Itoa(*u.ChannelCount)
}
lastLogin := ""
if u.LastLogin > 0 {
lastLogin = time.UnixMilli(u.LastLogin).String()
}
deleteAt := ""
if u.DeleteAt > 0 {
deleteAt = time.UnixMilli(u.DeleteAt).String()
}
return []string{
u.Id,
u.Username,
u.Email,
time.UnixMilli(u.CreateAt).String(),
u.User.GetDisplayName(ShowNicknameFullName),
u.Roles,
lastLogin,
lastStatusAt,
lastPostDate,
daysActive,
totalPosts,
channelCount,
deleteAt,
}
}
type UserReportOptions struct {
ReportingBaseOptions
Role string
Team string
HasNoTeam bool
HideActive bool
HideInactive bool
SearchTerm string
GuestFilter string
}
func (u *UserReportOptions) IsValid() *AppError {
if appErr := u.ReportingBaseOptions.IsValid(); appErr != nil {
return appErr
}
// Validate against the columns we allow sorting for
if !slices.Contains(UserReportSortColumns, u.SortColumn) {
return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_sort_column", nil, "", http.StatusBadRequest)
}
if u.GuestFilter != "" && !slices.Contains(AllowedGuestFilters, u.GuestFilter) {
return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_guest_filter", nil, "", http.StatusBadRequest)
}
return nil
}
func (u *UserReportQuery) ToReport() *UserReport {
u.ClearNonProfileFields(true)
return &UserReport{
User: u.User,
UserPostStats: u.UserPostStats,
ChannelCount: u.ChannelCount,
}
}
func IsValidReportExportFormat(format string) bool {
return slices.Contains(ReportExportFormats, format)
}