Reviewer search api (#34036)

* Added another property field

* WIP

* WIP

* Added validations

* Added data validations and hidden post if confifgured to

* lint fixes

* Added API spec

* Added some tests

* Added tests for getContentReviewBot

* test: add comprehensive tests for getContentReviewChannels function

* Added more app layer tests

* Added TestCanFlagPost

* test: Add comprehensive tests for FlagPost function

* Added all app layer tests

* Removed a file that was reamoved downstream

* test: add content flagging test file

* test: add comprehensive tests for FlagContentRequest.IsValid method

* Added model tests

* test: add comprehensive tests for SqlPropertyValueStore.CreateMany

* test: add comprehensive tests for flagPost() API function

* Added API tests

* linter fix

* WIP

* sent post flagging confirmation message

* fixed i18n nissues

* fixed i18n nissues

* CI

* WIP

* WIP

* Added API call

* test: add test for Client4.flagPost API call in FlagPostModal

* fix: remove userEvent.setup() from flag post modal test

* test: wrap submit button click in act for proper state updates

* Updated tests

* lint fix

* Updated test

* fix: reset contentFlaggingGroupId for test isolation in content flagging tests

* removed cached group ID

* removed debug log

* CI

* Updated to allow special characters in comments

* Handled empty comment

* Created getContentFlaggingFields API

* created getPostPropertyValues API

* Used finally

* WIP

* Created useContentFlaggingFields hook

* WIP

* WIP

* Added option to retain data for reviewers

* Displayed deleted post's preview

* DIsplayed all properties

* Adding field name i18n

* WIP - managing i18n able texts

* Finished displaying all fields

* Manual cleanup

* lint fixes

* team role filter logic fix

* Fixed tests

* created new API to fetch flagged posts

* lint fix

* Added new client methods

* test: add comprehensive tests for content flagging APIs

* Added new API tests

* fixed openapi spec

* Fixed DataSpillageReport tests

* Fixed PostMarkdown test

* Fixed PostPreviewPropertyRenderer test

* Added metadata to card renderer

* test fixes

* Added no comment placeholder

* Added view detail button

* Created RemoveFlaggedMessageConfirmationModal modal

* Added key and remove flag request modal

* IMplemented delete flagged post

* Handled edge cases of deleting flagged post

* keep message

* UI integration

* Added WS event for post report update and handled deleted files of flagged post

* Added error handling in keep/remove forms

* i18n fixes

* Fixed test

* Updated OpenAPI specs

* fixed types

* fixed types

* refactoring

* refactor: improve test mocking for data spillage report component

* test mock updates

* Fixed tests

* Updated reducer

* not resetting mocks

* Added migrations for content flagging tables

* Created new structure

* review fixes

* Used correct ot name

* WIP

* review fixes

* review fixes

* Added new property translations

* CI

* CI

* CI

* Improved test

* fixed test

* CI

* New UI component

* WIP

* Updated settings APIs

* cached DB data

* used cached reviewer data

* Updated tests

* Lint fixes

* test: add tests for saveContentFlaggingSettings and getContentFlaggingSettings APIs

* test fix

* test: add tests for SaveContentFlaggingConfig and GetContentFlaggingConfigReviewerIDs

* Updated tests

* test: add content flagging test for local cache layer

* test: add comprehensive tests for content flagging store cache

* Updated tests

* lint fix

* Updated mobile text

* Added content flagging SQL store mocks

* Added API specs for new APIs

* fixed tests

* feat: add TestContentFlaggingStore function for content flagging store testing

* feat: add comprehensive tests for content flagging store

* Added SQL store tests

* test: add content flagging test for local cache layer

* test: add tests for content flagging store caching

* Added cache layer tests

* Updated tests

* Fixed

* Handled JSON error

* fixes

* fixes

* Fixed retry layer test

* fixerdf i18n

* Fixed test

* CI

* building index concurrently

* CI

* fixed a test

* CI

* cleanup

* Implemented reviewer search API

* feat: add tests for SearchCommonContentFlaggingReviewers and SearchTeamContentFlaggingReviewers

* Added store tests

* test: add comprehensive tests for SearchReviewers function

* feat: add comprehensive tests for searchReviewers endpoint

* API tests

* Integrate flag post api (#33798)

* WIP

* WIP

* Added API call

* test: add test for Client4.flagPost API call in FlagPostModal

* fix: remove userEvent.setup() from flag post modal test

* test: wrap submit button click in act for proper state updates

* Updated tests

* lint fix

* CI

* Updated to allow special characters in comments

* Handled empty comment

* Used finally

* CI

* Fixed test

* Spillage card integration (#33832)

* Created getContentFlaggingFields API

* created getPostPropertyValues API

* WIP

* Created useContentFlaggingFields hook

* WIP

* WIP

* Added option to retain data for reviewers

* Displayed deleted post's preview

* DIsplayed all properties

* Adding field name i18n

* WIP - managing i18n able texts

* Finished displaying all fields

* Manual cleanup

* lint fixes

* team role filter logic fix

* Fixed tests

* created new API to fetch flagged posts

* lint fix

* Added new client methods

* test: add comprehensive tests for content flagging APIs

* Added new API tests

* fixed openapi spec

* Fixed DataSpillageReport tests

* Fixed PostMarkdown test

* Fixed PostPreviewPropertyRenderer test

* Added metadata to card renderer

* test fixes

* Added no comment placeholder

* Fixed test

* refactor: improve test mocking for data spillage report component

* test mock updates

* Updated reducer

* not resetting mocks

* WIP

* review fixes

* CI

* Fixed

* fixes

* Content flagging actions implementation (#33852)

* Added view detail button

* Created RemoveFlaggedMessageConfirmationModal modal

* Added key and remove flag request modal

* IMplemented delete flagged post

* Handled edge cases of deleting flagged post

* keep message

* UI integration

* Added WS event for post report update and handled deleted files of flagged post

* Added error handling in keep/remove forms

* i18n fixes

* Updated OpenAPI specs

* fixed types

* fixed types

* refactoring

* Fixed tests

* review fixes

* Added new property translations

* Improved test

* fixed test

* CI

* fixes

* CI

* fixed a test

* fixed  abad commit

* CI

* WIP

* IMplemented assign reviewer API

* Display reviewers

* Review fixes

* UI integration

* lint fix

* Added API docs

* test: add comprehensive tests for assignFlaggedPostReviewer function

* test: add comprehensive tests for AssignFlaggedPostReviewer

* Added tests

* Fixed test

* Sequential tests

* minor improvemenmts

* WIP

* Added keep/delete message notifications

* refactor: update AssignFlaggedPostReviewer method signature to include context

* test: add tests for getReviewerPostsForFlaggedPost and postReviewerMessage

* lint fixes

* handled reviewer updates

* Handled preference

* review fixes

* Review fixes
This commit is contained in:
Harshil Sharma 2025-10-14 09:06:23 +05:30 committed by GitHub
parent a8b56ddb60
commit 79756ae1e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2316 additions and 140 deletions

View file

@ -301,3 +301,81 @@
description: Internal server error.
'403':
description: User does not have permission to manage system configuration.
/api/v4/content_flagging/team/{team_id}/reviewers/search:
get:
summary: Search content reviewers in a team
description: |
Searches for content reviewers of a specific team based on a provided term. Only a content reviewer can access this endpoint.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: team_id
required: true
schema:
type: string
description: The ID of the team to search for content reviewers for
- in: query
name: term
required: true
schema:
type: string
description: The search term to filter content reviewers by
responses:
'200':
description: Content reviewers retrieved successfully
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
description: An array of user objects representing the content reviewers that match the search criteria
'403':
description: Forbidden - User does not have permission to access this team.
'404':
description: The specified team was not found or the feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/post/{post_id}/assign/{content_reviewer_id}:
post:
summary: Assign a content reviewer to a flagged post
description: |
Assigns a content reviewer to a specific flagged post for review. The user must be a content reviewer of the team to which the post belongs to.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: post_id
required: true
schema:
type: string
description: The ID of the post to assign a content reviewer to
- in: path
name: content_reviewer_id
required: true
schema:
type: string
description: The ID of the user to be assigned as the content reviewer for the post
responses:
'200':
description: Content reviewer assigned successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
'400':
description: Bad request - Invalid input data or missing required fields.
'403':
description: Forbidden - User does not have permission to assign a reviewer to this post.
'404':
description: Post or user not found, or feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.

View file

@ -7,6 +7,7 @@ import (
"encoding/json"
"net/http"
"slices"
"strings"
"github.com/mattermost/mattermost/server/v8/channels/app"
@ -29,6 +30,8 @@ func (api *API) InitContentFlagging() {
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(keepFlaggedPost)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(saveContentFlaggingSettings)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(getContentFlaggingSettings)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/reviewers/search", api.APISessionRequired(searchReviewers)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/assign/{content_reviewer_id:[A-Za-z0-9]+}", api.APISessionRequired(assignFlaggedPostReviewer)).Methods(http.MethodPost)
}
func requireContentFlaggingAvailable(c *Context) {
@ -506,3 +509,109 @@ func getContentFlaggingSettings(c *Context, w http.ResponseWriter, r *http.Reque
c.Err = model.NewAppError("getContentFlaggingSettings", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func searchReviewers(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequireTeamId()
if c.Err != nil {
return
}
teamId := c.Params.TeamId
userId := c.AppContext.Session().UserId
searchTerm := strings.TrimSpace(r.URL.Query().Get("term"))
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, teamId)
if appErr != nil {
c.Err = appErr
return
}
if !isReviewer {
c.Err = model.NewAppError("searchReviewers", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
return
}
reviewers, appErr := c.App.SearchReviewers(c.AppContext, searchTerm, teamId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(reviewers); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("searchReviewers", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func assignFlaggedPostReviewer(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
c.RequireContentReviewerId()
if c.Err != nil {
return
}
postId := c.Params.PostId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
assignedBy := c.AppContext.Session().UserId
isReviewer, appErr := c.App.IsUserTeamContentReviewer(assignedBy, channel.TeamId)
if appErr != nil {
c.Err = appErr
return
}
if !isReviewer {
c.Err = model.NewAppError("assignFlaggedPostReviewer", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
return
}
reviewerId := c.Params.ContentReviewerId
isReviewer, appErr = c.App.IsUserTeamContentReviewer(reviewerId, channel.TeamId)
if appErr != nil {
c.Err = appErr
return
}
if !isReviewer {
c.Err = model.NewAppError("assignFlaggedPostReviewer", "api.content_flagging.error.assignee_not_reviewer", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventSetReviewer, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "assigningUserId", assignedBy)
model.AddEventParameterToAuditRec(auditRec, "reviewerUserId", reviewerId)
appErr = c.App.AssignFlaggedPostReviewer(c.AppContext, postId, channel.TeamId, reviewerId, assignedBy)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}

View file

@ -13,9 +13,25 @@ import (
"github.com/stretchr/testify/require"
)
func TestGetFlaggingConfiguration(t *testing.T) {
mainHelper.Parallel(t)
func setBasicCommonReviewerConfig(th *TestHelper) *model.AppError {
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
config.SetDefaults()
return th.App.SaveContentFlaggingConfig(config)
}
func TestGetFlaggingConfiguration(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t)
defer func() {
@ -57,8 +73,6 @@ func TestGetFlaggingConfiguration(t *testing.T) {
}
func TestSaveContentFlaggingSettings(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
@ -143,8 +157,6 @@ func TestSaveContentFlaggingSettings(t *testing.T) {
}
func TestGetContentFlaggingSettings(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
@ -169,21 +181,7 @@ func TestGetContentFlaggingSettings(t *testing.T) {
defer th.RemoveLicense()
// First save some settings
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
// Use system admin who has manage system permission
@ -202,8 +200,6 @@ func TestGetContentFlaggingSettings(t *testing.T) {
}
func TestGetPostPropertyValues(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
@ -270,21 +266,7 @@ func TestGetPostPropertyValues(t *testing.T) {
t.Run("Should successfully get property values when user is a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
@ -305,8 +287,6 @@ func TestGetPostPropertyValues(t *testing.T) {
}
func TestGetFlaggedPost(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
@ -392,21 +372,7 @@ func TestGetFlaggedPost(t *testing.T) {
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
@ -419,21 +385,7 @@ func TestGetFlaggedPost(t *testing.T) {
t.Run("Should successfully get flagged post when user is a reviewer and post is flagged", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
@ -457,8 +409,6 @@ func TestGetFlaggedPost(t *testing.T) {
}
func TestFlagPost(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
@ -589,21 +539,7 @@ func TestFlagPost(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
@ -619,8 +555,6 @@ func TestFlagPost(t *testing.T) {
}
func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t)
defer func() {
@ -702,3 +636,342 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
require.True(t, status["enabled"])
})
}
func TestSearchReviewers(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ContentFlagging")
}()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test")
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, reviewers)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test")
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, reviewers)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{}, // Empty list - user is not a reviewer
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test")
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, reviewers)
})
t.Run("Should successfully search reviewers when user is a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "basic")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, reviewers)
})
t.Run("Should successfully search reviewers when user is a team reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{th.BasicUser.Id},
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "basic")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, reviewers)
})
}
func TestAssignContentFlaggingReviewer(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ContentFlagging")
}()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
resp, err := client.AssignContentFlaggingReviewer(context.Background(), model.NewId(), th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("Should return 400 when user ID is invalid", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, "invalidUserId")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Should return 403 when assigning user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{}, // Empty list - user is not a reviewer
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Should return 400 when assignee is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Create another user who will not be a reviewer
nonReviewerUser := th.CreateUser()
th.LinkUserToTeam(nonReviewerUser, th.BasicTeam)
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id}, // Only BasicUser is a reviewer
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
// Try to assign non-reviewer user
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, nonReviewerUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Should successfully assign reviewer when all conditions are met", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Create another reviewer user
reviewerUser := th.CreateUser()
th.LinkUserToTeam(reviewerUser, th.BasicTeam)
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id, reviewerUser.Id},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
// First flag the post so it can be assigned
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
}
flagResp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, flagResp.StatusCode)
// Now assign the reviewer
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, reviewerUser.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("Should successfully assign reviewer when user is team reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Create another reviewer user
reviewerUser := th.CreateUser()
th.LinkUserToTeam(reviewerUser, th.BasicTeam)
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{th.BasicUser.Id, reviewerUser.Id},
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
// First flag the post so it can be assigned
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
}
flagResp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, flagResp.StatusCode)
// Now assign the reviewer
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, reviewerUser.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}

View file

@ -24,6 +24,8 @@ const (
CONTENT_FLAGGING_MAX_PROPERTY_VALUES = 100
POST_PROP_KEY_FLAGGED_POST_ID = "reported_post_id"
CONTENT_FLAGGING_REVIEWER_SEARCH_INDIVIDUAL_LIMIT = 50
)
func (a *App) ContentFlaggingEnabledForTeam(teamId string) (bool, *model.AppError) {
@ -151,8 +153,13 @@ func (a *App) FlagPost(rctx request.CTX, post *model.Post, teamId, reportingUser
}
}
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
if !ok {
return model.NewAppError("FlagPost", "app.content_flagging.missing_flagged_post_id_field.app_error", nil, "", http.StatusInternalServerError)
}
a.Srv().Go(func() {
appErr = a.createContentReviewPost(rctx, post.Id, teamId, reportingUserId, flagData.Reason, post.ChannelId, post.UserId)
appErr = a.createContentReviewPost(rctx, post.Id, teamId, reportingUserId, flagData.Reason, post.ChannelId, post.UserId, flaggedPostIdField.ID, groupId)
if appErr != nil {
rctx.Logger().Error("Failed to create content review post", mlog.Err(appErr), mlog.String("team_id", teamId), mlog.String("post_id", post.Id))
}
@ -233,7 +240,7 @@ func (a *App) GetContentFlaggingMappedFields(groupId string) (map[string]*model.
return mappedFields, nil
}
func (a *App) createContentReviewPost(rctx request.CTX, flaggedPostId, teamId, reportingUserId, reportingReason, flaggedPostChannelId, flaggedPostAuthorId string) *model.AppError {
func (a *App) createContentReviewPost(rctx request.CTX, flaggedPostId, teamId, reportingUserId, reportingReason, flaggedPostChannelId, flaggedPostAuthorId, flaggedPostIdFieldId, contentFlaggingGroupId string) *model.AppError {
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return appErr
@ -281,11 +288,23 @@ func (a *App) createContentReviewPost(rctx request.CTX, flaggedPostId, teamId, r
ChannelId: channel.Id,
}
post.AddProp(POST_PROP_KEY_FLAGGED_POST_ID, flaggedPostId)
_, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{})
createdPost, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{})
if appErr != nil {
rctx.Logger().Error("Failed to create content review post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("team_id", teamId))
continue // Don't stop processing other channels if one fails
}
propertyValue := &model.PropertyValue{
TargetID: createdPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: contentFlaggingGroupId,
FieldID: flaggedPostIdFieldId,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, flaggedPostId)),
}
_, err := a.Srv().propertyService.CreatePropertyValue(propertyValue)
if err != nil {
rctx.Logger().Error("Failed to create content review post property value in one of the channels", mlog.Err(err), mlog.String("channel_id", channel.Id), mlog.String("team_id", teamId), mlog.String("post_id", createdPost.Id))
}
}
return nil
@ -581,6 +600,13 @@ func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.
}
})
a.Srv().Go(func() {
postErr := a.postDeletePostReviewerMessage(rctx, flaggedPost.Id, reviewerId, actionRequest.Comment, groupId)
if postErr != nil {
rctx.Logger().Error("Failed to post delete post reviewer message after permanently removing flagged post", mlog.Err(postErr), mlog.String("post_id", flaggedPost.Id))
}
})
return nil
}
@ -686,6 +712,13 @@ func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContent
}
a.invalidateCacheForChannelPosts(flaggedPost.ChannelId)
a.Srv().Go(func() {
postErr := a.postKeepPostReviewerMessage(rctx, flaggedPost.Id, reviewerId, actionRequest.Comment, groupId)
if postErr != nil {
rctx.Logger().Error("Failed to post keep post reviewer message after retaining flagged post", mlog.Err(postErr), mlog.String("post_id", flaggedPost.Id))
}
})
return nil
}
@ -760,3 +793,276 @@ func (a *App) GetContentFlaggingConfigReviewerIDs() (*model.ReviewerIDsSettings,
return reviewerSettings, nil
}
func (a *App) SearchReviewers(rctx request.CTX, term string, teamId string) ([]*model.User, *model.AppError) {
reviewerSettings := a.Config().ContentFlaggingSettings.ReviewerSettings
reviewers := map[string]*model.User{}
if reviewerSettings.CommonReviewers != nil && *reviewerSettings.CommonReviewers {
commonReviewers, err := a.Srv().Store().User().SearchCommonContentFlaggingReviewers(term)
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.content_flagging.search_common_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range commonReviewers {
reviewers[user.Id] = user
}
} else {
teamReviewers, err := a.Srv().Store().User().SearchTeamContentFlaggingReviewers(teamId, term)
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.content_flagging.search_team_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range teamReviewers {
reviewers[user.Id] = user
}
}
if reviewerSettings.SystemAdminsAsReviewers != nil && *reviewerSettings.SystemAdminsAsReviewers {
systemAdminReviewers, err := a.Srv().Store().User().Search(rctx, teamId, term, &model.UserSearchOptions{
AllowInactive: false,
Role: model.SystemAdminRoleId,
AllowEmails: false,
AllowFullNames: true,
Limit: CONTENT_FLAGGING_REVIEWER_SEARCH_INDIVIDUAL_LIMIT,
})
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.content_flagging.search_sysadmin_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range systemAdminReviewers {
reviewers[user.Id] = user
}
}
if reviewerSettings.TeamAdminsAsReviewers != nil && *reviewerSettings.TeamAdminsAsReviewers {
teamAdminReviewers, err := a.Srv().Store().User().Search(rctx, teamId, term, &model.UserSearchOptions{
AllowInactive: false,
TeamRoles: []string{model.TeamAdminRoleId},
AllowEmails: false,
AllowFullNames: true,
Limit: CONTENT_FLAGGING_REVIEWER_SEARCH_INDIVIDUAL_LIMIT,
})
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.content_flagging.search_team_admin_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range teamAdminReviewers {
reviewers[user.Id] = user
}
}
reviewersList := make([]*model.User, 0, len(reviewers))
for _, user := range reviewers {
reviewersList = append(reviewersList, user)
}
return reviewersList, nil
}
func (a *App) AssignFlaggedPostReviewer(rctx request.CTX, flaggedPostId, flaggedPostTeamId, reviewerId, assigneeId string) *model.AppError {
statusPropertyValue, appErr := a.GetPostContentFlaggingStatusValue(flaggedPostId)
if appErr != nil {
return appErr
}
status := strings.Trim(string(statusPropertyValue.Value), `"`)
if status != model.ContentFlaggingStatusPending && status != model.ContentFlaggingStatusAssigned {
return model.NewAppError("AssignFlaggedPostReviewer", "api.content_flagging.error.post_not_in_progress", nil, "", http.StatusBadRequest)
}
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
if _, ok := mappedFields[contentFlaggingPropertyNameReviewerUserID]; !ok {
return model.NewAppError("AssignFlaggedPostReviewer", "app.content_flagging.assign_reviewer.no_reviewer_field.app_error", nil, "", http.StatusInternalServerError)
}
assigneePropertyValue := &model.PropertyValue{
TargetID: flaggedPostId,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReviewerUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
}
assigneePropertyValue, err := a.Srv().propertyService.UpsertPropertyValue(assigneePropertyValue)
if err != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.content_flagging.assign_reviewer.upsert_property_value.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
if status == model.ContentFlaggingStatusPending {
statusPropertyValue.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusAssigned))
statusPropertyValue, err = a.Srv().propertyService.UpdatePropertyValue(groupId, statusPropertyValue)
if err != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.content_flagging.assign_reviewer.update_status_property_value.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
}
a.Srv().Go(func() {
postErr := a.postAssignReviewerMessage(rctx, groupId, flaggedPostId, reviewerId, assigneeId)
if postErr != nil {
rctx.Logger().Error("Failed to post assign reviewer message", mlog.Err(postErr), mlog.String("flagged_post_id", flaggedPostId), mlog.String("reviewer_id", reviewerId), mlog.String("assignee_id", assigneeId))
}
})
a.Srv().Go(func() {
updateEventAppErr := a.publishContentFlaggingReportUpdateEvent(flaggedPostId, flaggedPostTeamId, []*model.PropertyValue{assigneePropertyValue, statusPropertyValue})
if updateEventAppErr != nil {
rctx.Logger().Error("Failed to publish report change after assigning reviewer", mlog.Err(updateEventAppErr), mlog.String("flagged_post_id", flaggedPostId), mlog.String("reviewer_id", reviewerId), mlog.String("assignee_id", assigneeId))
}
})
return nil
}
func (a *App) postAssignReviewerMessage(rctx request.CTX, contentFlaggingGroupId, flaggedPostId, reviewerId, assignedById string) *model.AppError {
notificationSettings := a.Config().ContentFlaggingSettings.NotificationSettings
if notificationSettings == nil {
return nil
}
if !slices.Contains(notificationSettings.EventTargetMapping[model.EventAssigned], model.TargetReviewers) {
return nil
}
reviewerUser, appErr := a.GetUser(reviewerId)
if appErr != nil {
return appErr
}
var assignedByUser *model.User
if reviewerId == assignedById {
assignedByUser = reviewerUser
} else {
assignedByUser, appErr = a.GetUser(assignedById)
if appErr != nil {
return appErr
}
}
message := fmt.Sprintf("@%s was assigned as a reviewer by @%s", reviewerUser.Username, assignedByUser.Username)
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
}
func (a *App) postDeletePostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) *model.AppError {
actorUser, appErr := a.GetUser(actorUserId)
if appErr != nil {
return appErr
}
message := fmt.Sprintf("The flagged message was removed by @%s", actorUser.Username)
if comment != "" {
message = fmt.Sprintf("%s\n\nWith comment:\n\n> %s", message, comment)
}
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
}
func (a *App) postKeepPostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) *model.AppError {
actorUser, appErr := a.GetUser(actorUserId)
if appErr != nil {
return appErr
}
message := fmt.Sprintf("The flagged message was retained by @%s", actorUser.Username)
if comment != "" {
message = fmt.Sprintf("%s\n\nWith comment:\n\n> %s", message, comment)
}
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
}
func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGroupId, flaggedPostId string) *model.AppError {
mappedFields, appErr := a.GetContentFlaggingMappedFields(contentFlaggingGroupId)
if appErr != nil {
return appErr
}
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
if !ok {
return model.NewAppError("postAssignReviewerMessage", "app.content_flagging.missing_flagged_post_id_field.app_error", nil, "", http.StatusInternalServerError)
}
postIds, appErr := a.getReviewerPostsForFlaggedPost(contentFlaggingGroupId, flaggedPostId, flaggedPostIdField.ID)
if appErr != nil {
return appErr
}
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return appErr
}
for _, postId := range postIds {
reviewerPost, appErr := a.GetSinglePost(rctx, postId, false)
if appErr != nil {
rctx.Logger().Error("Failed to get reviewer post while posting assign reviewer message", mlog.Err(appErr), mlog.String("post_id", postId))
continue
}
channel, appErr := a.GetChannel(rctx, reviewerPost.ChannelId)
if appErr != nil {
rctx.Logger().Error("Failed to get channel for reviewer post while posting assign reviewer message", mlog.Err(appErr), mlog.String("post_id", postId), mlog.String("channel_id", reviewerPost.ChannelId))
continue
}
post := &model.Post{
Message: message,
UserId: contentReviewBot.UserId,
ChannelId: reviewerPost.ChannelId,
RootId: postId,
}
_, appErr = a.CreatePost(rctx, post, channel, model.CreatePostFlags{})
if appErr != nil {
rctx.Logger().Error("Failed to create assign reviewer post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("post_id", postId))
continue
}
}
return nil
}
func (a *App) getReviewerPostsForFlaggedPost(contentFlaggingGroupId, flaggedPostId, flaggedPostIdFieldId string) ([]string, *model.AppError) {
searchOptions := model.PropertyValueSearchOpts{
TargetType: model.PropertyValueTargetTypePost,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, flaggedPostId)),
FieldID: flaggedPostIdFieldId,
PerPage: 100,
Cursor: model.PropertyValueSearchCursor{},
}
var propertyValues []*model.PropertyValue
for {
batch, err := a.Srv().propertyService.SearchPropertyValues(contentFlaggingGroupId, searchOptions)
if err != nil {
return nil, model.NewAppError("getReviewerPostsForFlaggedPost", "app.content_flagging.search_reviewer_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
propertyValues = append(propertyValues, batch...)
if len(batch) < searchOptions.PerPage {
break
}
searchOptions.Cursor.PropertyValueID = propertyValues[len(propertyValues)-1].ID
searchOptions.Cursor.CreateAt = propertyValues[len(propertyValues)-1].CreateAt
}
reviewerPostIds := make([]string, 0, len(propertyValues))
for _, pv := range propertyValues {
reviewerPostIds = append(reviewerPostIds, pv.TargetID)
}
return reviewerPostIds, nil
}

View file

@ -5,6 +5,7 @@ package app
import (
"encoding/json"
"net/http"
"testing"
"time"
@ -109,6 +110,212 @@ func TestContentFlaggingEnabledForTeam(t *testing.T) {
})
}
func TestAssignFlaggedPostReviewer(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
getBaseConfig := func() model.ContentFlaggingSettingsRequest {
config := model.ContentFlaggingSettingsRequest{}
config.SetDefaults()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id}
config.AdditionalSettings.ReporterCommentRequired = model.NewPointer(false)
config.AdditionalSettings.HideFlaggedContent = model.NewPointer(false)
config.AdditionalSettings.Reasons = &[]string{"spam", "harassment", "inappropriate"}
return config
}
setupFlaggedPost := func() *model.Post {
appErr := th.App.SaveContentFlaggingConfig(getBaseConfig())
require.Nil(t, appErr)
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
return post
}
t.Run("should successfully assign reviewer to pending flagged post", func(t *testing.T) {
post := setupFlaggedPost()
appErr := th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, appErr)
// Verify status was updated to assigned
statusValue, appErr := th.App.GetPostContentFlaggingStatusValue(post.Id)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusAssigned+`"`, string(statusValue.Value))
// Verify reviewer property was created
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
reviewerValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReviewerUserID].ID,
})
require.NoError(t, err)
require.Len(t, reviewerValues, 1)
require.Equal(t, `"`+th.BasicUser.Id+`"`, string(reviewerValues[0].Value))
})
t.Run("should successfully reassign reviewer to already assigned flagged post", func(t *testing.T) {
post := setupFlaggedPost()
// First assignment
appErr := th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, appErr)
// Second assignment (reassignment)
appErr = th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.Nil(t, appErr)
// Verify status remains assigned
statusValue, appErr := th.App.GetPostContentFlaggingStatusValue(post.Id)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusAssigned+`"`, string(statusValue.Value))
// Verify reviewer property was updated
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
reviewerValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReviewerUserID].ID,
})
require.NoError(t, err)
require.Len(t, reviewerValues, 1)
require.Equal(t, `"`+th.BasicUser2.Id+`"`, string(reviewerValues[0].Value))
})
t.Run("should fail when trying to assign reviewer to non-flagged post", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
appErr := th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser.Id, th.SystemAdminUser.Id)
require.NotNil(t, appErr)
require.Equal(t, http.StatusNotFound, appErr.StatusCode)
})
t.Run("should fail when trying to assign reviewer to retained post", func(t *testing.T) {
post := setupFlaggedPost()
// First retain the post
actionRequest := &model.FlagContentActionRequest{
Comment: "Keeping this post",
}
appErr := th.App.KeepFlaggedPost(th.Context, actionRequest, th.BasicUser.Id, post)
require.Nil(t, appErr)
// Try to assign reviewer to retained post
appErr = th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.NotNil(t, appErr)
require.Equal(t, "api.content_flagging.error.post_not_in_progress", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("should fail when trying to assign reviewer to removed post", func(t *testing.T) {
post := setupFlaggedPost()
// First remove the post
actionRequest := &model.FlagContentActionRequest{
Comment: "Removing this post",
}
appErr := th.App.PermanentDeleteFlaggedPost(th.Context, actionRequest, th.BasicUser.Id, post)
require.Nil(t, appErr)
// Try to assign reviewer to removed post
appErr = th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.NotNil(t, appErr)
require.Equal(t, "api.content_flagging.error.post_not_in_progress", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("should handle assignment with same reviewer ID", func(t *testing.T) {
post := setupFlaggedPost()
// Assign reviewer
appErr := th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, appErr)
// Assign same reviewer again
appErr = th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, appErr)
// Verify status remains assigned
statusValue, appErr := th.App.GetPostContentFlaggingStatusValue(post.Id)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusAssigned+`"`, string(statusValue.Value))
// Verify reviewer property still exists with correct value
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
reviewerValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReviewerUserID].ID,
})
require.NoError(t, err)
require.Len(t, reviewerValues, 1)
require.Equal(t, `"`+th.BasicUser.Id+`"`, string(reviewerValues[0].Value))
})
t.Run("should handle assignment with empty reviewer ID", func(t *testing.T) {
post := setupFlaggedPost()
appErr := th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, "", th.SystemAdminUser.Id)
require.Nil(t, appErr)
// Verify status was updated to assigned
statusValue, appErr := th.App.GetPostContentFlaggingStatusValue(post.Id)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusAssigned+`"`, string(statusValue.Value))
// Verify reviewer property was created with empty value
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
reviewerValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReviewerUserID].ID,
})
require.NoError(t, err)
require.Len(t, reviewerValues, 1)
require.Equal(t, `""`, string(reviewerValues[0].Value))
})
t.Run("should handle assignment with invalid post ID", func(t *testing.T) {
appErr := th.App.AssignFlaggedPostReviewer(th.Context, "invalid_post_id", th.BasicChannel.TeamId, th.BasicUser.Id, th.SystemAdminUser.Id)
require.NotNil(t, appErr)
require.Equal(t, http.StatusNotFound, appErr.StatusCode)
})
}
func TestSaveContentFlaggingConfig(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
@ -1069,3 +1276,640 @@ func TestFlagPost(t *testing.T) {
require.True(t, reportingTime >= beforeTime && reportingTime <= afterTime)
})
}
func TestSearchReviewers(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
getBaseConfig := func() model.ContentFlaggingSettingsRequest {
config := model.ContentFlaggingSettingsRequest{}
config.SetDefaults()
return config
}
t.Run("should return common reviewers when searching", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id, th.BasicUser2.Id}
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Search for users by username
reviewers, appErr := th.App.SearchReviewers(th.Context, th.BasicUser.Username, th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Equal(t, th.BasicUser.Id, reviewers[0].Id)
// Search for users by partial username
reviewers, appErr = th.App.SearchReviewers(th.Context, th.BasicUser.Username[:3], th.BasicTeam.Id)
require.Nil(t, appErr)
require.True(t, len(reviewers) >= 1)
// Verify the basic user is in the results
found := false
for _, reviewer := range reviewers {
if reviewer.Id == th.BasicUser.Id {
found = true
break
}
}
require.True(t, found)
})
t.Run("should return team reviewers when common reviewers disabled", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamReviewersSetting = map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{th.BasicUser2.Id},
},
}
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Search for team reviewer
reviewers, appErr := th.App.SearchReviewers(th.Context, th.BasicUser2.Username, th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Equal(t, th.BasicUser2.Id, reviewers[0].Id)
// Search should not return users not configured as team reviewers
reviewers, appErr = th.App.SearchReviewers(th.Context, th.BasicUser.Username, th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 0)
})
t.Run("should return system admins as additional reviewers", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(false)
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Add system admin to team
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
defer func() {
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
}()
// Search for system admin
reviewers, appErr := th.App.SearchReviewers(th.Context, th.SystemAdminUser.Username, th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Equal(t, th.SystemAdminUser.Id, reviewers[0].Id)
})
t.Run("should return team admins as additional reviewers", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(false)
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Create a new user and make them team admin
teamAdmin := th.CreateUser()
defer func() {
_ = th.App.PermanentDeleteUser(th.Context, teamAdmin)
}()
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "")
require.Nil(t, appErr)
_, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId)
require.Nil(t, appErr)
// Search for team admin
reviewers, appErr := th.App.SearchReviewers(th.Context, teamAdmin.Username, th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Equal(t, teamAdmin.Id, reviewers[0].Id)
})
t.Run("should return combined reviewers from multiple sources", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id}
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Add system admin to team
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
defer func() {
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
}()
// Create a team admin
teamAdmin := th.CreateUser()
defer func() {
_ = th.App.PermanentDeleteUser(th.Context, teamAdmin)
}()
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "")
require.Nil(t, appErr)
_, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId)
require.Nil(t, appErr)
// Search with empty term should return all reviewers
reviewers, appErr := th.App.SearchReviewers(th.Context, "", th.BasicTeam.Id)
require.Nil(t, appErr)
require.True(t, len(reviewers) >= 3)
// Verify all expected reviewers are present
reviewerIds := make([]string, len(reviewers))
for i, reviewer := range reviewers {
reviewerIds[i] = reviewer.Id
}
require.Contains(t, reviewerIds, th.BasicUser.Id)
require.Contains(t, reviewerIds, th.SystemAdminUser.Id)
require.Contains(t, reviewerIds, teamAdmin.Id)
})
t.Run("should deduplicate reviewers from multiple sources", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.SystemAdminUser.Id}
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Add system admin to team
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
defer func() {
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
}()
// Search for system admin (who is both common reviewer and system admin)
reviewers, appErr := th.App.SearchReviewers(th.Context, th.SystemAdminUser.Username, th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Equal(t, th.SystemAdminUser.Id, reviewers[0].Id)
})
t.Run("should return empty results when no reviewers match search term", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id}
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Search for non-existent user
reviewers, appErr := th.App.SearchReviewers(th.Context, "nonexistentuser", th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 0)
})
t.Run("should return empty results when no reviewers configured", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(false)
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Search should return no results
reviewers, appErr := th.App.SearchReviewers(th.Context, th.BasicUser.Username, th.BasicTeam.Id)
require.Nil(t, appErr)
require.Len(t, reviewers, 0)
})
t.Run("should work with team reviewers and additional reviewers combined", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamReviewersSetting = map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{th.BasicUser.Id},
},
}
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Add system admin to team
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
defer func() {
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
}()
// Search with empty term should return both team reviewer and system admin
reviewers, appErr := th.App.SearchReviewers(th.Context, "", th.BasicTeam.Id)
require.Nil(t, appErr)
require.True(t, len(reviewers) >= 2)
reviewerIds := make([]string, len(reviewers))
for i, reviewer := range reviewers {
reviewerIds[i] = reviewer.Id
}
require.Contains(t, reviewerIds, th.BasicUser.Id)
require.Contains(t, reviewerIds, th.SystemAdminUser.Id)
})
t.Run("should handle search by email and full name", func(t *testing.T) {
config := getBaseConfig()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id}
config.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
config.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// Search by first name (if the user has one set)
if th.BasicUser.FirstName != "" {
reviewers, appErr := th.App.SearchReviewers(th.Context, th.BasicUser.FirstName, th.BasicTeam.Id)
require.Nil(t, appErr)
require.True(t, len(reviewers) >= 1)
found := false
for _, reviewer := range reviewers {
if reviewer.Id == th.BasicUser.Id {
found = true
break
}
}
require.True(t, found)
}
})
}
func TestGetReviewerPostsForFlaggedPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
getBaseConfig := func() model.ContentFlaggingSettingsRequest {
config := model.ContentFlaggingSettingsRequest{}
config.SetDefaults()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id}
config.AdditionalSettings.ReporterCommentRequired = model.NewPointer(false)
config.AdditionalSettings.HideFlaggedContent = model.NewPointer(false)
config.AdditionalSettings.Reasons = &[]string{"spam", "harassment", "inappropriate"}
return config
}
setupFlaggedPost := func() *model.Post {
appErr := th.App.SaveContentFlaggingConfig(getBaseConfig())
require.Nil(t, appErr)
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
return post
}
t.Run("should return reviewer posts for flagged post", func(t *testing.T) {
post := setupFlaggedPost()
// Wait for async reviewer post creation to complete
time.Sleep(2 * time.Second)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
require.True(t, ok)
reviewerPostIds, appErr := th.App.getReviewerPostsForFlaggedPost(groupId, post.Id, flaggedPostIdField.ID)
require.Nil(t, appErr)
require.Len(t, reviewerPostIds, 1)
// Verify the reviewer post exists and has the correct properties
reviewerPost, appErr := th.App.GetSinglePost(th.Context, reviewerPostIds[0], false)
require.Nil(t, appErr)
require.Equal(t, model.ContentFlaggingPostType, reviewerPost.Type)
require.Contains(t, reviewerPost.GetProps(), POST_PROP_KEY_FLAGGED_POST_ID)
require.Equal(t, post.Id, reviewerPost.GetProps()[POST_PROP_KEY_FLAGGED_POST_ID])
})
t.Run("should return empty list when no reviewer posts exist", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
require.True(t, ok)
reviewerPostIds, appErr := th.App.getReviewerPostsForFlaggedPost(groupId, post.Id, flaggedPostIdField.ID)
require.Nil(t, appErr)
require.Len(t, reviewerPostIds, 0)
})
t.Run("should handle multiple reviewer posts for same flagged post", func(t *testing.T) {
// Create a config with multiple reviewers
config := getBaseConfig()
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id, th.BasicUser2.Id}
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.SystemAdminUser.Id, flagData)
require.Nil(t, appErr)
// Wait for async reviewer post creation to complete
time.Sleep(2 * time.Second)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
require.True(t, ok)
reviewerPostIds, appErr := th.App.getReviewerPostsForFlaggedPost(groupId, post.Id, flaggedPostIdField.ID)
require.Nil(t, appErr)
require.Len(t, reviewerPostIds, 2)
// Verify both reviewer posts exist and have correct properties
for _, postId := range reviewerPostIds {
reviewerPost, appErr := th.App.GetSinglePost(th.Context, postId, false)
require.Nil(t, appErr)
require.Equal(t, model.ContentFlaggingPostType, reviewerPost.Type)
require.Contains(t, reviewerPost.GetProps(), POST_PROP_KEY_FLAGGED_POST_ID)
require.Equal(t, post.Id, reviewerPost.GetProps()[POST_PROP_KEY_FLAGGED_POST_ID])
}
})
t.Run("should handle invalid flagged post ID", func(t *testing.T) {
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
require.True(t, ok)
reviewerPostIds, appErr := th.App.getReviewerPostsForFlaggedPost(groupId, "invalid_post_id", flaggedPostIdField.ID)
require.Nil(t, appErr)
require.Len(t, reviewerPostIds, 0)
})
}
func TestPostReviewerMessage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
getBaseConfig := func() model.ContentFlaggingSettingsRequest {
config := model.ContentFlaggingSettingsRequest{}
config.SetDefaults()
config.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id}
config.AdditionalSettings.ReporterCommentRequired = model.NewPointer(false)
config.AdditionalSettings.HideFlaggedContent = model.NewPointer(false)
config.AdditionalSettings.Reasons = &[]string{"spam", "harassment", "inappropriate"}
return config
}
setupFlaggedPost := func() *model.Post {
appErr := th.App.SaveContentFlaggingConfig(getBaseConfig())
require.Nil(t, appErr)
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
return post
}
t.Run("should post reviewer message to thread", func(t *testing.T) {
post := setupFlaggedPost()
// Wait for async reviewer post creation to complete
time.Sleep(2 * time.Second)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
testMessage := "Test reviewer message"
appErr = th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
require.Nil(t, appErr)
// Wait for async message posting to complete
time.Sleep(2 * time.Second)
// Verify message was posted to the reviewer thread
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
posts, appErr := th.App.GetPostsPage(th.Context, model.GetPostsOptions{
ChannelId: dmChannel.Id,
Page: 0,
PerPage: 10,
})
require.Nil(t, appErr)
// Find the original review post and the test message
var reviewPost *model.Post
var testMessagePost *model.Post
for _, p := range posts.Posts {
if p.Type == "custom_spillage_report" {
reviewPost = p
} else if p.RootId != "" && p.Message == testMessage {
testMessagePost = p
}
}
require.NotNil(t, reviewPost)
require.NotNil(t, testMessagePost)
require.Equal(t, reviewPost.Id, testMessagePost.RootId)
require.Equal(t, contentReviewBot.UserId, testMessagePost.UserId)
})
t.Run("should handle multiple reviewer channels", func(t *testing.T) {
// Create a config with multiple reviewers
config := getBaseConfig()
config.ReviewerSettings.CommonReviewerIds = []string{th.BasicUser.Id, th.BasicUser2.Id}
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.SystemAdminUser.Id, flagData)
require.Nil(t, appErr)
// Wait for async reviewer post creation to complete
time.Sleep(2 * time.Second)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
testMessage := "Test message for multiple reviewers"
appErr = th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
require.Nil(t, appErr)
// Wait for async message posting to complete
time.Sleep(2 * time.Second)
// Verify message was posted to both reviewer threads
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
// Check first reviewer's channel
dmChannel1, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
posts1, appErr := th.App.GetPostsPage(th.Context, model.GetPostsOptions{
ChannelId: dmChannel1.Id,
Page: 0,
PerPage: 10,
})
require.Nil(t, appErr)
// Check second reviewer's channel
dmChannel2, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser2.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
posts2, appErr := th.App.GetPostsPage(th.Context, model.GetPostsOptions{
ChannelId: dmChannel2.Id,
Page: 0,
PerPage: 10,
})
require.Nil(t, appErr)
// Verify test message exists in both channels
var testMessagePost1, testMessagePost2 *model.Post
for _, p := range posts1.Posts {
if p.RootId != "" && p.Message == testMessage {
testMessagePost1 = p
break
}
}
for _, p := range posts2.Posts {
if p.RootId != "" && p.Message == testMessage {
testMessagePost2 = p
break
}
}
require.NotNil(t, testMessagePost1)
require.NotNil(t, testMessagePost2)
require.Equal(t, contentReviewBot.UserId, testMessagePost1.UserId)
require.Equal(t, contentReviewBot.UserId, testMessagePost2.UserId)
})
t.Run("should handle case when no reviewer posts exist", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
testMessage := "Test message for non-flagged post"
appErr = th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
require.Nil(t, appErr)
// Should not error, but no messages should be posted since no reviewer posts exist
// This is a graceful handling case
})
t.Run("should handle message with special characters", func(t *testing.T) {
post := setupFlaggedPost()
// Wait for async reviewer post creation to complete
time.Sleep(2 * time.Second)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
testMessage := "Test message with special chars: @user #channel ~team & <script>alert('xss')</script>"
appErr = th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
require.Nil(t, appErr)
// Wait for async message posting to complete
time.Sleep(2 * time.Second)
// Verify message was posted correctly with special characters preserved
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
posts, appErr := th.App.GetPostsPage(th.Context, model.GetPostsOptions{
ChannelId: dmChannel.Id,
Page: 0,
PerPage: 10,
})
require.Nil(t, appErr)
// Find the test message
var testMessagePost *model.Post
for _, p := range posts.Posts {
if p.RootId != "" && p.Message == testMessage {
testMessagePost = p
break
}
}
require.NotNil(t, testMessagePost)
require.Equal(t, testMessage, testMessagePost.Message)
})
}

