mattermost/server/channels/api4/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

274 lines
8.8 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"strconv"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitReports() {
api.BaseRoutes.Reports.Handle("/users", api.APISessionRequired(getUsersForReporting)).Methods(http.MethodGet)
api.BaseRoutes.Reports.Handle("/users/count", api.APISessionRequired(getUserCountForReporting)).Methods(http.MethodGet)
api.BaseRoutes.Reports.Handle("/users/export", api.APISessionRequired(startUsersBatchExport)).Methods(http.MethodPost)
api.BaseRoutes.Reports.Handle("/posts", api.APISessionRequired(getPostsForReporting)).Methods(http.MethodPost)
}
func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers) {
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers)
return
}
baseOptions := fillReportingBaseOptions(r.URL.Query())
options, err := fillUserReportOptions(r.URL.Query())
if err != nil {
c.Err = err
return
}
options.ReportingBaseOptions = baseOptions
// Don't allow fetching more than 100 users at a time from the normal query endpoint
if options.PageSize <= 0 || options.PageSize > model.ReportingMaxPageSize {
c.Err = model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_page_size", nil, "", http.StatusBadRequest)
return
}
userReports, err := c.App.GetUsersForReporting(options)
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))
}
}
func getUserCountForReporting(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers) {
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers)
return
}
options, err := fillUserReportOptions(r.URL.Query())
if err != nil {
c.Err = err
return
}
count, err := c.App.GetUserCountForReport(options)
if err != nil {
c.Err = err
return
}
if jsonErr := json.NewEncoder(w).Encode(count); jsonErr != nil {
c.Logger.Warn("Error writing response", mlog.Err(jsonErr))
}
}
func startUsersBatchExport(c *Context, w http.ResponseWriter, r *http.Request) {
if !(c.IsSystemAdmin()) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
baseOptions := fillReportingBaseOptions(r.URL.Query())
options, err := fillUserReportOptions(r.URL.Query())
if err != nil {
c.Err = err
return
}
options.ReportingBaseOptions = baseOptions
dateRange := options.ReportingBaseOptions.DateRange
if dateRange == "" {
dateRange = "all_time"
}
startAt, endAt := model.GetReportDateRange(dateRange, time.Now())
if err := c.App.StartUsersBatchExport(c.AppContext, options, startAt, endAt); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}
func fillReportingBaseOptions(values url.Values) model.ReportingBaseOptions {
sortColumn := "Username"
if values.Get("sort_column") != "" {
sortColumn = values.Get("sort_column")
}
direction := "next"
if values.Get("direction") == "prev" {
direction = "prev"
}
pageSize := 50
if pageSizeStr, err := strconv.ParseInt(values.Get("page_size"), 10, 64); err == nil {
pageSize = int(pageSizeStr)
}
options := model.ReportingBaseOptions{
Direction: direction,
SortColumn: sortColumn,
SortDesc: values.Get("sort_direction") == "desc",
PageSize: pageSize,
FromColumnValue: values.Get("from_column_value"),
FromId: values.Get("from_id"),
DateRange: values.Get("date_range"),
}
options.PopulateDateRange(time.Now())
return options
}
func fillUserReportOptions(values url.Values) (*model.UserReportOptions, *model.AppError) {
teamFilter := values.Get("team_filter")
if !(teamFilter == "" || model.IsValidId(teamFilter)) {
return nil, model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_team_filter", nil, "", http.StatusBadRequest)
}
hideActive := values.Get("hide_active") == "true"
hideInactive := values.Get("hide_inactive") == "true"
if hideActive && hideInactive {
return nil, model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_active_filter", nil, "", http.StatusBadRequest)
}
return &model.UserReportOptions{
Team: teamFilter,
Role: values.Get("role_filter"),
HasNoTeam: values.Get("has_no_team") == "true",
HideActive: hideActive,
HideInactive: hideInactive,
SearchTerm: values.Get("search_term"),
GuestFilter: values.Get("guest_filter"),
}, nil
}
// getPostsForReporting retrieves posts for reporting purposes with cursor-based pagination.
//
// API Endpoint: POST /api/v4/reports/posts
//
// Cursor Behavior:
// - The cursor is opaque and self-contained (base64-encoded)
// - When a cursor is provided, it contains all query parameters from the initial request
// - Query parameters in the request body (time_field, sort_direction, include_deleted, exclude_system_posts)
// are IGNORED when a cursor is present - the cursor's parameters take precedence
// - This allows clients to keep sending the same parameters on every request without causing errors
// - For the first page or to start a new query, omit the cursor or send an empty string
//
// Required permissions: System Admin (PERMISSION_MANAGE_SYSTEM)
func getPostsForReporting(c *Context, w http.ResponseWriter, r *http.Request) {
// Require system admin permission for accessing posts reporting
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
// Request body contains both options and cursor
var request struct {
model.ReportPostOptions
model.ReportPostOptionsCursor
}
if jsonErr := json.NewDecoder(r.Body).Decode(&request); jsonErr != nil {
c.SetInvalidParamWithErr("body", jsonErr)
return
}
// Resolve query parameters: either from cursor (if provided) or from request options
var queryParams *model.ReportPostQueryParams
var appErr *model.AppError
if request.Cursor != "" {
// Decode cursor - it contains all query parameters
queryParams, appErr = model.DecodeReportPostCursorV1(request.Cursor)
if appErr != nil {
c.Err = appErr
return
}
} else {
// First request - build params from options
// Set defaults for optional parameters
timeField := model.ReportingTimeFieldCreateAt
if request.TimeField == model.ReportingTimeFieldUpdateAt {
timeField = model.ReportingTimeFieldUpdateAt
}
sortDirection := model.ReportingSortDirectionAsc
if request.SortDirection == model.ReportingSortDirectionDesc {
sortDirection = model.ReportingSortDirectionDesc
}
// Set initial cursor position based on sort direction and start_time
cursorTime := int64(0) // ASC: start from beginning (or user-specified start_time)
if request.StartTime > 0 {
cursorTime = request.StartTime
} else if sortDirection == model.ReportingSortDirectionDesc {
cursorTime = math.MaxInt64 // DESC: start from end
}
// Build query params
queryParams = &model.ReportPostQueryParams{
ChannelId: request.ChannelId,
CursorTime: cursorTime,
CursorId: "",
TimeField: timeField,
SortDirection: sortDirection,
IncludeDeleted: request.IncludeDeleted,
ExcludeSystemPosts: request.ExcludeSystemPosts,
}
}
// Handle PerPage - applies to both cursor and non-cursor requests
// Cap at min 100, max 1000
if request.PerPage <= 0 {
queryParams.PerPage = 100
} else if request.PerPage > model.MaxReportingPerPage {
queryParams.PerPage = model.MaxReportingPerPage
} else {
queryParams.PerPage = request.PerPage
}
// Validate the fully resolved query parameters (shared validation for both cursor and options)
if appErr = queryParams.Validate(); appErr != nil {
c.Err = appErr
return
}
// Call app layer with resolved and validated parameters
response, appErr := c.App.GetPostsForReporting(c.AppContext, *queryParams, request.IncludeMetadata)
if appErr != nil {
c.Err = appErr
return
}
if len(response.Posts) == 0 {
// Determine if channel exists.
// This provides a better error message than returning an empty result set
channel, appErr := c.App.GetChannel(c.AppContext, request.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel == nil {
c.Err = model.NewAppError("getPostsForReporting", "api.post.get_posts_for_reporting.channel_not_found", nil, fmt.Sprintf("channel_id=%s", request.ChannelId), http.StatusNotFound)
return
}
}
if jsonErr := json.NewEncoder(w).Encode(response); jsonErr != nil {
c.Logger.Warn("Error writing response", mlog.Err(jsonErr))
}
}