mirror of
https://github.com/mattermost/mattermost.git
synced 2026-04-13 04:57:45 -04:00
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 - 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.
274 lines
8.8 KiB
Go
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))
|
|
}
|
|
}
|