View file

@ -14293,6 +14293,27 @@ func (s *RetryLayerTokenStore) Cleanup(expiryTime int64) {
}
func (s *RetryLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) {
tries := 0
for {
result, err := s.TokenStore.ConsumeOnce(tokenStr)
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 *RetryLayerTokenStore) Delete(token string) error {
tries := 0
@ -15763,6 +15784,27 @@ func (s *RetryLayerUserStore) Search(rctx request.CTX, teamID string, term strin
}
func (s *RetryLayerUserStore) SearchCommonContentFlaggingReviewers(term string) ([]*model.User, error) {
tries := 0
for {
result, err := s.UserStore.SearchCommonContentFlaggingReviewers(term)
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) SearchInChannel(channelID string, term string, options *model.UserSearchOptions) ([]*model.User, error) {
tries := 0
@ -15868,6 +15910,27 @@ func (s *RetryLayerUserStore) SearchNotInTeam(notInTeamID string, term string, o
}
func (s *RetryLayerUserStore) SearchTeamContentFlaggingReviewers(teamId string, term string) ([]*model.User, error) {
tries := 0
for {
result, err := s.UserStore.SearchTeamContentFlaggingReviewers(teamId, term)
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) SearchWithoutTeam(term string, options *model.UserSearchOptions) ([]*model.User, error) {
tries := 0

View file

@ -179,6 +179,10 @@ func (s *SqlPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSea
builder = builder.Where(sq.Gt{"UpdateAt": opts.SinceUpdateAt})
}
if opts.Value != nil {
builder = builder.Where(sq.Eq{"Value": string(opts.Value)})
}
var values []*model.PropertyValue
if err := s.GetReplica().SelectBuilder(&values, builder); err != nil {
return nil, errors.Wrap(err, "property_value_search_query")

View file

@ -27,6 +27,7 @@ import (
const (
MaxGroupChannelsForProfiles = 50
ContentReviewerSearchLimit = 50
)
var (
@ -89,6 +90,14 @@ func getUsersColumns() []string {
}
}
func getBotInfoColumns() []string {
return []string{
"b.UserId IS NOT NULL AS IsBot",
"COALESCE(b.Description, '') AS BotDescription",
"COALESCE(b.LastIconUpdate, 0) AS BotLastIconUpdate",
}
}
func newSqlUserStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.UserStore {
us := &SqlUserStore{
SqlStore: sqlStore,
@ -99,11 +108,7 @@ func newSqlUserStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) s
// Together with getUsersColumns, the order specified here must match
// with [SqlUserStore.Get] and [SqlUserStore.GetAllProfilesInChannel].
Select(getUsersColumns()...).
Columns(
"b.UserId IS NOT NULL AS IsBot",
"COALESCE(b.Description, '') AS BotDescription",
"COALESCE(b.LastIconUpdate, 0) AS BotLastIconUpdate",
).
Columns(getBotInfoColumns()...).
From("Users").
LeftJoin("Bots b ON ( b.UserId = Users.Id )")
@ -1571,6 +1576,47 @@ func (us SqlUserStore) Search(rctx request.CTX, teamId string, term string, opti
return us.performSearch(query, term, options)
}
func (us SqlUserStore) SearchCommonContentFlaggingReviewers(term string) ([]*model.User, error) {
query := us.getQueryBuilder().
Select(getUsersColumns()...).
Columns(getBotInfoColumns()...).
From("ContentFlaggingCommonReviewers").
LeftJoin("Users ON Users.Id = ContentFlaggingCommonReviewers.UserId").
LeftJoin("Bots b ON (b.UserId = Users.Id)").
OrderBy("Users.Username ASC").
Limit(ContentReviewerSearchLimit)
searchOptions := &model.UserSearchOptions{
AllowEmails: false,
AllowFullNames: true,
AllowInactive: false,
Limit: 50,
}
return us.performSearch(query, term, searchOptions)
}
func (us SqlUserStore) SearchTeamContentFlaggingReviewers(teamId, term string) ([]*model.User, error) {
query := us.getQueryBuilder().
Select(getUsersColumns()...).
Columns(getBotInfoColumns()...).
From("ContentFlaggingTeamReviewers").
LeftJoin("Users ON Users.Id = ContentFlaggingTeamReviewers.UserId").
LeftJoin("Bots b ON (b.UserId = Users.Id)").
Where("ContentFlaggingTeamReviewers.TeamId = ?", teamId).
OrderBy("Users.Username ASC").
Limit(ContentReviewerSearchLimit)
searchOptions := &model.UserSearchOptions{
AllowEmails: false,
AllowFullNames: true,
AllowInactive: false,
Limit: 50,
}
return us.performSearch(query, term, searchOptions)
}
func (us SqlUserStore) SearchWithoutTeam(term string, options *model.UserSearchOptions) ([]*model.User, error) {
query := us.usersQuery.
Where(`(

View file

@ -507,6 +507,8 @@ type UserStore interface {
RefreshPostStatsForUsers() error
GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error)
GetUserCountForReport(filter *model.UserReportOptions) (int64, error)
SearchCommonContentFlaggingReviewers(term string) ([]*model.User, error)
SearchTeamContentFlaggingReviewers(teamId, term string) ([]*model.User, error)
}
type BotStore interface {

View file

@ -1734,6 +1734,36 @@ func (_m *UserStore) Search(rctx request.CTX, teamID string, term string, option
return r0, r1
}
// SearchCommonContentFlaggingReviewers provides a mock function with given fields: term
func (_m *UserStore) SearchCommonContentFlaggingReviewers(term string) ([]*model.User, error) {
ret := _m.Called(term)
if len(ret) == 0 {
panic("no return value specified for SearchCommonContentFlaggingReviewers")
}
var r0 []*model.User
var r1 error
if rf, ok := ret.Get(0).(func(string) ([]*model.User, error)); ok {
return rf(term)
}
if rf, ok := ret.Get(0).(func(string) []*model.User); ok {
r0 = rf(term)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.User)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(term)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SearchInChannel provides a mock function with given fields: channelID, term, options
func (_m *UserStore) SearchInChannel(channelID string, term string, options *model.UserSearchOptions) ([]*model.User, error) {
ret := _m.Called(channelID, term, options)
@ -1884,6 +1914,36 @@ func (_m *UserStore) SearchNotInTeam(notInTeamID string, term string, options *m
return r0, r1
}
// SearchTeamContentFlaggingReviewers provides a mock function with given fields: teamId, term
func (_m *UserStore) SearchTeamContentFlaggingReviewers(teamId string, term string) ([]*model.User, error) {
ret := _m.Called(teamId, term)
if len(ret) == 0 {
panic("no return value specified for SearchTeamContentFlaggingReviewers")
}
var r0 []*model.User
var r1 error
if rf, ok := ret.Get(0).(func(string, string) ([]*model.User, error)); ok {
return rf(teamId, term)
}
if rf, ok := ret.Get(0).(func(string, string) []*model.User); ok {
r0 = rf(teamId, term)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.User)
}
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(teamId, term)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SearchWithoutTeam provides a mock function with given fields: term, options
func (_m *UserStore) SearchWithoutTeam(term string, options *model.UserSearchOptions) ([]*model.User, error) {
ret := _m.Called(term, options)

View file

@ -87,6 +87,8 @@ func TestUserStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("SearchWithoutTeam", func(t *testing.T) { testUserStoreSearchWithoutTeam(t, rctx, ss) })
t.Run("SearchInGroup", func(t *testing.T) { testUserStoreSearchInGroup(t, rctx, ss) })
t.Run("SearchNotInGroup", func(t *testing.T) { testUserStoreSearchNotInGroup(t, rctx, ss) })
t.Run("SearchCommonContentFlaggingReviewers", func(t *testing.T) { testUserStoreSearchCommonContentFlaggingReviewers(t, rctx, ss) })
t.Run("SearchTeamContentFlaggingReviewers", func(t *testing.T) { testUserStoreSearchTeamContentFlaggingReviewers(t, rctx, ss) })
t.Run("GetProfilesNotInTeam", func(t *testing.T) { testUserStoreGetProfilesNotInTeam(t, rctx, ss) })
t.Run("ClearAllCustomRoleAssignments", func(t *testing.T) { testUserStoreClearAllCustomRoleAssignments(t, rctx, ss) })
t.Run("GetAllAfter", func(t *testing.T) { testUserStoreGetAllAfter(t, rctx, ss) })
@ -6677,3 +6679,173 @@ func testMfaUsedTimestamps(t *testing.T, rctx request.CTX, ss store.Store) {
require.NoError(t, err)
require.Equal(t, []int{1, 2, 3}, tss)
}
func testUserStoreSearchCommonContentFlaggingReviewers(t *testing.T, rctx request.CTX, ss store.Store) {
ss.ContentFlagging().ClearCaches()
u1, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "reviewer1" + model.NewId(),
FirstName: "John",
LastName: "Reviewer",
Nickname: "johnny",
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u1.Id)) }()
u2, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "reviewer2" + model.NewId(),
FirstName: "Jane",
LastName: "Smith",
Nickname: "janie",
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u2.Id)) }()
u3, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "notreviewer" + model.NewId(),
DeleteAt: model.GetMillis(), // Inactive user
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u3.Id)) }()
u4, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "otheruser" + model.NewId(),
FirstName: "Bob",
LastName: "Johnson",
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u4.Id)) }()
reviewerSettings := model.ReviewerIDsSettings{
CommonReviewerIds: []string{u1.Id, u2.Id},
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{},
}
saveErr = ss.ContentFlagging().SaveReviewerSettings(reviewerSettings)
require.NoError(t, saveErr)
t.Run("search with empty term returns all common reviewers", func(t *testing.T) {
users, err := ss.User().SearchCommonContentFlaggingReviewers("")
require.NoError(t, err)
require.Len(t, users, 2)
userIds := []string{users[0].Id, users[1].Id}
assert.Contains(t, userIds, u1.Id)
assert.Contains(t, userIds, u2.Id)
})
t.Run("search by username", func(t *testing.T) {
users, err := ss.User().SearchCommonContentFlaggingReviewers("reviewer1")
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, u1.Id, users[0].Id)
})
t.Run("search does not return non-reviewers", func(t *testing.T) {
users, err := ss.User().SearchCommonContentFlaggingReviewers("otheruser")
require.NoError(t, err)
require.Empty(t, users) // u4 is not a common reviewer
})
}
func testUserStoreSearchTeamContentFlaggingReviewers(t *testing.T, rctx request.CTX, ss store.Store) {
ss.ContentFlagging().ClearCaches()
teamId := model.NewId()
u1, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "teamreviewer1" + model.NewId(),
FirstName: "Alice",
LastName: "TeamReviewer",
Nickname: "alice",
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u1.Id)) }()
u2, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "teamreviewer2" + model.NewId(),
FirstName: "Charlie",
LastName: "Brown",
Nickname: "charlie",
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u2.Id)) }()
u3, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "inactiveteamreviewer" + model.NewId(),
DeleteAt: model.GetMillis(), // Inactive user
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u3.Id)) }()
u4, saveErr := ss.User().Save(rctx, &model.User{
Email: MakeEmail(),
Username: "nonteamreviewer" + model.NewId(),
FirstName: "David",
LastName: "Wilson",
})
require.NoError(t, saveErr)
defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u4.Id)) }()
reviewerSettings := model.ReviewerIDsSettings{
CommonReviewerIds: []string{},
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
teamId: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{u1.Id, u2.Id},
},
},
}
saveErr = ss.ContentFlagging().SaveReviewerSettings(reviewerSettings)
require.NoError(t, saveErr)
t.Run("search with empty term returns all team reviewers", func(t *testing.T) {
users, err := ss.User().SearchTeamContentFlaggingReviewers(teamId, "")
require.NoError(t, err)
require.Len(t, users, 2)
userIds := []string{users[0].Id, users[1].Id}
assert.Contains(t, userIds, u1.Id)
assert.Contains(t, userIds, u2.Id)
})
t.Run("search by username", func(t *testing.T) {
users, err := ss.User().SearchTeamContentFlaggingReviewers(teamId, "teamreviewer1")
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, u1.Id, users[0].Id)
})
t.Run("search does not return inactive users", func(t *testing.T) {
// Add inactive user as team reviewer
reviewerSettings.TeamReviewersSetting[teamId].ReviewerIds = append(
reviewerSettings.TeamReviewersSetting[teamId].ReviewerIds, u3.Id)
err := ss.ContentFlagging().SaveReviewerSettings(reviewerSettings)
require.NoError(t, err)
users, err := ss.User().SearchTeamContentFlaggingReviewers(teamId, "inactiveteamreviewer")
require.NoError(t, err)
require.Empty(t, users) // Should not return inactive user
})
t.Run("search does not return non-team-reviewers", func(t *testing.T) {
users, err := ss.User().SearchTeamContentFlaggingReviewers(teamId, "nonteamreviewer")
require.NoError(t, err)
require.Empty(t, users) // u4 is not a team reviewer
})
t.Run("search with different team returns empty", func(t *testing.T) {
differentTeamId := model.NewId()
users, err := ss.User().SearchTeamContentFlaggingReviewers(differentTeamId, "teamreviewer")
require.NoError(t, err)
require.Empty(t, users) // No reviewers for different team
})
}

View file

@ -11243,6 +11243,22 @@ func (s *TimerLayerTokenStore) Cleanup(expiryTime int64) {
}
}
func (s *TimerLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) {
start := time.Now()
result, err := s.TokenStore.ConsumeOnce(tokenStr)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("TokenStore.ConsumeOnce", success, elapsed)
}
return result, err
}
func (s *TimerLayerTokenStore) Delete(token string) error {
start := time.Now()
@ -12439,6 +12455,22 @@ func (s *TimerLayerUserStore) Search(rctx request.CTX, teamID string, term strin
return result, err
}
func (s *TimerLayerUserStore) SearchCommonContentFlaggingReviewers(term string) ([]*model.User, error) {
start := time.Now()
result, err := s.UserStore.SearchCommonContentFlaggingReviewers(term)
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.SearchCommonContentFlaggingReviewers", success, elapsed)
}
return result, err
}
func (s *TimerLayerUserStore) SearchInChannel(channelID string, term string, options *model.UserSearchOptions) ([]*model.User, error) {
start := time.Now()
@ -12519,6 +12551,22 @@ func (s *TimerLayerUserStore) SearchNotInTeam(notInTeamID string, term string, o
return result, err
}
func (s *TimerLayerUserStore) SearchTeamContentFlaggingReviewers(teamId string, term string) ([]*model.User, error) {
start := time.Now()
result, err := s.UserStore.SearchTeamContentFlaggingReviewers(teamId, term)
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.SearchTeamContentFlaggingReviewers", success, elapsed)
}
return result, err
}
func (s *TimerLayerUserStore) SearchWithoutTeam(term string, options *model.UserSearchOptions) ([]*model.User, error) {
start := time.Now()

View file

@ -757,6 +757,17 @@ func (c *Context) RequireInvoiceId() *Context {
return c
}
func (c *Context) RequireContentReviewerId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.ContentReviewerId) {
c.SetInvalidURLParam("content_reviewer_id")
}
return c
}
func (c *Context) GetRemoteID(r *http.Request) string {
return r.Header.Get(model.HeaderRemoteclusterId)
}

View file

@ -109,6 +109,7 @@ type Params struct {
ExcludeRemote bool
AccessControlPolicyEnforced bool
ExcludeAccessControlPolicyEnforced bool
ContentReviewerId string
//Bookmarks
ChannelBookmarkId string
@ -285,6 +286,7 @@ func ParamsFromRequest(r *http.Request) *Params {
params.ExcludePolicyConstrained, _ = strconv.ParseBool(query.Get("exclude_policy_constrained"))
params.AccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("access_control_policy_enforced"))
params.ExcludeAccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("exclude_access_control_policy_enforced"))
params.ContentReviewerId = props["content_reviewer_id"]
if val := query.Get("group_source"); val != "" {
switch val {

View file

@ -1741,6 +1741,10 @@
"id": "api.config.update_config.translations.app_error",
"translation": "Failed to update server translations."
},
{
"id": "api.content_flagging.error.assignee_not_reviewer",
"translation": "Only a content reviewer can be assigned as a reviewer."
},
{
"id": "api.content_flagging.error.comment_required",
"translation": "Please add a comment explaining why youre flagging this message."
@ -5170,6 +5174,18 @@
"id": "app.compliance.save.saving.app_error",
"translation": "We encountered an error saving the compliance report."
},
{
"id": "app.content_flagging.assign_reviewer.no_reviewer_field.app_error",
"translation": "No Reviewer ID property field found."
},
{
"id": "app.content_flagging.assign_reviewer.update_status_property_value.app_error",
"translation": "Failed to update Status property value on setting reviewer."
},
{
"id": "app.content_flagging.assign_reviewer.upsert_property_value.app_error",
"translation": "Failed to set flagged post reviewer."
},
{
"id": "app.content_flagging.can_flag_post.in_progress",
"translation": "Cannot flag this post as is already flagged."
@ -5238,6 +5254,10 @@
"id": "app.content_flagging.marshal_property_values.app_error",
"translation": "Failed to marshal Content Flagging property values to send in WebSocket event."
},
{
"id": "app.content_flagging.missing_flagged_post_id_field.app_error",
"translation": "Unable to find Flagged Post ID property field."
},
{
"id": "app.content_flagging.no_status_property.app_error",
"translation": "Cannot fetch flagged post as the post is not flagged."
@ -5262,6 +5282,10 @@
"id": "app.content_flagging.save_reviewer_settings.app_error",
"translation": "Failed to save content reviewer settings to the database."
},
{
"id": "app.content_flagging.search_common_reviewers.app_error",
"translation": "Failed to search the term in Common Reviewers."
},
{
"id": "app.content_flagging.search_property_fields.app_error",
"translation": "Failed to search Content Flagging property fields."
@ -5270,10 +5294,26 @@
"id": "app.content_flagging.search_property_values.app_error",
"translation": "Failed to fetch post's content flagging property values from the database."
},
{
"id": "app.content_flagging.search_reviewer_posts.app_error",
"translation": "Failed to search reviewer posts for the flagged post."
},
{
"id": "app.content_flagging.search_status_property.app_error",
"translation": "Failed to search Property Values for the flagged post."
},
{
"id": "app.content_flagging.search_sysadmin_reviewers.app_error",
"translation": "Failed to search the search term in System Admin Reviewers."
},
{
"id": "app.content_flagging.search_team_admin_reviewers.app_error",
"translation": "Failed to search the search term in Team Admin Reviewers."
},
{
"id": "app.content_flagging.search_team_reviewers.app_error",
"translation": "Failed to search the search term in Team Reviewers."
},
{
"id": "app.create_basic_user.save_member.app_error",
"translation": "Unable to create default team memberships"

View file

@ -435,4 +435,5 @@ const (
AuditEventPermanentlyRemoveFlaggedPost = "permanentlyRemoveFlaggedPost" // permanently remove flagged post
AuditEventKeepFlaggedPost = "keepFlaggedPost" // keep flagged post
AuditEventUpdateContentFlaggingConfig = "updateContentFlaggingConfig" // update content flagging configuration
AuditEventSetReviewer = "setFlaggedPostReviewer" // assign reviewer for flagged post
)

View file

@ -3532,6 +3532,28 @@ func (c *Client4) GetContentFlaggingSettings(ctx context.Context) (*ContentFlagg
return DecodeJSONFromResponse[*ContentFlaggingSettingsRequest](r)
}
func (c *Client4) AssignContentFlaggingReviewer(ctx context.Context, postId, reviewerId string) (*Response, error) {
r, err := c.DoAPIPost(ctx, fmt.Sprintf("%s/post/%s/assign/%s", c.contentFlaggingRoute(), postId, reviewerId), "")
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
func (c *Client4) SearchContentFlaggingReviewers(ctx context.Context, teamID, term string) ([]*User, *Response, error) {
values := url.Values{}
values.Set("term", term)
r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/team/"+teamID+"/reviewers/search?"+values.Encode(), "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return DecodeJSONFromResponse[[]*User](r)
}
// SearchFiles returns any posts with matching terms string.
func (c *Client4) SearchFiles(ctx context.Context, teamId string, terms string, isOrSearch bool) (*FileInfoList, *Response, error) {
params := SearchParameter{

View file

@ -115,4 +115,5 @@ type PropertyValueSearchOpts struct {
IncludeDeleted bool
Cursor PropertyValueSearchCursor
PerPage int
Value json.RawMessage
}

View file

@ -41,7 +41,7 @@ type MultiSelectProps = {
}
type SingleSelectProps = {
singleSelectOnChange?: (selectedUserIds: string) => void;
singleSelectOnChange?: (selectedUserId: string) => void;
singleSelectInitialValue?: string;
}
@ -52,9 +52,10 @@ type Props = MultiSelectProps & SingleSelectProps & {
hasError?: boolean;
placeholder?: React.ReactNode;
showDropdownIndicator?: boolean;
searchFunc?: (term: string) => Promise<UserProfile[]>;
};
export function UserSelector({id, isMulti, className, multiSelectOnChange, multiSelectInitialValue, singleSelectOnChange, singleSelectInitialValue, hasError, placeholder, showDropdownIndicator}: Props) {
export function UserSelector({id, isMulti, className, multiSelectOnChange, multiSelectInitialValue, singleSelectOnChange, singleSelectInitialValue, hasError, placeholder, showDropdownIndicator, searchFunc}: Props) {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const initialDataLoaded = useRef<boolean>(false);
@ -89,7 +90,7 @@ export function UserSelector({id, isMulti, className, multiSelectOnChange, multi
const noUsersMessage = useCallback(() => formatMessage({id: 'admin.userMultiSelector.noUsers', defaultMessage: 'No users found'}), [formatMessage]);
const defaultPlaceholder = formatMessage({id: 'admin.userMultiSelector.placeholder', defaultMessage: 'Start typing to search for users...'});
const searchUsers = useMemo(() => debounce(async (searchTerm: string, callback) => {
const generalSearchUsers = useMemo(() => debounce(async (searchTerm: string, callback) => {
try {
const response = await dispatch(searchProfiles(searchTerm, {page: 0}));
if (response && response.data && response.data.length > 0) {
@ -112,6 +113,32 @@ export function UserSelector({id, isMulti, className, multiSelectOnChange, multi
}
}, 200), [dispatch]);
const customSearchFunc = useMemo(() => debounce(async (searchTerm: string, callback) => {
if (!searchFunc) {
return null;
}
try {
const response = await searchFunc(searchTerm);
const users = response.
map((user) => ({
value: user.id,
label: user.username,
raw: user,
}));
callback(users);
return null;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
callback([]);
return null;
}
}, 200), [searchFunc]);
const searchUsers = searchFunc ? customSearchFunc : generalSearchUsers;
const multiSelectHandleOnChange = useCallback((value: MultiValue<AutocompleteOptionType<UserProfile>>) => {
const selectedUserIds = value.map((option) => option.value);
multiSelectOnChange?.(selectedUserIds);

View file

@ -52,6 +52,11 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
username: 'reported_post_author',
});
const reviewerUser = TestHelper.getUserMock({
id: 'reviewer_user_id',
username: 'reviewer_user',
});
const reportedPost = TestHelper.getPostMock({
id: 'reported_post_id',
message: 'Hello, world!',
@ -72,6 +77,7 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
profiles: {
[reportingUser.id]: reportingUser,
[reportedPostAuthor.id]: reportedPostAuthor,
[reviewerUser.id]: reviewerUser,
},
},
channels: {
@ -191,7 +197,7 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'reviewer_user_id',
type: 'user',
attrs: null,
attrs: {editable: true},
target_id: '',
target_type: '',
create_at: 1756788661624,
@ -237,7 +243,7 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: 'kd9n7tf9n3ynjczqpkpjkbzgoh',
field_id: contentFlaggingFields.status.id,
value: 'Pending',
create_at: 1756790533486,
update_at: 1756790533486,
@ -248,7 +254,7 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: '5knyqectdfbi98rab3zz4hsyhh',
field_id: contentFlaggingFields.reporting_reason.id,
value: 'Sensitive data',
create_at: 1756790533487,
update_at: 1756790533487,
@ -259,7 +265,7 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: '1is7ir68bp8nup3rr1pp6d7fsr',
field_id: contentFlaggingFields.reporting_user_id.id,
value: reportingUser.id,
create_at: 1756790533487,
update_at: 1756790533487,
@ -270,7 +276,7 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: '5cib5g3ag3gs3gxyg7awjd6csh',
field_id: contentFlaggingFields.reporting_time.id,
value: 1756790533486,
create_at: 1756790533488,
update_at: 1756790533488,
@ -281,12 +287,23 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: 'sx7h53tdsbfb985edkmze71j3c',
field_id: contentFlaggingFields.reporting_comment.id,
value: 'Please review this post for potential violations',
create_at: 1756790533488,
update_at: 1756790533488,
delete_at: 0,
},
{
id: '7azuir6wcf8n5gbmruyat1g7xh',
target_id: 'oxjt9atahbrjugqrd8rgorps6h',
target_type: 'post',
group_id: 'kykzwf98njrbzp89r9s4ey15kh',
field_id: contentFlaggingFields.reviewer_user_id.id,
value: reviewerUser.id,
create_at: 1759732888594,
update_at: 1759743763772,
delete_at: 0,
},
];
beforeEach(() => {

View file

@ -34,7 +34,7 @@ const orderedFieldName = [
'action_time',
'post_preview',
'post_id',
'reviewer',
'reviewer_user_id',
'reporting_user_id',
'reporting_time',
'reporting_comment',
@ -48,7 +48,7 @@ const shortModeFieldOrder = [
'status',
'reporting_reason',
'post_preview',
'reviewer',
'reviewer_user_id',
];
type Props = {
@ -127,7 +127,7 @@ export function DataSpillageReport({post, isRHS}: Props) {
const mode = isRHS ? 'full' : 'short';
const metadata = useMemo<PropertiesCardViewMetadata>(() => {
return {
const fieldMetadata = {
post_preview: {
getPost: loadFlaggedPost,
fetchDeletedPost: true,
@ -136,7 +136,18 @@ export function DataSpillageReport({post, isRHS}: Props) {
placeholder: formatMessage({id: 'data_spillage_report_post.reporting_comment.placeholder', defaultMessage: 'No comment'}),
},
};
}, [formatMessage]);
if (channel) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
fieldMetadata.reviewer_user_id = {
searchUsers: getSearchContentReviewersFunction(channel.team_id),
setUser: saveReviewerSelection(reportedPostId),
};
}
return fieldMetadata;
}, [channel, formatMessage, reportedPostId]);
const footer = useMemo(() => {
if (isRHS) {
@ -190,3 +201,15 @@ export function DataSpillageReport({post, isRHS}: Props) {
async function loadFlaggedPost(postId: string) {
return Client4.getFlaggedPost(postId);
}
function getSearchContentReviewersFunction(teamId: string) {
return (term: string) => {
return Client4.searchContentFlaggingReviewers(term, teamId);
};
}
function saveReviewerSelection(postId: string) {
return (userId: string) => {
return Client4.setContentFlaggingReviewer(postId, userId);
};
}

View file

@ -25,16 +25,6 @@ export function getSyntheticPropertyFields(groupId: string): NameMappedPropertyF
update_at: 0,
delete_at: 0,
},
reviewer: {
id: 'reviewer_field_id',
group_id: groupId,
name: 'reviewer',
type: 'user',
attrs: {editable: true},
create_at: 0,
update_at: 0,
delete_at: 0,
},
channel: {
id: 'channel_field_id',
group_id: groupId,
@ -102,17 +92,6 @@ export function getSyntheticPropertyValues(groupId: string, reportedPostId: stri
update_at: 0,
delete_at: 0,
},
{
id: 'reviewer_user_value_id',
field_id: 'reviewer_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: '',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'channel_value_id',
field_id: 'channel_field_id',

View file

@ -10,6 +10,7 @@ import type {
PropertyField,
PropertyValue,
} from '@mattermost/types/properties';
import type {UserProfile} from '@mattermost/types/users';
import PropertyValueRenderer from './propertyValueRenderer/propertyValueRenderer';
@ -20,11 +21,16 @@ export type PostPreviewFieldMetadata = {
fetchDeletedPost?: boolean;
};
export type UserPropertyMetadata = {
searchUsers?: (term: string) => Promise<UserProfile[]>;
setUser?: (userId: string) => void;
}
export type TextFieldMetadata = {
placeholder?: string;
};
export type FieldMetadata = PostPreviewFieldMetadata | TextFieldMetadata;
export type FieldMetadata = PostPreviewFieldMetadata | TextFieldMetadata | UserPropertyMetadata;
export type PropertiesCardViewMetadata = {
[key: string]: FieldMetadata;
@ -52,7 +58,7 @@ const fieldNameMessages = defineMessages({
id: 'property_card.field.post_id.label',
defaultMessage: 'Post ID',
},
reviewer: {
reviewer_user_id: {
id: 'property_card.field.reviewer_user_id.label',
defaultMessage: 'Reviewer',
},
@ -135,7 +141,9 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
const field = propertyFields[fieldName];
const value = field ? valuesByFieldId.get(field.id) : undefined;
return field && value ? {field, value} : null;
const allowEmptyValue = field?.attrs?.editable;
return field && (value || allowEmptyValue) ? {field, value} : null;
}).
filter((row): row is OrderedRow => row !== null);
}, [fieldOrder, mode, propertyFields, propertyValues, shortModeFieldOrder]);

View file

@ -12,6 +12,7 @@ import type {
FieldMetadata,
PostPreviewFieldMetadata,
TextFieldMetadata,
UserPropertyMetadata,
} from 'components/properties_card_view/properties_card_view';
import ChannelPropertyRenderer from './channel_property_renderer/channel_property_renderer';
@ -45,6 +46,7 @@ export default function PropertyValueRenderer({field, value, metadata}: Props) {
<UserPropertyRenderer
field={field}
value={value}
metadata={metadata as UserPropertyMetadata}
/>
);
case 'select':

View file

@ -1,20 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import type {PropertyField} from '@mattermost/types/properties';
import './selectable_user_property_renderer.scss';
import {UserSelector} from 'components/admin_console/content_flagging/user_multiselector/user_multiselector';
import type {UserPropertyMetadata} from 'components/properties_card_view/properties_card_view';
type Props = {
field: PropertyField;
metadata?: UserPropertyMetadata;
initialValue?: string;
}
export function SelectableUserPropertyRenderer({field}: Props) {
export function SelectableUserPropertyRenderer({field, metadata, initialValue}: Props) {
const {formatMessage} = useIntl();
const [value, setValue] = useState('');
useEffect(() => {
if (initialValue) {
setValue(initialValue);
}
}, [initialValue]);
const placeholder = (
<span className='SelectableUserPropertyRenderer_placeholder'>
<i className='icon icon-account-outline'/>
@ -22,6 +33,13 @@ export function SelectableUserPropertyRenderer({field}: Props) {
</span>
);
const onSelect = useCallback((userId: string) => {
if (metadata?.setUser) {
metadata.setUser(userId);
setValue(userId);
}
}, [metadata]);
return (
<div
className='SelectableUserPropertyRenderer'
@ -32,6 +50,9 @@ export function SelectableUserPropertyRenderer({field}: Props) {
id={`selectable-user-property-renderer-${field.id}`}
placeholder={placeholder}
showDropdownIndicator={true}
searchFunc={metadata?.searchUsers}
singleSelectOnChange={onSelect}
singleSelectInitialValue={value}
/>
</div>
);

View file

@ -109,7 +109,6 @@ describe('UserPropertyRenderer', () => {
renderWithContext(
<UserPropertyRenderer
field={editableField}
value={mockValue}
/>,
baseState,
);

View file

@ -10,6 +10,7 @@ import type {
import {useUser} from 'components/common/hooks/useUser';
import PreviewPostAvatar from 'components/post_view/post_message_preview/avatar/avatar';
import type {UserPropertyMetadata} from 'components/properties_card_view/properties_card_view';
import UserProfileComponent from 'components/user_profile';
import {SelectableUserPropertyRenderer} from './selectable_user_property_renderer';
@ -18,17 +19,20 @@ import './user_property_renderer.scss';
type Props = {
field: PropertyField;
value: PropertyValue<unknown>;
value?: PropertyValue<unknown>;
metadata?: UserPropertyMetadata;
}
export default function UserPropertyRenderer({field, value}: Props) {
const userId = value.value as string;
export default function UserPropertyRenderer({field, value, metadata}: Props) {
const userId = value ? value.value as string : '';
const user = useUser(userId);
if (field.attrs?.editable) {
return (
<SelectableUserPropertyRenderer
field={field}
metadata={metadata}
initialValue={userId}
/>
);
}

View file

@ -4691,6 +4691,20 @@ export default class Client4 {
);
};
searchContentFlaggingReviewers = (term: string, teamId: string) => {
return this.doFetch<UserProfile[]>(
`${this.getContentFlaggingRoute()}/team/${teamId}/reviewers/search${buildQueryString({term})}`,
{method: 'get'},
);
};
setContentFlaggingReviewer = (postId: string, reviewerId: string) => {
return this.doFetch(
`${this.getContentFlaggingRoute()}/post/${postId}/assign/${reviewerId}`,
{method: 'post'},
);
};
saveContentFlaggingConfig = (config: ContentFlaggingSettings) => {
return this.doFetch(
`${this.getContentFlaggingRoute()}/config`,