mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
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:
parent
a8b56ddb60
commit
79756ae1e1
28 changed files with 2316 additions and 140 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(`(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 you’re 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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -115,4 +115,5 @@ type PropertyValueSearchOpts struct {
|
|||
IncludeDeleted bool
|
||||
Cursor PropertyValueSearchCursor
|
||||
PerPage int
|
||||
Value json.RawMessage
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ describe('UserPropertyRenderer', () => {
|
|||
renderWithContext(
|
||||
<UserPropertyRenderer
|
||||
field={editableField}
|
||||
value={mockValue}
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue