mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
Flag post API (#33765)
* Added enable/disable setting and feature flag * added rest of notifgication settings * Added backend for content flagging setting and populated notification values from server side defaults * WIP user selector * Added common reviewers UI * Added additonal reviewers section * WIP * WIP * Team table base * Added search in teams * Added search in teams * Added additional settings section * WIP * Inbtegrated reviewers settings * WIP * WIP * Added server side validation * cleanup * cleanup * [skip ci] * Some refactoring * type fixes * lint fix * test: add content flagging settings test file * test: add comprehensive unit tests for content flagging settings * enhanced tests * test: add test file for content flagging additional settings * test: add comprehensive unit tests for ContentFlaggingAdditionalSettingsSection * Added additoonal settings test * test: add empty test file for team reviewers section * test: add comprehensive unit tests for TeamReviewersSection component * test: update tests to handle async data fetching in team reviewers section * test: add empty test file for content reviewers component * feat: add comprehensive unit tests for ContentFlaggingContentReviewers component * Added ContentFlaggingContentReviewersContentFlaggingContentReviewers test * test: add notification settings test file for content flagging * test: add comprehensive unit tests for content flagging notification settings * Added ContentFlaggingNotificationSettingsSection tests * test: add user profile pill test file * test: add comprehensive unit tests for UserProfilePill component * refactor: Replace enzyme shallow with renderWithContext in user_profile_pill tests * Added UserProfilePill tests * test: add empty test file for content reviewers team option * test: add comprehensive unit tests for TeamOptionComponent * Added TeamOptionComponent tests * test: add empty test file for reason_option component * test: add comprehensive unit tests for ReasonOption component * Added ReasonOption tests * cleanup * Fixed i18n error * fixed e2e test lijnt issues * Updated test cases * Added snaoshot * Updated snaoshot * lint fix * WIP * lint fix * Added post flagging properties setup * review fixes * updated snapshot * CI * Added base APIs * Fetched team status data on load and team switch * WIP * Review fixes * wip * WIP * Removed an test, updated comment * CI * Added tests * Added tests * Lint fix * Added API specs * Fixed types * CI fixes * API tests * lint fixes * Set env variable so API routes are regiustered * Test update * term renaming and disabling API tests on MySQL * typo * Updated store type definition * Minor tweaks * Added tests * Removed error in app startup when content flaghging setup fails * Updated sync condition: * Flag message modal basE * added post preview * displaying options * Adde comment input * Updated tests and docs * finction rename * WIP * Updated tests * refactor * lint fix * MOved to data migration * lint fix * CI * added new migration mocks * Used setup for tests * some comment * Removed unnecesseery nil check * Form validation * WIP tests * WIP tests * WIP tests * fix: mock content flagging config selector with correct reasons format Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat> * fix: add mock for getContentFlaggingConfig in flag post modal test Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat> * Updated error code order in API docs * removed empty files * Added tests * lint fixes * minor tweak * lint fix * type fix * fixed test * nit * test enhancements * API WIP * API WIP * creating values * creating content flagging channel and properties * Able to save properties * 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 * Updated test * fix: reset contentFlaggingGroupId for test isolation in content flagging tests * removed cached group ID * removed debug log * review fixes * Used correct ot name * CI * Updated mobile text * Handled JSON error * fixerdf i18n * CI * 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 * CI --------- Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
This commit is contained in:
parent
84cf95ff6e
commit
c21ef29f02
81 changed files with 4943 additions and 629 deletions
|
|
@ -33,6 +33,7 @@
|
|||
summary: Get content flagging status for a team
|
||||
description: |
|
||||
Returns the content flagging status for a specific team, indicating whether content flagging is enabled on the specified team or not.
|
||||
An enterprise advanced license is required.
|
||||
tags:
|
||||
- Content Flagging
|
||||
parameters:
|
||||
|
|
@ -61,3 +62,197 @@
|
|||
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}/flag:
|
||||
post:
|
||||
summary: Flag a post
|
||||
description: |
|
||||
Flags a post with a reason and a comment. The user must have access to the channel 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 be flagged
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
description: The reason for flagging the post. This must be one of the configured reasons available for selection.
|
||||
comment:
|
||||
type: string
|
||||
description: Comment from the user flagging the post.
|
||||
responses:
|
||||
"200":
|
||||
description: Post flagged 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 flag this post.
|
||||
'404':
|
||||
description: Post 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.
|
||||
/api/v4/content_flagging/fields:
|
||||
get:
|
||||
summary: Get content flagging property fields
|
||||
description: |
|
||||
Returns the list of property fields that can be associated with content flagging reports. These fields are used for storing metadata about a post's flag.
|
||||
An enterprise advanced license is required.
|
||||
tags:
|
||||
- Content Flagging
|
||||
responses:
|
||||
'200':
|
||||
description: Custom fields retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: A map of property field names to their definitions
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/PropertyField"
|
||||
'404':
|
||||
description: 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}/field_values:
|
||||
get:
|
||||
summary: Get content flagging property field values for a post
|
||||
description: |
|
||||
Returns the property field values associated with content flagging reports for a specific post. These values provide additional context about the flags on the post.
|
||||
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 retrieve property field values for
|
||||
responses:
|
||||
'200':
|
||||
description: Property field values retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PropertyValue"
|
||||
description: An array of property field values associated with the post
|
||||
'403':
|
||||
description: Forbidden - User does not have permission to access this post.
|
||||
'404':
|
||||
description: Post 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.
|
||||
/api/v4/content_flagging/post/{post_id}:
|
||||
get:
|
||||
summary: Get a flagged post with all its content.
|
||||
description: |
|
||||
Returns the flagged post with all its data, even if it is soft-deleted. This endpoint is only accessible by content reviewers. A content reviewer can only fetch flagged posts from this API if the post is indeed flagged and they are a content reviewer of the post's team.
|
||||
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 retrieve
|
||||
responses:
|
||||
'200':
|
||||
description: The flagged post is fetched correctly
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Post"
|
||||
'403':
|
||||
description: Forbidden - User does not have permission to access this post, or is not a reviewer of the post's team.
|
||||
'404':
|
||||
description: Post 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.
|
||||
/api/v4/content_flagging/post/{post_id}/remove:
|
||||
put:
|
||||
summary: Remove a flagged post
|
||||
description: |
|
||||
Permanently removes a flagged post and all its associated contents from the system. This action is typically performed by content reviewers after they have reviewed the flagged content. This action is irreversible.
|
||||
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 be removed
|
||||
responses:
|
||||
'200':
|
||||
description: Post removed successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusOK"
|
||||
'403':
|
||||
description: Forbidden - User does not have permission to remove this post.
|
||||
'404':
|
||||
description: Post 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.
|
||||
/api/v4/content_flagging/post/{post_id}/keep:
|
||||
put:
|
||||
summary: Keep a flagged post
|
||||
description: |
|
||||
Marks a flagged post as reviewed and keeps it in the system without any changes. This action is typically performed by content reviewers after they have reviewed the flagged content and determined that it does not violate any guidelines.
|
||||
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 be kept
|
||||
responses:
|
||||
'200':
|
||||
description: Post marked to be kept successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusOK"
|
||||
'403':
|
||||
description: Forbidden - User does not have permission to keep this post.
|
||||
'404':
|
||||
description: Post 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.
|
||||
|
|
|
|||
|
|
@ -452,6 +452,13 @@ func (th *TestHelper) TearDown() {
|
|||
}
|
||||
}
|
||||
|
||||
func (th *TestHelper) RemoveLicense() {
|
||||
err := th.App.Srv().RemoveLicense()
|
||||
if err != nil {
|
||||
th.TestLogger.Warn("TestHelper.RemoveLicense: failed to remove license", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func closeBody(r *http.Response) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package api4
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
|
||||
|
|
@ -20,6 +21,12 @@ func (api *API) InitContentFlagging() {
|
|||
|
||||
api.BaseRoutes.ContentFlagging.Handle("/flag/config", api.APISessionRequired(getFlaggingConfiguration)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/status", api.APISessionRequired(getTeamPostFlaggingFeatureStatus)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/flag", api.APISessionRequired(flagPost)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.ContentFlagging.Handle("/fields", api.APISessionRequired(getContentFlaggingFields)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/field_values", api.APISessionRequired(getPostPropertyValues)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(getFlaggedPost)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(removeFlaggedPost)).Methods(http.MethodPut)
|
||||
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(keepFlaggedPost)).Methods(http.MethodPut)
|
||||
}
|
||||
|
||||
func requireContentFlaggingEnabled(c *Context) {
|
||||
|
|
@ -41,13 +48,40 @@ func getFlaggingConfiguration(c *Context, w http.ResponseWriter, r *http.Request
|
|||
return
|
||||
}
|
||||
|
||||
config := getFlaggingConfig(c.App.Config().ContentFlaggingSettings)
|
||||
// A team ID is expected to be specified bny a content reviewer.
|
||||
// When specified, we verify that the user is a content reviewer of the team.
|
||||
// If the user is indeed a content reviewer, we return the configuration along with some extra fields
|
||||
// that only a reviewer should be aware of.
|
||||
// If no team ID is specified, we return the configuration as is, without the extra fields.
|
||||
// This is the expected usage for non-reviewers.
|
||||
teamId := r.URL.Query().Get("team_id")
|
||||
asReviewer := false
|
||||
if teamId != "" {
|
||||
isReviewer, appErr := c.App.IsUserTeamContentReviewer(c.AppContext.Session().UserId, teamId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(config); err != nil {
|
||||
mlog.Error("failed to encode content flagging configuration to return API response", mlog.Err(err))
|
||||
if !isReviewer {
|
||||
c.Err = model.NewAppError("getFlaggingConfiguration", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
asReviewer = true
|
||||
}
|
||||
|
||||
config := getFlaggingConfig(c.App.Config().ContentFlaggingSettings, asReviewer)
|
||||
|
||||
responseBytes, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getFlaggingConfiguration", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(responseBytes); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -72,16 +106,332 @@ func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http
|
|||
payload := map[string]bool{
|
||||
"enabled": enabled,
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
mlog.Error("failed to encode content flagging configuration to return API response", mlog.Err(err))
|
||||
|
||||
responseBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getTeamPostFlaggingFeatureStatus", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(responseBytes); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings) *model.ContentFlaggingReportingConfig {
|
||||
return &model.ContentFlaggingReportingConfig{
|
||||
func flagPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireContentFlaggingEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var flagRequest model.FlagContentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&flagRequest); err != nil {
|
||||
c.SetInvalidParamWithErr("flagPost", err)
|
||||
return
|
||||
}
|
||||
|
||||
postId := c.Params.PostId
|
||||
userId := c.AppContext.Session().UserId
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventFlagPost, model.AuditStatusFail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
|
||||
|
||||
post, appErr := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
enabled := app.ContentFlaggingEnabledForTeam(c.App.Config(), channel.TeamId)
|
||||
if !enabled {
|
||||
c.Err = model.NewAppError("flagPost", "api.content_flagging.error.not_available_on_team", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
appErr = c.App.FlagPost(c.AppContext, post, channel.TeamId, userId, flagRequest)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventObjectType("post")
|
||||
|
||||
writeOKResponse(w)
|
||||
}
|
||||
|
||||
func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings, asReviewer bool) *model.ContentFlaggingReportingConfig {
|
||||
config := &model.ContentFlaggingReportingConfig{
|
||||
Reasons: contentFlaggingSettings.AdditionalSettings.Reasons,
|
||||
ReporterCommentRequired: contentFlaggingSettings.AdditionalSettings.ReporterCommentRequired,
|
||||
ReviewerCommentRequired: contentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired,
|
||||
}
|
||||
|
||||
if asReviewer {
|
||||
config.NotifyReporterOnRemoval = model.NewPointer(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentRemoved], model.TargetReporter))
|
||||
|
||||
config.NotifyReporterOnDismissal = model.NewPointer(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentDismissed], model.TargetReporter))
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func getContentFlaggingFields(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireContentFlaggingEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
groupId, appErr := c.App.ContentFlaggingGroupId()
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
mappedFields, appErr := c.App.GetContentFlaggingMappedFields(groupId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
responseBytes, err := json.Marshal(mappedFields)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getContentFlaggingFields", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(responseBytes); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getPostPropertyValues(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireContentFlaggingEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// The requesting user must be a reviewer of the post's team
|
||||
// to be able to fetch the post's Content Flagging property values
|
||||
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
|
||||
}
|
||||
|
||||
userId := c.AppContext.Session().UserId
|
||||
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, channel.TeamId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if !isReviewer {
|
||||
c.Err = model.NewAppError("getPostPropertyValues", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
propertyValues, appErr := c.App.GetPostContentFlaggingPropertyValues(postId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
responseBytes, err := json.Marshal(propertyValues)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getPostPropertyValues", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(responseBytes); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireContentFlaggingEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// A user can obtain a flagged post if-
|
||||
// 1. The post is currently flagged and in any status
|
||||
// 2. The user is a reviewer of the post's team
|
||||
|
||||
// check if user is a reviewer of the post's team
|
||||
postId := c.Params.PostId
|
||||
userId := c.AppContext.Session().UserId
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventGetFlaggedPost, model.AuditStatusFail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, channel.TeamId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if !isReviewer {
|
||||
c.Err = model.NewAppError("getFlaggedPost", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// This validates that the post is flagged
|
||||
_, appErr = c.App.GetPostContentFlaggingStatusValue(postId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true, RetainContent: true})
|
||||
post, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if err := post.EncodeJSON(w); err != nil {
|
||||
c.Err = model.NewAppError("getFlaggedPost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func removeFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
|
||||
if c.Err != nil {
|
||||
c.Err.Where = "removeFlaggedPost"
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventPermanentlyRemoveFlaggedPost, model.AuditStatusFail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
|
||||
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
|
||||
|
||||
if appErr := c.App.PermanentDeleteFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
writeOKResponse(w)
|
||||
}
|
||||
|
||||
func keepFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
|
||||
if c.Err != nil {
|
||||
c.Err.Where = "keepFlaggedPost"
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventKeepFlaggedPost, model.AuditStatusFail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
|
||||
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
|
||||
|
||||
if appErr := c.App.KeepFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
writeOKResponse(w)
|
||||
}
|
||||
|
||||
func keepRemoveFlaggedPostChecks(c *Context, r *http.Request) (*model.FlagContentActionRequest, string, *model.Post) {
|
||||
requireContentFlaggingEnabled(c)
|
||||
if c.Err != nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
var actionRequest model.FlagContentActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&actionRequest); err != nil {
|
||||
c.SetInvalidParamWithErr("flagContentActionRequestBody", err)
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
postId := c.Params.PostId
|
||||
userId := c.AppContext.Session().UserId
|
||||
|
||||
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, channel.TeamId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
if !isReviewer {
|
||||
c.Err = model.NewAppError("", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
commentRequired := c.App.Config().ContentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired
|
||||
if err := actionRequest.IsValid(*commentRequired); err != nil {
|
||||
c.Err = err
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
return &actionRequest, userId, post
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ func TestGetFlaggingConfiguration(t *testing.T) {
|
|||
|
||||
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()
|
||||
|
|
@ -40,6 +42,8 @@ func TestGetFlaggingConfiguration(t *testing.T) {
|
|||
|
||||
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()
|
||||
|
|
@ -52,6 +56,360 @@ func TestGetFlaggingConfiguration(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGetPostPropertyValues(t *testing.T) {
|
||||
mainHelper.Parallel(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))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
require.Nil(t, propertyValues)
|
||||
})
|
||||
|
||||
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
require.Nil(t, propertyValues)
|
||||
})
|
||||
|
||||
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), model.NewId())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
require.Nil(t, propertyValues)
|
||||
})
|
||||
|
||||
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Nil(t, propertyValues)
|
||||
})
|
||||
|
||||
t.Run("Should successfully get property values when user is a reviewer", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
response, err := client.FlagPostForContentReview(context.Background(), post.Id, &model.FlagContentRequest{
|
||||
Reason: "Sensitive data",
|
||||
Comment: "This is sensitive content",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
|
||||
// Now get the property values
|
||||
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NotNil(t, propertyValues)
|
||||
require.Len(t, *propertyValues, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFlaggedPost(t *testing.T) {
|
||||
mainHelper.Parallel(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))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
require.Nil(t, flaggedPost)
|
||||
})
|
||||
|
||||
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
require.Nil(t, flaggedPost)
|
||||
})
|
||||
|
||||
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), model.NewId())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
require.Nil(t, flaggedPost)
|
||||
})
|
||||
|
||||
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
// Set up config so user is not a reviewer
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{}
|
||||
(*config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting)[th.BasicTeam.Id] = model.TeamReviewerSetting{
|
||||
Enabled: model.NewPointer(true),
|
||||
ReviewerIds: &[]string{}, // Empty list - user is not a reviewer
|
||||
}
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Nil(t, flaggedPost)
|
||||
})
|
||||
|
||||
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
require.Nil(t, flaggedPost)
|
||||
})
|
||||
|
||||
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))
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
|
||||
// First flag the post
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "Sensitive data",
|
||||
Comment: "This is sensitive content",
|
||||
}
|
||||
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Now get the flagged post
|
||||
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NotNil(t, flaggedPost)
|
||||
require.Equal(t, post.Id, flaggedPost.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlagPost(t *testing.T) {
|
||||
mainHelper.Parallel(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()
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "This is spam content",
|
||||
}
|
||||
|
||||
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
|
||||
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()
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "This is spam content",
|
||||
}
|
||||
|
||||
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
|
||||
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()
|
||||
})
|
||||
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "This is spam content",
|
||||
}
|
||||
|
||||
resp, err := client.FlagPostForContentReview(context.Background(), model.NewId(), flagRequest)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Should return 403 when user does not have permission to view post", 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()
|
||||
})
|
||||
|
||||
// Create a private channel and post
|
||||
privateChannel := th.CreatePrivateChannel()
|
||||
post := th.CreatePostWithClient(th.Client, privateChannel)
|
||||
th.RemoveUserFromChannel(th.BasicUser, privateChannel)
|
||||
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "This is spam content",
|
||||
}
|
||||
|
||||
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Should return 400 when content flagging is not enabled for the team", 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()
|
||||
// Set up config so content flagging is not enabled for this team
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{}
|
||||
(*config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting)[th.BasicTeam.Id] = model.TeamReviewerSetting{Enabled: model.NewPointer(false)}
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "This is spam content",
|
||||
}
|
||||
|
||||
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Should successfully flag a post when all conditions are met", 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()
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
})
|
||||
|
||||
post := th.CreatePost()
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "Sensitive data",
|
||||
Comment: "This is sensitive data",
|
||||
}
|
||||
|
||||
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
|
|
@ -66,6 +424,8 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
|
|||
|
||||
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()
|
||||
|
|
@ -79,6 +439,8 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
|
|||
|
||||
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()
|
||||
|
|
@ -92,6 +454,8 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
|
|||
|
||||
t.Run("Should return Forbidden error when calling for a team without the team membership", 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()
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
rp = model.AddPostActionCookies(rp, c.App.PostActionCookieSecret())
|
||||
rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, true, false, true)
|
||||
rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
|
||||
rp, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
@ -454,7 +454,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, false, false, true)
|
||||
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
|
||||
post, err = c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
@ -518,7 +518,7 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
continue
|
||||
}
|
||||
|
||||
post = c.App.PreparePostForClient(c.AppContext, post, false, false, true)
|
||||
post = c.App.PreparePostForClient(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
|
||||
post.StripActionIntegrations()
|
||||
posts = append(posts, post)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,10 @@ func (a *App) CreateBot(rctx request.CTX, bot *model.Bot) (*model.Bot, *model.Ap
|
|||
}
|
||||
|
||||
func (a *App) GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError) {
|
||||
return a.GetOrCreateSystemOwnedBot(rctx, model.BotSystemBotUsername, i18n.T("app.system.system_bot.bot_displayname"))
|
||||
}
|
||||
|
||||
func (a *App) GetOrCreateSystemOwnedBot(rctx request.CTX, botUsername, botDisplayName string) (*model.Bot, *model.AppError) {
|
||||
perPage := 1
|
||||
userOptions := &model.UserGetOptions{
|
||||
Page: 0,
|
||||
|
|
@ -183,10 +187,9 @@ func (a *App) GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError) {
|
|||
return nil, model.NewAppError("GetSystemBot", "app.bot.get_system_bot.empty_admin_list.app_error", nil, "", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
T := i18n.GetUserTranslations(sysAdminList[0].Locale)
|
||||
systemBot := &model.Bot{
|
||||
Username: model.BotSystemBotUsername,
|
||||
DisplayName: T("app.system.system_bot.bot_displayname"),
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayName,
|
||||
Description: "",
|
||||
OwnerId: sysAdminList[0].Id,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,28 @@
|
|||
|
||||
package app
|
||||
|
||||
import "github.com/mattermost/mattermost/server/public/model"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/public/utils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
CONTENT_FLAGGING_MAX_PROPERTY_FIELDS = 100
|
||||
CONTENT_FLAGGING_MAX_PROPERTY_VALUES = 100
|
||||
|
||||
POST_PROP_KEY_FLAGGED_POST_ID = "reported_post_id"
|
||||
)
|
||||
|
||||
func ContentFlaggingEnabledForTeam(config *model.Config, teamId string) bool {
|
||||
reviewerSettings := config.ContentFlaggingSettings.ReviewerSettings
|
||||
|
|
@ -27,3 +48,665 @@ func ContentFlaggingEnabledForTeam(config *model.Config, teamId string) bool {
|
|||
|
||||
return hasAdditionalReviewers
|
||||
}
|
||||
|
||||
func (a *App) FlagPost(rctx request.CTX, post *model.Post, teamId, reportingUserId string, flagData model.FlagContentRequest) *model.AppError {
|
||||
commentBytes, err := json.Marshal(flagData.Comment)
|
||||
if err != nil {
|
||||
return model.NewAppError("FlagPost", "app.content_flagging.flag_post.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
|
||||
// generating unsafe JSON values
|
||||
commentJsonValue := json.RawMessage(commentBytes)
|
||||
|
||||
reasonJson, err := json.Marshal(flagData.Reason)
|
||||
if err != nil {
|
||||
return model.NewAppError("FlagPost", "app.content_flagging.flag_post.marshal_reason.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
|
||||
// generating unsafe JSON values
|
||||
reasonJsonValue := json.RawMessage(reasonJson)
|
||||
|
||||
commentRequired := a.Config().ContentFlaggingSettings.AdditionalSettings.ReporterCommentRequired
|
||||
validReasons := a.Config().ContentFlaggingSettings.AdditionalSettings.Reasons
|
||||
if appErr := flagData.IsValid(*commentRequired, *validReasons); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
groupId, appErr := a.ContentFlaggingGroupId()
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
reportingUser, appErr := a.GetUser(reportingUserId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
appErr = a.canFlagPost(groupId, post.Id, reportingUser.Locale)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
propertyValues := []*model.PropertyValue{
|
||||
{
|
||||
TargetID: post.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameStatus].ID,
|
||||
Value: json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusPending)),
|
||||
},
|
||||
{
|
||||
TargetID: post.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingUserID].ID,
|
||||
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reportingUserId)),
|
||||
},
|
||||
{
|
||||
TargetID: post.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingReason].ID,
|
||||
Value: reasonJsonValue,
|
||||
},
|
||||
{
|
||||
TargetID: post.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingComment].ID,
|
||||
Value: commentJsonValue,
|
||||
},
|
||||
{
|
||||
TargetID: post.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingTime].ID,
|
||||
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
|
||||
},
|
||||
}
|
||||
|
||||
_, err = a.Srv().propertyService.CreatePropertyValues(propertyValues)
|
||||
if err != nil {
|
||||
return model.NewAppError("FlagPostForContentReview", "app.content_flagging.create_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
contentReviewBot, appErr := a.getContentReviewBot(rctx)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if *a.Config().ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent {
|
||||
_, appErr = a.DeletePost(rctx, post.Id, contentReviewBot.UserId)
|
||||
if appErr != nil {
|
||||
return model.NewAppError("FlagPostForContentReview", "app.content_flagging.delete_post.app_error", nil, appErr.Error(), http.StatusInternalServerError).Wrap(appErr)
|
||||
}
|
||||
}
|
||||
|
||||
a.Srv().Go(func() {
|
||||
appErr = a.createContentReviewPost(rctx, post.Id, teamId, reportingUserId, flagData.Reason, post.ChannelId, post.UserId)
|
||||
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))
|
||||
}
|
||||
})
|
||||
|
||||
return a.sendContentFlaggingConfirmationMessage(rctx, reportingUserId, post.UserId, post.ChannelId)
|
||||
}
|
||||
|
||||
func (a *App) ContentFlaggingGroupId() (string, *model.AppError) {
|
||||
group, err := a.Srv().propertyService.GetPropertyGroup(model.ContentFlaggingGroupName)
|
||||
if err != nil {
|
||||
return "", model.NewAppError("getContentFlaggingGroupId", "app.content_flagging.get_group.error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return group.ID, nil
|
||||
}
|
||||
|
||||
func (a *App) GetPostContentFlaggingStatusValue(postId string) (*model.PropertyValue, *model.AppError) {
|
||||
groupId, appErr := a.ContentFlaggingGroupId()
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
statusPropertyField, err := a.Srv().propertyService.GetPropertyFieldByName(groupId, "", contentFlaggingPropertyNameStatus)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetPostContentFlaggingStatusValue", "app.content_flagging.get_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
searchOptions := model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES, FieldID: statusPropertyField.ID}
|
||||
propertyValues, err := a.Srv().propertyService.SearchPropertyValues(groupId, searchOptions)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetPostContentFlaggingStatusValue", "app.content_flagging.search_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if len(propertyValues) == 0 {
|
||||
return nil, model.NewAppError("GetPostContentFlaggingStatusValue", "app.content_flagging.no_status_property.app_error", nil, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
return propertyValues[0], nil
|
||||
}
|
||||
|
||||
func (a *App) canFlagPost(groupId, postId, userLocal string) *model.AppError {
|
||||
status, appErr := a.GetPostContentFlaggingStatusValue(postId)
|
||||
if appErr != nil {
|
||||
if appErr.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return appErr
|
||||
}
|
||||
|
||||
var reason string
|
||||
T := i18n.GetUserTranslations(userLocal)
|
||||
|
||||
switch strings.Trim(string(status.Value), `"`) {
|
||||
case model.ContentFlaggingStatusPending, model.ContentFlaggingStatusAssigned:
|
||||
reason = T("app.content_flagging.can_flag_post.in_progress")
|
||||
case model.ContentFlaggingStatusRetained:
|
||||
reason = T("app.content_flagging.can_flag_post.retained")
|
||||
case model.ContentFlaggingStatusRemoved:
|
||||
reason = T("app.content_flagging.can_flag_post.removed")
|
||||
default:
|
||||
reason = T("app.content_flagging.can_flag_post.unknown")
|
||||
}
|
||||
|
||||
return model.NewAppError("canFlagPost", reason, nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (a *App) GetContentFlaggingMappedFields(groupId string) (map[string]*model.PropertyField, *model.AppError) {
|
||||
fields, err := a.Srv().propertyService.SearchPropertyFields(groupId, model.PropertyFieldSearchOpts{PerPage: CONTENT_FLAGGING_MAX_PROPERTY_FIELDS})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetContentFlaggingMappedFields", "app.content_flagging.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
mappedFields := map[string]*model.PropertyField{}
|
||||
for _, field := range fields {
|
||||
mappedFields[field.Name] = field
|
||||
}
|
||||
|
||||
return mappedFields, nil
|
||||
}
|
||||
|
||||
func (a *App) createContentReviewPost(rctx request.CTX, reportedPostId, teamId, reportingUserId, reportingReason, flaggedPostChannelId, flaggedPostAuthorId string) *model.AppError {
|
||||
contentReviewBot, appErr := a.getContentReviewBot(rctx)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
channels, appErr := a.getContentReviewChannels(rctx, teamId, contentReviewBot.UserId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
reportingUser, appErr := a.GetUser(reportingUserId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
flaggedPostChannel, appErr := a.GetChannel(rctx, flaggedPostChannelId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
flaggedPostTeam, appErr := a.GetTeam(flaggedPostChannel.TeamId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
message := fmt.Sprintf(
|
||||
"@%s flagged a message for review.\n\nReason: %s\nChannel: ~%s\nTeam: %s\nPost Author: @%s\n\nOpen on a web browser or the Desktop app to view the full report and take action.",
|
||||
reportingUser.Username,
|
||||
reportingReason,
|
||||
flaggedPostChannel.Name,
|
||||
flaggedPostTeam.DisplayName,
|
||||
flaggedPostAuthor.Username,
|
||||
)
|
||||
|
||||
for _, channel := range channels {
|
||||
post := &model.Post{
|
||||
Message: message,
|
||||
UserId: contentReviewBot.UserId,
|
||||
Type: model.ContentFlaggingPostType,
|
||||
ChannelId: channel.Id,
|
||||
}
|
||||
post.AddProp(POST_PROP_KEY_FLAGGED_POST_ID, reportedPostId)
|
||||
_, 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
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) getContentReviewChannels(rctx request.CTX, teamId, contentReviewBotId string) ([]*model.Channel, *model.AppError) {
|
||||
reviewersUserIDs, appErr := a.getReviewersForTeam(teamId, true)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
var channels []*model.Channel
|
||||
for _, userId := range reviewersUserIDs {
|
||||
channel, appErr := a.GetOrCreateDirectChannel(rctx, userId, contentReviewBotId)
|
||||
if appErr != nil {
|
||||
// Don't stop processing other reviewers if one fails
|
||||
rctx.Logger().Error("Failed to get or create direct channel for one of the reviewers and content review bot", mlog.Err(appErr), mlog.String("user_id", userId), mlog.String("bot_id", contentReviewBotId))
|
||||
}
|
||||
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (a *App) getContentReviewBot(rctx request.CTX) (*model.Bot, *model.AppError) {
|
||||
return a.GetOrCreateSystemOwnedBot(rctx, model.ContentFlaggingBotUsername, i18n.T("app.system.content_review_bot.bot_displayname"))
|
||||
}
|
||||
|
||||
func (a *App) getReviewersForTeam(teamId string, includeAdditionalReviewers bool) ([]string, *model.AppError) {
|
||||
reviewerUserIDMap := map[string]bool{}
|
||||
|
||||
reviewerSettings := a.Config().ContentFlaggingSettings.ReviewerSettings
|
||||
|
||||
if *reviewerSettings.CommonReviewers {
|
||||
for _, userID := range *reviewerSettings.CommonReviewerIds {
|
||||
reviewerUserIDMap[userID] = true
|
||||
}
|
||||
} else {
|
||||
// If common reviewers are not enabled, we still need to check if the team has specific reviewers
|
||||
teamSettings, exist := (*reviewerSettings.TeamReviewersSetting)[teamId]
|
||||
if exist && *teamSettings.Enabled && teamSettings.ReviewerIds != nil {
|
||||
for _, userID := range *teamSettings.ReviewerIds {
|
||||
reviewerUserIDMap[userID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if includeAdditionalReviewers {
|
||||
var additionalReviewers []*model.User
|
||||
if *reviewerSettings.TeamAdminsAsReviewers {
|
||||
teamAdminReviewers, appErr := a.getAllUsersInTeamForRoles(teamId, nil, []string{model.TeamAdminRoleId})
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
additionalReviewers = append(additionalReviewers, teamAdminReviewers...)
|
||||
}
|
||||
|
||||
if *reviewerSettings.SystemAdminsAsReviewers {
|
||||
sysAdminReviewers, appErr := a.getAllUsersInTeamForRoles(teamId, []string{model.SystemAdminRoleId}, nil)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
additionalReviewers = append(additionalReviewers, sysAdminReviewers...)
|
||||
}
|
||||
|
||||
for _, user := range additionalReviewers {
|
||||
reviewerUserIDMap[user.Id] = true
|
||||
}
|
||||
}
|
||||
|
||||
reviewerUserIDs := make([]string, 0, len(reviewerUserIDMap))
|
||||
for userID := range maps.Keys(reviewerUserIDMap) {
|
||||
reviewerUserIDs = append(reviewerUserIDs, userID)
|
||||
}
|
||||
|
||||
return reviewerUserIDs, nil
|
||||
}
|
||||
|
||||
func (a *App) getAllUsersInTeamForRoles(teamId string, systemRoles, teamRoles []string) ([]*model.User, *model.AppError) {
|
||||
var additionalReviewers []*model.User
|
||||
|
||||
options := &model.UserGetOptions{
|
||||
InTeamId: teamId,
|
||||
Page: 0,
|
||||
PerPage: 100,
|
||||
Active: true,
|
||||
Roles: systemRoles,
|
||||
TeamRoles: teamRoles,
|
||||
}
|
||||
|
||||
fetchFunc := func(page int) ([]*model.User, error) {
|
||||
options.Page = page
|
||||
users, appErr := a.GetUsersInTeam(options)
|
||||
// Checking for error this way instead of directly returning *model.AppError
|
||||
// doesn't equate to error == nil (pointer vs non-pointer)
|
||||
if appErr != nil {
|
||||
return users, errors.New(appErr.Error())
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
additionalReviewers, err := utils.Pager(fetchFunc, options.PerPage)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("getReviewersForTeam", "app.content_flagging.get_users_in_team.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return additionalReviewers, nil
|
||||
}
|
||||
|
||||
func (a *App) sendContentFlaggingConfirmationMessage(rctx request.CTX, flaggingUserId, flaggedPostAuthorId, channelID string) *model.AppError {
|
||||
flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
T := i18n.GetUserTranslations(flaggedPostAuthor.Locale)
|
||||
post := &model.Post{
|
||||
Message: T("app.content_flagging.flag_post_confirmation.message", map[string]any{"username": flaggedPostAuthor.Username}),
|
||||
ChannelId: channelID,
|
||||
}
|
||||
|
||||
a.SendEphemeralPost(rctx, flaggingUserId, post)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) IsUserTeamContentReviewer(userId, teamId string) (bool, *model.AppError) {
|
||||
// not fetching additional reviewers because if the user exist in common or team
|
||||
// specific reviewers, they are definitely a reviewer, and it saves multiple database calls.
|
||||
reviewers, appErr := a.getReviewersForTeam(teamId, false)
|
||||
if appErr != nil {
|
||||
return false, appErr
|
||||
}
|
||||
|
||||
if slices.Contains(reviewers, userId) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// if user is not in common or team specific reviewers, we need to check if they are
|
||||
// an additional reviewer.
|
||||
reviewers, appErr = a.getReviewersForTeam(teamId, true)
|
||||
if appErr != nil {
|
||||
return false, appErr
|
||||
}
|
||||
|
||||
return slices.Contains(reviewers, userId), nil
|
||||
}
|
||||
|
||||
func (a *App) GetPostContentFlaggingPropertyValues(postId string) ([]*model.PropertyValue, *model.AppError) {
|
||||
groupId, appErr := a.ContentFlaggingGroupId()
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
propertyValues, err := a.Srv().propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetPostContentFlaggingPropertyValues", "app.content_flagging.search_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return propertyValues, nil
|
||||
}
|
||||
|
||||
func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.FlagContentActionRequest, reviewerId string, flaggedPost *model.Post) *model.AppError {
|
||||
// when a flagged post is removed, the following things need to be done
|
||||
// 1. Hard delete corresponding file infos
|
||||
// 2. Hard delete file infos associated to post's edit history
|
||||
// 3. Hard delete post's edit history
|
||||
// 4. Hard delete the files from file storage
|
||||
// 5. Hard delete post's priority data
|
||||
// 6. Hard delete post's post acknowledgements
|
||||
// 7. Hard delete post reminders
|
||||
// 8. Scrub the post's content - message, props
|
||||
|
||||
commentBytes, jsonErr := json.Marshal(actionRequest.Comment)
|
||||
if jsonErr != nil {
|
||||
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.permanently_delete.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
|
||||
}
|
||||
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
|
||||
// generating unsafe JSON values
|
||||
commentJsonValue := json.RawMessage(commentBytes)
|
||||
|
||||
status, appErr := a.GetPostContentFlaggingStatusValue(flaggedPost.Id)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
statusValue := strings.Trim(string(status.Value), `"`)
|
||||
if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned {
|
||||
return model.NewAppError("removeFlaggedPost", "api.content_flagging.error.post_not_in_progress", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
editHistories, appErr := a.GetEditHistoryForPost(flaggedPost.Id)
|
||||
if appErr != nil {
|
||||
//editHistories = []*model.Post{}
|
||||
|
||||
if appErr.StatusCode != http.StatusNotFound {
|
||||
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to get edit history for flaggedPost", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
}
|
||||
|
||||
for _, editHistory := range editHistories {
|
||||
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, editHistory.Id); filesDeleteAppErr != nil {
|
||||
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete files for one of the edit history posts", mlog.Err(filesDeleteAppErr), mlog.String("post_id", editHistory.Id))
|
||||
}
|
||||
|
||||
if deletePostAppErr := a.PermanentDeletePost(rctx, editHistory.Id, reviewerId); deletePostAppErr != nil {
|
||||
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete one of the edit history posts", mlog.Err(deletePostAppErr), mlog.String("post_id", editHistory.Id))
|
||||
}
|
||||
}
|
||||
|
||||
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, flaggedPost.Id); filesDeleteAppErr != nil {
|
||||
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete files for the flaggedPost", mlog.Err(filesDeleteAppErr), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
|
||||
if err := a.DeletePriorityForPost(flaggedPost.Id); err != nil {
|
||||
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost priority for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().PostAcknowledgement().DeleteAllForPost(flaggedPost.Id); err != nil {
|
||||
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost acknowledgements for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Post().DeleteAllPostRemindersForPost(flaggedPost.Id); err != nil {
|
||||
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost reminders for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
|
||||
scrubPost(flaggedPost)
|
||||
_, err := a.Srv().Store().Post().Overwrite(rctx, flaggedPost)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.permanently_delete.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
groupId, appErr := a.ContentFlaggingGroupId()
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
propertyValues := []*model.PropertyValue{
|
||||
{
|
||||
TargetID: flaggedPost.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameActorUserID].ID,
|
||||
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
|
||||
},
|
||||
{
|
||||
TargetID: flaggedPost.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
|
||||
Value: commentJsonValue,
|
||||
},
|
||||
{
|
||||
TargetID: flaggedPost.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameActionTime].ID,
|
||||
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
|
||||
},
|
||||
}
|
||||
|
||||
_, err = a.Srv().propertyService.CreatePropertyValues(propertyValues)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.create_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRemoved))
|
||||
_, err = a.Srv().propertyService.UpdatePropertyValue(groupId, status)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.permanently_delete.update_property_value.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
a.Srv().Go(func() {
|
||||
channel, appErr := a.GetChannel(rctx, flaggedPost.ChannelId)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Error("Failed to get channel for flagged post while publishing report change after permanently removing flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id), mlog.String("channel_id", flaggedPost.ChannelId))
|
||||
return
|
||||
}
|
||||
|
||||
propertyValues = append(propertyValues, status)
|
||||
if err := a.publishContentFlaggingReportUpdateEvent(flaggedPost.Id, channel.TeamId, propertyValues); err != nil {
|
||||
rctx.Logger().Error("Failed to publish report change after permanently removing flagged post", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContentActionRequest, reviewerId string, flaggedPost *model.Post) *model.AppError {
|
||||
// for keeping a flagged flaggedPost we need to-
|
||||
// 1. Undelete the flaggedPost if it was deleted, that's it
|
||||
|
||||
status, appErr := a.GetPostContentFlaggingStatusValue(flaggedPost.Id)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
statusValue := strings.Trim(string(status.Value), `"`)
|
||||
if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned {
|
||||
return model.NewAppError("removeFlaggedPost", "api.content_flagging.error.post_not_in_progress", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if flaggedPost.DeleteAt > 0 {
|
||||
flaggedPost.DeleteAt = 0
|
||||
flaggedPost.UpdateAt = model.GetMillis()
|
||||
flaggedPost.PreCommit()
|
||||
_, err := a.Srv().Store().Post().Overwrite(rctx, flaggedPost)
|
||||
if err != nil {
|
||||
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.keep_post.undelete.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
err = a.Srv().Store().FileInfo().RestoreForPostByIds(rctx, flaggedPost.Id, flaggedPost.FileIds)
|
||||
if err != nil {
|
||||
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.restore_file_info.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
groupId, appErr := a.ContentFlaggingGroupId()
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
commentBytes, err := json.Marshal(actionRequest.Comment)
|
||||
if err != nil {
|
||||
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.keep_flag_post.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
|
||||
// generating unsafe JSON values
|
||||
commentJsonValue := json.RawMessage(commentBytes)
|
||||
|
||||
propertyValues := []*model.PropertyValue{
|
||||
{
|
||||
TargetID: flaggedPost.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameActorUserID].ID,
|
||||
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
|
||||
},
|
||||
{
|
||||
TargetID: flaggedPost.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
|
||||
Value: commentJsonValue,
|
||||
},
|
||||
{
|
||||
TargetID: flaggedPost.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupId,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameActionTime].ID,
|
||||
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
|
||||
},
|
||||
}
|
||||
|
||||
_, err = a.Srv().propertyService.CreatePropertyValues(propertyValues)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.create_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRetained))
|
||||
_, err = a.Srv().propertyService.UpdatePropertyValue(groupId, status)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.keep_post.status_update.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
a.Srv().Go(func() {
|
||||
channel, getChannelErr := a.GetChannel(rctx, flaggedPost.ChannelId)
|
||||
if getChannelErr != nil {
|
||||
rctx.Logger().Error("Failed to get channel for flagged post while publishing report change after permanently removing flagged post", mlog.Err(getChannelErr), mlog.String("post_id", flaggedPost.Id), mlog.String("channel_id", flaggedPost.ChannelId))
|
||||
return
|
||||
}
|
||||
|
||||
propertyValues = append(propertyValues, status)
|
||||
if err := a.publishContentFlaggingReportUpdateEvent(flaggedPost.Id, channel.TeamId, propertyValues); err != nil {
|
||||
rctx.Logger().Error("Failed to publish report change after permanently removing flagged flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
})
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", flaggedPost.ChannelId, "", nil, "")
|
||||
appErr = a.publishWebsocketEventForPost(rctx, flaggedPost, message)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Error("Failed to publish websocket event for post edit while keeping flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
|
||||
}
|
||||
a.invalidateCacheForChannelPosts(flaggedPost.ChannelId)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scrubPost(post *model.Post) {
|
||||
post.Message = "*Content deleted as part of Content Flagging review process*"
|
||||
post.MessageSource = post.Message
|
||||
post.Hashtags = ""
|
||||
post.Metadata = nil
|
||||
post.FileIds = []string{}
|
||||
post.SetProps(make(map[string]any))
|
||||
}
|
||||
|
||||
func (a *App) publishContentFlaggingReportUpdateEvent(targetId, teamId string, propertyValues []*model.PropertyValue) *model.AppError {
|
||||
reviewersUserIDs, appErr := a.getReviewersForTeam(teamId, true)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(propertyValues)
|
||||
if err != nil {
|
||||
return model.NewAppError("publishContentFlaggingReportUpdateEvent", "app.content_flagging.marshal_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
for _, userId := range reviewersUserIDs {
|
||||
message := model.NewWebSocketEvent(model.WebsocketContentFlaggingReportValueUpdated, "", "", userId, nil, "")
|
||||
message.Add("property_values", string(bytes))
|
||||
message.Add("target_id", targetId)
|
||||
a.Publish(message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,21 +4,26 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestContentFlaggingEnabledForTeam(t *testing.T) {
|
||||
getBaseConfig := func() *model.Config {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
var getBaseConfig = func() *model.Config {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
return &model.Config{
|
||||
ContentFlaggingSettings: contentFlaggingSettings,
|
||||
}
|
||||
return &model.Config{
|
||||
ContentFlaggingSettings: contentFlaggingSettings,
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentFlaggingEnabledForTeam(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("should return true for common reviewers", func(t *testing.T) {
|
||||
config := getBaseConfig()
|
||||
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
|
|
@ -70,3 +75,748 @@ func TestContentFlaggingEnabledForTeam(t *testing.T) {
|
|||
require.True(t, status)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetContentReviewChannels(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
|
||||
baseConfig := model.ContentFlaggingSettings{}
|
||||
baseConfig.SetDefaults()
|
||||
baseConfig.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
|
||||
baseConfig.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
|
||||
baseConfig.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
baseConfig.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id, th.BasicUser2.Id}
|
||||
|
||||
t.Run("should return channels for common reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings = baseConfig
|
||||
})
|
||||
|
||||
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, contentReviewBot)
|
||||
|
||||
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, channels, 2)
|
||||
|
||||
for _, channel := range channels {
|
||||
require.Equal(t, model.ChannelTypeDirect, channel.Type)
|
||||
otherUserId := channel.GetOtherUserIdForDM(contentReviewBot.UserId)
|
||||
require.True(t, otherUserId == th.BasicUser.Id || otherUserId == th.BasicUser2.Id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should return channels for system admins as additional reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings = baseConfig
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
|
||||
})
|
||||
|
||||
// Sysadmin explicitly need to be a team member to be returned as reviewer
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
defer func() {
|
||||
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
}()
|
||||
|
||||
require.Nil(t, appErr)
|
||||
|
||||
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, contentReviewBot)
|
||||
|
||||
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, channels, 3)
|
||||
|
||||
require.Equal(t, model.ChannelTypeDirect, channels[0].Type)
|
||||
require.Equal(t, model.ChannelTypeDirect, channels[1].Type)
|
||||
require.Equal(t, model.ChannelTypeDirect, channels[2].Type)
|
||||
|
||||
reviewerIds := []string{
|
||||
channels[0].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
channels[1].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
channels[2].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
}
|
||||
require.Contains(t, reviewerIds, th.BasicUser.Id)
|
||||
require.Contains(t, reviewerIds, th.BasicUser2.Id)
|
||||
require.Contains(t, reviewerIds, th.SystemAdminUser.Id)
|
||||
})
|
||||
|
||||
t.Run("should return channels for team admins as additional reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings = baseConfig
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, contentReviewBot)
|
||||
|
||||
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, channels, 3)
|
||||
|
||||
require.Equal(t, model.ChannelTypeDirect, channels[0].Type)
|
||||
require.Equal(t, model.ChannelTypeDirect, channels[1].Type)
|
||||
require.Equal(t, model.ChannelTypeDirect, channels[2].Type)
|
||||
|
||||
reviewerIds := []string{
|
||||
channels[0].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
channels[1].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
channels[2].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
}
|
||||
require.Contains(t, reviewerIds, th.BasicUser.Id)
|
||||
require.Contains(t, reviewerIds, th.BasicUser2.Id)
|
||||
require.Contains(t, reviewerIds, teamAdmin.Id)
|
||||
})
|
||||
|
||||
t.Run("should return channels for team reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings = baseConfig
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
|
||||
th.BasicTeam.Id: {
|
||||
Enabled: model.NewPointer(true),
|
||||
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, contentReviewBot)
|
||||
|
||||
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, channels, 1)
|
||||
|
||||
require.Equal(t, model.ChannelTypeDirect, channels[0].Type)
|
||||
otherUserId := channels[0].GetOtherUserIdForDM(contentReviewBot.UserId)
|
||||
require.Equal(t, th.BasicUser2.Id, otherUserId)
|
||||
})
|
||||
|
||||
t.Run("should not return channels for team reviewers when disabled for the team", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings = baseConfig
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
|
||||
th.BasicTeam.Id: {
|
||||
Enabled: model.NewPointer(false),
|
||||
ReviewerIds: model.NewPointer([]string{th.BasicUser.Id}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, contentReviewBot)
|
||||
|
||||
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, channels, 0)
|
||||
})
|
||||
|
||||
t.Run("should return channels for additional reviewers with team reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings = baseConfig
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
|
||||
th.BasicTeam.Id: {
|
||||
Enabled: model.NewPointer(true),
|
||||
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
defer func() {
|
||||
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
}()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, contentReviewBot)
|
||||
|
||||
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, channels, 2)
|
||||
|
||||
reviewerIds := []string{
|
||||
channels[0].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
channels[1].GetOtherUserIdForDM(contentReviewBot.UserId),
|
||||
}
|
||||
|
||||
require.Contains(t, reviewerIds, th.BasicUser2.Id)
|
||||
require.Contains(t, reviewerIds, th.SystemAdminUser.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetReviewersForTeam(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
|
||||
t.Run("should return common reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id, th.BasicUser2.Id}
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
|
||||
})
|
||||
|
||||
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 2)
|
||||
require.Contains(t, reviewers, th.BasicUser.Id)
|
||||
require.Contains(t, reviewers, th.BasicUser2.Id)
|
||||
})
|
||||
|
||||
t.Run("should return system admins as additional reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
|
||||
})
|
||||
|
||||
// Sysadmin explicitly need to be a team member to be returned as reviewer
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 2)
|
||||
require.Contains(t, reviewers, th.BasicUser.Id)
|
||||
require.Contains(t, reviewers, th.SystemAdminUser.Id)
|
||||
|
||||
// system admin is a reviewer even when there are no common reviewers
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{}
|
||||
})
|
||||
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 1)
|
||||
require.Contains(t, reviewers, th.SystemAdminUser.Id)
|
||||
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
})
|
||||
|
||||
// If sysadmin is not a team member, they should not be returned as a reviewer
|
||||
appErr = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 1)
|
||||
require.Contains(t, reviewers, th.BasicUser.Id)
|
||||
})
|
||||
|
||||
t.Run("should return team admins as additional reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 2)
|
||||
require.Contains(t, reviewers, th.BasicUser.Id)
|
||||
require.Contains(t, reviewers, teamAdmin.Id)
|
||||
|
||||
// team admin is a reviewer even when there are no common reviewers
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{}
|
||||
})
|
||||
|
||||
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 1)
|
||||
require.Contains(t, reviewers, teamAdmin.Id)
|
||||
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
})
|
||||
|
||||
// If team admin is not a team member, they should not be returned as a reviewer
|
||||
appErr = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 1)
|
||||
require.Contains(t, reviewers, th.BasicUser.Id)
|
||||
})
|
||||
|
||||
t.Run("should return team reviewers", func(t *testing.T) {
|
||||
team2 := th.CreateTeam()
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
|
||||
th.BasicTeam.Id: {
|
||||
Enabled: model.NewPointer(true),
|
||||
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Reviewers configured for th.BasicTeam
|
||||
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 1)
|
||||
require.Contains(t, reviewers, th.BasicUser2.Id)
|
||||
|
||||
// NO reviewers configured for team2
|
||||
reviewers, appErr = th.App.getReviewersForTeam(team2.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 0)
|
||||
})
|
||||
|
||||
t.Run("should not return reviewers when disabled for the team", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
|
||||
th.BasicTeam.Id: {
|
||||
Enabled: model.NewPointer(false),
|
||||
ReviewerIds: model.NewPointer([]string{th.BasicUser.Id}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 0)
|
||||
})
|
||||
|
||||
t.Run("should return additional reviewers with team reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
|
||||
th.BasicTeam.Id: {
|
||||
Enabled: model.NewPointer(true),
|
||||
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 2)
|
||||
require.Contains(t, reviewers, th.BasicUser2.Id)
|
||||
require.Contains(t, reviewers, th.SystemAdminUser.Id)
|
||||
})
|
||||
|
||||
t.Run("should return unique reviewers", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
contentFlaggingSettings := model.ContentFlaggingSettings{}
|
||||
contentFlaggingSettings.SetDefaults()
|
||||
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id, th.SystemAdminUser.Id}
|
||||
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
|
||||
})
|
||||
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, reviewers, 2)
|
||||
require.Contains(t, reviewers, th.BasicUser.Id)
|
||||
require.Contains(t, reviewers, th.SystemAdminUser.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCanFlagPost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
|
||||
t.Run("should be able to flag post which has not already been flagged", func(t *testing.T) {
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
groupId, appErr := th.App.ContentFlaggingGroupId()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
appErr = th.App.canFlagPost(groupId, post.Id, "en")
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
|
||||
t.Run("should not be able to flag post which has already been flagged", func(t *testing.T) {
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
groupId, appErr := th.App.ContentFlaggingGroupId()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
statusField, err := th.Server.propertyService.GetPropertyFieldByName(groupId, "", contentFlaggingPropertyNameStatus)
|
||||
require.NoError(t, err)
|
||||
|
||||
propertyValue, err := th.Server.propertyService.CreatePropertyValue(&model.PropertyValue{
|
||||
TargetID: post.Id,
|
||||
GroupID: groupId,
|
||||
FieldID: statusField.ID,
|
||||
TargetType: "post",
|
||||
Value: json.RawMessage(`"` + model.ContentFlaggingStatusPending + `"`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Can't fleg when post already flagged in pending status
|
||||
appErr = th.App.canFlagPost(groupId, post.Id, "en")
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "Cannot flag this post as is already flagged.", appErr.Id)
|
||||
|
||||
// Can't fleg when post already flagged in assigned status
|
||||
propertyValue.Value = json.RawMessage(`"` + model.ContentFlaggingStatusAssigned + `"`)
|
||||
_, err = th.Server.propertyService.UpdatePropertyValue(groupId, propertyValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
appErr = th.App.canFlagPost(groupId, post.Id, "en")
|
||||
require.NotNil(t, appErr)
|
||||
|
||||
// Can't fleg when post already flagged in retained status
|
||||
propertyValue.Value = json.RawMessage(`"` + model.ContentFlaggingStatusRetained + `"`)
|
||||
_, err = th.Server.propertyService.UpdatePropertyValue(groupId, propertyValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
appErr = th.App.canFlagPost(groupId, post.Id, "en")
|
||||
require.NotNil(t, appErr)
|
||||
|
||||
// Can't fleg when post already flagged in removed status
|
||||
propertyValue.Value = json.RawMessage(`"` + model.ContentFlaggingStatusRemoved + `"`)
|
||||
_, err = th.Server.propertyService.UpdatePropertyValue(groupId, propertyValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
appErr = th.App.canFlagPost(groupId, post.Id, "en")
|
||||
require.NotNil(t, appErr)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlagPost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
|
||||
// Setup base config for content flagging
|
||||
baseConfig := model.ContentFlaggingSettings{}
|
||||
baseConfig.SetDefaults()
|
||||
baseConfig.ReviewerSettings.CommonReviewers = model.NewPointer(true)
|
||||
baseConfig.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
|
||||
baseConfig.AdditionalSettings.ReporterCommentRequired = model.NewPointer(false)
|
||||
baseConfig.AdditionalSettings.HideFlaggedContent = model.NewPointer(false)
|
||||
baseConfig.AdditionalSettings.Reasons = &[]string{"spam", "harassment", "inappropriate"}
|
||||
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings = baseConfig
|
||||
})
|
||||
|
||||
t.Run("should successfully flag a post with valid data", func(t *testing.T) {
|
||||
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)
|
||||
|
||||
// Verify property values were created
|
||||
groupId, appErr := th.App.ContentFlaggingGroupId()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Check status property
|
||||
statusValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{post.Id},
|
||||
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameStatus].ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, statusValues, 1)
|
||||
require.Equal(t, `"`+model.ContentFlaggingStatusPending+`"`, string(statusValues[0].Value))
|
||||
|
||||
// Check reporting user property
|
||||
userValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{post.Id},
|
||||
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingUserID].ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, userValues, 1)
|
||||
require.Equal(t, `"`+th.BasicUser2.Id+`"`, string(userValues[0].Value))
|
||||
|
||||
// Check reason property
|
||||
reasonValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{post.Id},
|
||||
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingReason].ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reasonValues, 1)
|
||||
require.Equal(t, `"spam"`, string(reasonValues[0].Value))
|
||||
|
||||
// Check comment property
|
||||
commentValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{post.Id},
|
||||
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingComment].ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, commentValues, 1)
|
||||
require.Equal(t, `"This is spam content"`, string(commentValues[0].Value))
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid reason", func(t *testing.T) {
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
flagData := model.FlagContentRequest{
|
||||
Reason: "invalid_reason",
|
||||
Comment: "This is spam content",
|
||||
}
|
||||
|
||||
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "api.content_flagging.error.reason_invalid", appErr.Id)
|
||||
})
|
||||
|
||||
t.Run("should fail when comment is required but not provided", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.AdditionalSettings.ReporterCommentRequired = model.NewPointer(true)
|
||||
})
|
||||
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
flagData := model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "",
|
||||
}
|
||||
|
||||
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
||||
require.NotNil(t, appErr)
|
||||
|
||||
// Reset config
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.AdditionalSettings.ReporterCommentRequired = model.NewPointer(false)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should fail when trying to flag already flagged post", func(t *testing.T) {
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
flagData := model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "\"This is spam content\"",
|
||||
}
|
||||
|
||||
// Flag the post first time
|
||||
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Try to flag the same post again
|
||||
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "Cannot flag this post as is already flagged.", appErr.Id)
|
||||
})
|
||||
|
||||
t.Run("should hide flagged content when configured", func(t *testing.T) {
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent = model.NewPointer(true)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// Verify post was deleted
|
||||
deletedPost, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, deletedPost)
|
||||
|
||||
// Reset config
|
||||
th.UpdateConfig(func(conf *model.Config) {
|
||||
conf.ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent = model.NewPointer(false)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should create content review post for reviewers", func(t *testing.T) {
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
flagData := model.FlagContentRequest{
|
||||
Reason: "harassment",
|
||||
Comment: "\"This is harassment\"",
|
||||
}
|
||||
|
||||
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// The reviewer posts are created async in a go routine. Wait for a short time to allow it to complete.
|
||||
// 2 seconds is the minimum time when the test consistently passes locally and in CI.
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Get the content review bot
|
||||
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Get direct channel between reviewer and bot
|
||||
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, contentReviewBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Check if review post was created in the DM channel
|
||||
posts, appErr := th.App.GetPostsPage(th.Context, model.GetPostsOptions{
|
||||
ChannelId: dmChannel.Id,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
})
|
||||
require.Nil(t, appErr)
|
||||
require.NotEmpty(t, posts.Posts)
|
||||
|
||||
// Find the content review post
|
||||
var reviewPost *model.Post
|
||||
for _, p := range posts.Posts {
|
||||
if p.Type == "custom_spillage_report" {
|
||||
reviewPost = p
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, reviewPost)
|
||||
})
|
||||
|
||||
t.Run("should work with empty comment when not required", func(t *testing.T) {
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
flagData := model.FlagContentRequest{
|
||||
Reason: "inappropriate",
|
||||
Comment: "",
|
||||
}
|
||||
|
||||
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify property values were created with empty comment
|
||||
groupId, appErr := th.App.ContentFlaggingGroupId()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
commentValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{post.Id},
|
||||
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingComment].ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, commentValues, 1)
|
||||
require.Equal(t, `""`, string(commentValues[0].Value))
|
||||
})
|
||||
|
||||
t.Run("should set reporting time property", func(t *testing.T) {
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
flagData := model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "\"Test comment\"",
|
||||
}
|
||||
|
||||
beforeTime := model.GetMillis()
|
||||
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
||||
afterTime := model.GetMillis()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify reporting time property was set
|
||||
groupId, appErr := th.App.ContentFlaggingGroupId()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
timeValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{post.Id},
|
||||
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
|
||||
FieldID: mappedFields[contentFlaggingPropertyNameReportingTime].ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, timeValues, 1)
|
||||
|
||||
var reportingTime int64
|
||||
err = json.Unmarshal(timeValues[0].Value, &reportingTime)
|
||||
require.NoError(t, err)
|
||||
require.True(t, reportingTime >= beforeTime && reportingTime <= afterTime)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ func (a *App) PatchCPAValues(userID string, fieldValueMap map[string]json.RawMes
|
|||
|
||||
value := &model.PropertyValue{
|
||||
GroupID: groupID,
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
TargetID: userID,
|
||||
FieldID: fieldID,
|
||||
Value: sanitizedValue,
|
||||
|
|
|
|||
|
|
@ -583,7 +583,7 @@ func TestDeleteCPAField(t *testing.T) {
|
|||
for i := range 3 {
|
||||
newValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: cpaGroupID,
|
||||
FieldID: createdField.ID,
|
||||
Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
|
||||
|
|
@ -662,7 +662,7 @@ func TestGetCPAValue(t *testing.T) {
|
|||
t.Run("should fail if the group id is invalid", func(t *testing.T) {
|
||||
propertyValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: model.NewId(),
|
||||
FieldID: fieldID,
|
||||
Value: json.RawMessage(`"Value"`),
|
||||
|
|
@ -678,7 +678,7 @@ func TestGetCPAValue(t *testing.T) {
|
|||
t.Run("should succeed if id exists", func(t *testing.T) {
|
||||
propertyValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: cpaGroupID,
|
||||
FieldID: fieldID,
|
||||
Value: json.RawMessage(`"Value"`),
|
||||
|
|
@ -702,7 +702,7 @@ func TestGetCPAValue(t *testing.T) {
|
|||
|
||||
propertyValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: cpaGroupID,
|
||||
FieldID: createdField.ID,
|
||||
Value: json.RawMessage(`["option1", "option2", "option3"]`),
|
||||
|
|
@ -751,7 +751,7 @@ func TestListCPAValues(t *testing.T) {
|
|||
|
||||
value := &model.PropertyValue{
|
||||
TargetID: userID,
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: cpaGroupID,
|
||||
FieldID: field.ID,
|
||||
Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const (
|
|||
remainingSchemaMigrationsKey = "RemainingSchemaMigrations"
|
||||
postPriorityConfigDefaultTrueMigrationKey = "PostPriorityConfigDefaultTrueMigrationComplete"
|
||||
contentFlaggingSetupDoneKey = "content_flagging_setup_done"
|
||||
contentFlaggingMigrationVersion = "v1"
|
||||
contentFlaggingMigrationVersion = "v2"
|
||||
|
||||
contentFlaggingPropertyNameFlaggedPostId = "flagged_post_id"
|
||||
contentFlaggingPropertyNameStatus = "status"
|
||||
|
|
@ -40,6 +40,8 @@ const (
|
|||
contentFlaggingPropertyNameActorUserID = "actor_user_id"
|
||||
contentFlaggingPropertyNameActorComment = "actor_comment"
|
||||
contentFlaggingPropertyNameActionTime = "action_time"
|
||||
|
||||
contentFlaggingPropertySubTypeTimestamp = "timestamp"
|
||||
)
|
||||
|
||||
// This function migrates the default built in roles from code/config to the database.
|
||||
|
|
@ -641,6 +643,14 @@ func (s *Server) doSetupContentFlaggingProperties() error {
|
|||
GroupID: group.ID,
|
||||
Name: contentFlaggingPropertyNameStatus,
|
||||
Type: model.PropertyFieldTypeSelect,
|
||||
Attrs: map[string]any{
|
||||
"options": []map[string]string{
|
||||
{"name": model.ContentFlaggingStatusPending, "color": "light_grey"},
|
||||
{"name": model.ContentFlaggingStatusAssigned, "color": "dark_blue"},
|
||||
{"name": model.ContentFlaggingStatusRemoved, "color": "dark_red"},
|
||||
{"name": model.ContentFlaggingStatusRetained, "color": "light_blue"},
|
||||
},
|
||||
},
|
||||
},
|
||||
contentFlaggingPropertyNameReportingUserID: {
|
||||
GroupID: group.ID,
|
||||
|
|
@ -661,6 +671,7 @@ func (s *Server) doSetupContentFlaggingProperties() error {
|
|||
GroupID: group.ID,
|
||||
Name: contentFlaggingPropertyNameReportingTime,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
Attrs: map[string]any{"subType": contentFlaggingPropertySubTypeTimestamp},
|
||||
},
|
||||
contentFlaggingPropertyNameReviewerUserID: {
|
||||
GroupID: group.ID,
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan
|
|||
// to be done when we send the post over the websocket in handlePostEvents
|
||||
// PS: we don't want to include PostPriority from the db to avoid the replica lag,
|
||||
// so we just return the one that was passed with post
|
||||
rpost = a.PreparePostForClient(rctx, rpost, true, false, false)
|
||||
rpost = a.PreparePostForClient(rctx, rpost, &model.PreparePostForClientOpts{IsEditPost: true})
|
||||
|
||||
a.applyPostWillBeConsumedHook(&rpost)
|
||||
|
||||
|
|
@ -584,7 +584,7 @@ func (a *App) SendEphemeralPost(rctx request.CTX, userID string, post *model.Pos
|
|||
|
||||
post.GenerateActionIds()
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", post.ChannelId, userID, nil, "")
|
||||
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, true, false, true)
|
||||
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
|
||||
post = model.AddPostActionCookies(post, a.PostActionCookieSecret())
|
||||
|
||||
sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, post, userID)
|
||||
|
|
@ -618,7 +618,7 @@ func (a *App) UpdateEphemeralPost(rctx request.CTX, userID string, post *model.P
|
|||
|
||||
post.GenerateActionIds()
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, userID, nil, "")
|
||||
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, true, false, true)
|
||||
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
|
||||
post = model.AddPostActionCookies(post, a.PostActionCookieSecret())
|
||||
|
||||
sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, post, userID)
|
||||
|
|
@ -788,7 +788,7 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda
|
|||
}, plugin.MessageHasBeenUpdatedID)
|
||||
})
|
||||
|
||||
rpost = a.PreparePostForClientWithEmbedsAndImages(rctx, rpost, false, true, true)
|
||||
rpost = a.PreparePostForClientWithEmbedsAndImages(rctx, rpost, &model.PreparePostForClientOpts{IsEditPost: true, IncludePriority: true})
|
||||
|
||||
// Ensure IsFollowing is nil since this updated post will be broadcast to all users
|
||||
// and we don't want to have to populate it for every single user and broadcast to each
|
||||
|
|
@ -2230,7 +2230,7 @@ func (a *App) SetPostReminder(rctx request.CTX, postID, userID string, targetTim
|
|||
}
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", ephemeralPost.ChannelId, userID, nil, "")
|
||||
ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(rctx, ephemeralPost, true, false, true)
|
||||
ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(rctx, ephemeralPost, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
|
||||
ephemeralPost = model.AddPostActionCookies(ephemeralPost, a.PostActionCookieSecret())
|
||||
|
||||
postJSON, jsonErr := ephemeralPost.ToJSON()
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ func (a *App) sendPostUpdateEvent(rctx request.CTX, post *model.Post) {
|
|||
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, "", nil, "")
|
||||
|
||||
// Prepare the post with metadata for the event
|
||||
preparedPost := a.PreparePostForClient(rctx, post, false, true, true)
|
||||
preparedPost := a.PreparePostForClient(rctx, post, &model.PreparePostForClientOpts{IsEditPost: true, IncludePriority: true})
|
||||
|
||||
if appErr := a.publishWebsocketEventForPost(rctx, preparedPost, message); appErr != nil {
|
||||
rctx.Logger().Warn("Failed to send post update event for acknowledgement sync", mlog.String("post_id", post.Id), mlog.Err(appErr))
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func (a *App) PreparePostListForClient(rctx request.CTX, originalList *model.Pos
|
|||
}
|
||||
|
||||
for id, originalPost := range originalList.Posts {
|
||||
post := a.PreparePostForClientWithEmbedsAndImages(rctx, originalPost, false, false, false)
|
||||
post := a.PreparePostForClientWithEmbedsAndImages(rctx, originalPost, &model.PreparePostForClientOpts{})
|
||||
|
||||
list.Posts[id] = post
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ func (a *App) OverrideIconURLIfEmoji(rctx request.CTX, post *model.Post) {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post {
|
||||
func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
post := originalPost.Clone()
|
||||
|
||||
// Proxy image links before constructing metadata so that requests go through the proxy
|
||||
|
|
@ -123,7 +123,7 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, i
|
|||
post.Metadata = &model.PostMetadata{}
|
||||
}
|
||||
|
||||
if post.DeleteAt > 0 {
|
||||
if post.DeleteAt > 0 && !opts.RetainContent {
|
||||
// For deleted posts we don't fill out metadata nor do we return the post content
|
||||
post.Message = ""
|
||||
post.Metadata = &model.PostMetadata{}
|
||||
|
|
@ -139,13 +139,13 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, i
|
|||
}
|
||||
|
||||
// Files
|
||||
if fileInfos, _, err := a.getFileMetadataForPost(rctx, post, isNewPost || isEditPost); err != nil {
|
||||
if fileInfos, _, err := a.getFileMetadataForPost(rctx, post, opts.IsNewPost || opts.IsEditPost, opts.IncludeDeleted); err != nil {
|
||||
rctx.Logger().Warn("Failed to get files for a post", mlog.String("post_id", post.Id), mlog.Err(err))
|
||||
} else {
|
||||
post.Metadata.Files = fileInfos
|
||||
}
|
||||
|
||||
if includePriority && a.IsPostPriorityEnabled() && post.RootId == "" {
|
||||
if opts.IncludePriority && a.IsPostPriorityEnabled() && post.RootId == "" {
|
||||
// Post's Priority if any
|
||||
if priority, err := a.GetPriorityForPost(post.Id); err != nil {
|
||||
rctx.Logger().Warn("Failed to get post priority for a post", mlog.String("post_id", post.Id), mlog.Err(err))
|
||||
|
|
@ -164,9 +164,9 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, i
|
|||
return post
|
||||
}
|
||||
|
||||
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post {
|
||||
post := a.PreparePostForClient(rctx, originalPost, isNewPost, isEditPost, includePriority)
|
||||
post = a.getEmbedsAndImages(rctx, post, isNewPost)
|
||||
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
post := a.PreparePostForClient(rctx, originalPost, opts)
|
||||
post = a.getEmbedsAndImages(rctx, post, opts.IsNewPost)
|
||||
return post
|
||||
}
|
||||
|
||||
|
|
@ -274,12 +274,12 @@ func (a *App) SanitizePostListMetadataForUser(rctx request.CTX, postList *model.
|
|||
return clonedPostList, nil
|
||||
}
|
||||
|
||||
func (a *App) getFileMetadataForPost(rctx request.CTX, post *model.Post, fromMaster bool) ([]*model.FileInfo, int64, *model.AppError) {
|
||||
func (a *App) getFileMetadataForPost(rctx request.CTX, post *model.Post, fromMaster, includeDeleted bool) ([]*model.FileInfo, int64, *model.AppError) {
|
||||
if len(post.FileIds) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
return a.GetFileInfosForPost(rctx, post.Id, fromMaster, false)
|
||||
return a.GetFileInfosForPost(rctx, post.Id, fromMaster, includeDeleted)
|
||||
}
|
||||
|
||||
func (a *App) getEmojisAndReactionsForPost(rctx request.CTX, post *model.Post) ([]*model.Emoji, []*model.Reaction, *model.AppError) {
|
||||
|
|
@ -701,7 +701,7 @@ func (a *App) getLinkMetadataForPermalink(rctx request.CTX, requestURL string) (
|
|||
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPost, referencedTeam, referencedChannel)}
|
||||
} else {
|
||||
// referencedPost does not contain a permalink: we get its metadata
|
||||
referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(rctx, referencedPost, false, false, false)
|
||||
referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(rctx, referencedPost, &model.PreparePostForClientOpts{})
|
||||
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPostWithMetadata, referencedTeam, referencedChannel)}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
Message: message,
|
||||
}
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, true, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsEditPost: true})
|
||||
|
||||
t.Run("doesn't mutate provided post", func(t *testing.T) {
|
||||
assert.NotEqual(t, clientPost, post, "should've returned a new post")
|
||||
|
|
@ -162,7 +162,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
|
||||
post := th.CreatePost(th.BasicChannel)
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
assert.False(t, clientPost == post, "should've returned a new post")
|
||||
assert.Equal(t, clientPost, post, "shouldn't have changed any metadata")
|
||||
|
|
@ -179,7 +179,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
reactions := []*model.Reaction{reaction1, reaction2, reaction3}
|
||||
post.HasReactions = true
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
assert.Len(t, clientPost.Metadata.Reactions, 3, "should've populated Reactions")
|
||||
assert.ElementsMatch(t, reactions, clientPost.Metadata.Reactions)
|
||||
|
|
@ -205,7 +205,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
|
||||
var clientPost *model.Post
|
||||
assert.Eventually(t, func() bool {
|
||||
clientPost = th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost = th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
return assert.ObjectsAreEqual([]*model.FileInfo{fileInfo}, clientPost.Metadata.Files)
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
th.AddReactionToPost(post, th.BasicUser2, "angry")
|
||||
post.HasReactions = true
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
t.Run("populates emojis", func(t *testing.T) {
|
||||
assert.ElementsMatch(t, []*model.Emoji{}, clientPost.Metadata.Emojis, "should've populated empty Emojis")
|
||||
|
|
@ -286,7 +286,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
th.AddReactionToPost(post, th.BasicUser2, "angry")
|
||||
post.HasReactions = true
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
t.Run("populates emojis", func(t *testing.T) {
|
||||
assert.ElementsMatch(t, []*model.Emoji{emoji1, emoji2, emoji3, emoji4}, clientPost.Metadata.Emojis, "should've populated post.Emojis")
|
||||
|
|
@ -318,7 +318,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
post.AddProp(model.PostPropsOverrideIconURL, url)
|
||||
post.AddProp(model.PostPropsOverrideIconEmoji, emoji)
|
||||
|
||||
return th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
return th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
}
|
||||
|
||||
emoji := "basketball"
|
||||
|
|
@ -371,7 +371,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
t.Run("populates image dimensions", func(t *testing.T) {
|
||||
imageDimensions := clientPost.Metadata.Images
|
||||
|
|
@ -404,7 +404,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
post.AddProp(model.PostPropsOverrideIconEmoji, true)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
_ = th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
_ = th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -434,7 +434,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
post.Metadata.Embeds = nil
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
// Reminder that only the first link gets an embed and dimensions
|
||||
|
||||
|
|
@ -469,7 +469,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
firstEmbed := clientPost.Metadata.Embeds[0]
|
||||
ogData := firstEmbed.Data.(*opengraph.OpenGraph)
|
||||
|
||||
|
|
@ -540,7 +540,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
post, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
assert.Len(t, clientPost.Metadata.Embeds, 0)
|
||||
assert.Len(t, clientPost.Metadata.Images, 0)
|
||||
|
|
@ -556,7 +556,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
post, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
assert.Greater(t, len(clientPost.Metadata.Embeds)+len(clientPost.Metadata.Images), 0)
|
||||
})
|
||||
|
|
@ -582,7 +582,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
post.Metadata.Embeds = nil
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
t.Run("populates embeds", func(t *testing.T) {
|
||||
assert.ElementsMatch(t, []*model.PostEmbed{
|
||||
|
|
@ -627,7 +627,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
// DeleteAt isn't set on the post returned by App.DeletePost
|
||||
post.DeleteAt = model.GetMillis()
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
assert.NotEqual(t, nil, clientPost.Metadata, "should've populated Metadata“")
|
||||
assert.Equal(t, "", clientPost.Message, "should've cleaned post content")
|
||||
|
|
@ -662,7 +662,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
previewPost.Metadata.Embeds = nil
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
firstEmbed := clientPost.Metadata.Embeds[0]
|
||||
preview := firstEmbed.Data.(*model.PreviewPost)
|
||||
require.Equal(t, referencedPost.Id, preview.PostID)
|
||||
|
|
@ -721,7 +721,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
require.Nil(t, err)
|
||||
previewPost.Metadata.Embeds = nil
|
||||
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
firstEmbed := clientPost.Metadata.Embeds[0]
|
||||
preview := firstEmbed.Data.(*model.PreviewPost)
|
||||
|
||||
|
|
@ -759,7 +759,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
require.Nil(t, err)
|
||||
previewPost.Metadata.Embeds = nil
|
||||
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
firstEmbed := clientPost.Metadata.Embeds[0]
|
||||
preview := firstEmbed.Data.(*model.PreviewPost)
|
||||
referencedPostFirstEmbed := preview.Post.Metadata.Embeds[0]
|
||||
|
|
@ -806,7 +806,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
require.Nil(t, err)
|
||||
previewPost.Metadata.Embeds = nil
|
||||
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
firstEmbed := clientPost.Metadata.Embeds[0]
|
||||
preview := firstEmbed.Data.(*model.PreviewPost)
|
||||
referencedPostMetadata := preview.Post.Metadata
|
||||
|
|
@ -841,7 +841,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, previewPost, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
firstEmbed := clientPost.Metadata.Embeds[0]
|
||||
preview := firstEmbed.Data.(*model.PreviewPost)
|
||||
require.Equal(t, referencedPost.Id, preview.PostID)
|
||||
|
|
@ -850,13 +850,13 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
*cfg.ServiceSettings.EnablePermalinkPreviews = false
|
||||
})
|
||||
|
||||
th.App.PreparePostForClient(th.Context, previewPost, false, false, false)
|
||||
th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnablePermalinkPreviews = true
|
||||
})
|
||||
|
||||
clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, false, false, false)
|
||||
clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
firstEmbed2 := clientPost2.Metadata.Embeds[0]
|
||||
preview2 := firstEmbed2.Data.(*model.PreviewPost)
|
||||
require.Equal(t, referencedPost.Id, preview2.PostID)
|
||||
|
|
@ -909,7 +909,7 @@ func testProxyLinkedImage(t *testing.T, th *TestHelper, shouldProxy bool) {
|
|||
Message: fmt.Sprintf(postTemplate, imageURL),
|
||||
}
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
|
||||
if shouldProxy {
|
||||
assert.Equal(t, fmt.Sprintf(postTemplate, imageURL), post.Message, "should not have mutated original post")
|
||||
|
|
@ -959,7 +959,7 @@ func testProxyOpenGraphImage(t *testing.T, th *TestHelper, shouldProxy bool) {
|
|||
require.Nil(t, err)
|
||||
|
||||
post.Metadata.Embeds = nil
|
||||
embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false).Metadata.Embeds
|
||||
embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{}).Metadata.Embeds
|
||||
require.Len(t, embeds, 1, "should have one embed")
|
||||
|
||||
embed := embeds[0]
|
||||
|
|
@ -3098,7 +3098,7 @@ func TestSanitizePostMetaDataForAudit(t *testing.T) {
|
|||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, err)
|
||||
previewPost.Metadata.Embeds = nil
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
|
||||
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
|
||||
firstEmbed := clientPost.Metadata.Embeds[0]
|
||||
preview := firstEmbed.Data.(*model.PreviewPost)
|
||||
require.Equal(t, referencedPost.Id, preview.PostID)
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Chann
|
|||
}
|
||||
|
||||
if len(desktopUsers) != 0 {
|
||||
post = a.PreparePostForClient(request.EmptyContext(a.Log()), post, false, false, true)
|
||||
post = a.PreparePostForClient(request.EmptyContext(a.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
|
||||
postJSON, jsonErr := post.ToJSON()
|
||||
if jsonErr != nil {
|
||||
return errors.Wrapf(jsonErr, "failed to encode post to JSON")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package app
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
|
|
@ -36,3 +37,12 @@ func (a *App) GetPriorityForPostList(list *model.PostList) (map[string]*model.Po
|
|||
func (a *App) IsPostPriorityEnabled() bool {
|
||||
return *a.Config().ServiceSettings.PostPriority
|
||||
}
|
||||
|
||||
func (a *App) DeletePriorityForPost(postId string) *model.AppError {
|
||||
err := a.Srv().Store().PostPriority().Delete(postId)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return model.NewAppError("DeletePriorityForPost", "app.post_priority.delete_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ func (ps *PropertyService) CreatePropertyValue(value *model.PropertyValue) (*mod
|
|||
return ps.valueStore.Create(value)
|
||||
}
|
||||
|
||||
func (ps *PropertyService) CreatePropertyValues(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
return ps.valueStore.CreateMany(values)
|
||||
}
|
||||
|
||||
func (ps *PropertyService) GetPropertyValue(groupID, id string) (*model.PropertyValue, error) {
|
||||
return ps.valueStore.Get(groupID, id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
|
|||
require.Nil(t, appErr)
|
||||
assert.Equal(t, "https://example.com/icon.png", post.GetProp(model.PostPropsOverrideIconURL))
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
|
||||
|
||||
assert.Equal(t, "https://example.com/icon.png", clientPost.GetProp(model.PostPropsOverrideIconURL))
|
||||
})
|
||||
|
|
@ -450,7 +450,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
|
|||
require.Nil(t, appErr)
|
||||
assert.Equal(t, "smile", post.GetProp(model.PostPropsOverrideIconEmoji))
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
|
||||
|
||||
assert.Equal(t, "/static/emoji/1f604.png", clientPost.GetProp(model.PostPropsOverrideIconURL))
|
||||
})
|
||||
|
|
@ -475,7 +475,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
|
|||
require.Nil(t, appErr)
|
||||
assert.Equal(t, emoji.Name, post.GetProp(model.PostPropsOverrideIconEmoji))
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
|
||||
|
||||
assert.Equal(t, fmt.Sprintf("/api/v4/emoji/%s/image", emoji.Id), clientPost.GetProp(model.PostPropsOverrideIconURL))
|
||||
})
|
||||
|
|
@ -498,7 +498,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
|
|||
require.Nil(t, appErr)
|
||||
assert.Equal(t, ":smile:", post.GetProp(model.PostPropsOverrideIconEmoji))
|
||||
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
|
||||
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
|
||||
|
||||
assert.Equal(t, "/static/emoji/1f604.png", clientPost.GetProp(model.PostPropsOverrideIconURL))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7683,6 +7683,27 @@ func (s *RetryLayerPostStore) Delete(rctx request.CTX, postID string, timestamp
|
|||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostStore) DeleteAllPostRemindersForPost(postId string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.PostStore.DeleteAllPostRemindersForPost(postId)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
|
||||
tries := 0
|
||||
|
|
@ -8625,6 +8646,27 @@ func (s *RetryLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostA
|
|||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostAcknowledgementStore) DeleteAllForPost(postID string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.PostAcknowledgementStore.DeleteAllForPost(postID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) {
|
||||
|
||||
tries := 0
|
||||
|
|
@ -9570,6 +9612,27 @@ func (s *RetryLayerPropertyValueStore) Create(value *model.PropertyValue) (*mode
|
|||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.PropertyValueStore.CreateMany(values)
|
||||
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 *RetryLayerPropertyValueStore) Delete(groupID string, id string) error {
|
||||
|
||||
tries := 0
|
||||
|
|
|
|||
|
|
@ -109,6 +109,15 @@ func (s *SqlPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowle
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlPostAcknowledgementStore) DeleteAllForPost(postID string) error {
|
||||
query := s.getQueryBuilder().
|
||||
Delete("PostAcknowledgements").
|
||||
Where(sq.Eq{"PostId": postID})
|
||||
|
||||
_, err := s.GetMaster().ExecBuilder(query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SqlPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) {
|
||||
var acknowledgements []*model.PostAcknowledgement
|
||||
|
||||
|
|
|
|||
|
|
@ -3245,6 +3245,14 @@ func (s *SqlPostStore) GetPostReminders(now int64) (_ []*model.PostReminder, err
|
|||
return reminders, nil
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) DeleteAllPostRemindersForPost(postId string) error {
|
||||
_, err := s.GetMaster().Exec(`DELETE from PostReminders WHERE PostId = ?`, postId)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to delete post reminders for postId %s", postId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) GetPostReminderMetadata(postID string) (*store.PostReminderMetadata, error) {
|
||||
meta := &store.PostReminderMetadata{}
|
||||
err := s.GetReplica().Get(meta, `SELECT c.id as ChannelID,
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ type SqlPropertyValueStore struct {
|
|||
tableSelectQuery sq.SelectBuilder
|
||||
}
|
||||
|
||||
var propertyValueColumns = []string{"ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt"}
|
||||
|
||||
func newPropertyValueStore(sqlStore *SqlStore) store.PropertyValueStore {
|
||||
s := SqlPropertyValueStore{SqlStore: sqlStore}
|
||||
|
||||
s.tableSelectQuery = s.getQueryBuilder().
|
||||
Select("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt").
|
||||
Select(propertyValueColumns...).
|
||||
From("PropertyValues")
|
||||
|
||||
return &s
|
||||
|
|
@ -47,7 +49,7 @@ func (s *SqlPropertyValueStore) Create(value *model.PropertyValue) (*model.Prope
|
|||
|
||||
builder := s.getQueryBuilder().
|
||||
Insert("PropertyValues").
|
||||
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt").
|
||||
Columns(propertyValueColumns...).
|
||||
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt)
|
||||
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_create_insert")
|
||||
|
|
@ -56,6 +58,46 @@ func (s *SqlPropertyValueStore) Create(value *model.PropertyValue) (*model.Prope
|
|||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SqlPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
transaction, err := s.GetMaster().Beginx()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_create_many_begin_transaction")
|
||||
}
|
||||
defer finalizeTransactionX(transaction, &err)
|
||||
|
||||
for _, value := range values {
|
||||
value.PreSave()
|
||||
|
||||
if err := value.IsValid(); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_create_many_isvalid")
|
||||
}
|
||||
|
||||
valueJSON := value.Value
|
||||
if s.IsBinaryParamEnabled() {
|
||||
valueJSON = AppendBinaryFlag(valueJSON)
|
||||
}
|
||||
|
||||
builder := s.getQueryBuilder().
|
||||
Insert("PropertyValues").
|
||||
Columns(propertyValueColumns...).
|
||||
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt)
|
||||
|
||||
if _, err := transaction.ExecBuilder(builder); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_create_many_exec")
|
||||
}
|
||||
}
|
||||
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_create_many_commit_transaction")
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *SqlPropertyValueStore) Get(groupID, id string) (*model.PropertyValue, error) {
|
||||
builder := s.tableSelectQuery.Where(sq.Eq{"id": id})
|
||||
|
||||
|
|
@ -242,7 +284,7 @@ func (s *SqlPropertyValueStore) Upsert(values []*model.PropertyValue) (_ []*mode
|
|||
|
||||
builder := s.getQueryBuilder().
|
||||
Insert("PropertyValues").
|
||||
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt").
|
||||
Columns(propertyValueColumns...).
|
||||
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt)
|
||||
|
||||
builder = builder.SuffixExpr(sq.Expr(
|
||||
|
|
|
|||
|
|
@ -414,6 +414,7 @@ type PostStore interface {
|
|||
GetPostsSinceForSync(options model.GetPostsSinceForSyncOptions, cursor model.GetPostsSinceForSyncCursor, limit int) ([]*model.Post, model.GetPostsSinceForSyncCursor, error)
|
||||
SetPostReminder(reminder *model.PostReminder) error
|
||||
GetPostReminders(now int64) ([]*model.PostReminder, error)
|
||||
DeleteAllPostRemindersForPost(postId string) error
|
||||
GetPostReminderMetadata(postID string) (*PostReminderMetadata, error)
|
||||
// GetNthRecentPostTime returns the CreateAt time of the nth most recent post.
|
||||
GetNthRecentPostTime(n int64) (int64, error)
|
||||
|
|
@ -1064,6 +1065,7 @@ type PostAcknowledgementStore interface {
|
|||
SaveWithModel(acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, error)
|
||||
BatchSave(acknowledgements []*model.PostAcknowledgement) ([]*model.PostAcknowledgement, error)
|
||||
Delete(acknowledgement *model.PostAcknowledgement) error
|
||||
DeleteAllForPost(postID string) error
|
||||
BatchDelete(acknowledgements []*model.PostAcknowledgement) error
|
||||
}
|
||||
|
||||
|
|
@ -1117,6 +1119,7 @@ type PropertyFieldStore interface {
|
|||
|
||||
type PropertyValueStore interface {
|
||||
Create(value *model.PropertyValue) (*model.PropertyValue, error)
|
||||
CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error)
|
||||
Get(groupID, id string) (*model.PropertyValue, error)
|
||||
GetMany(groupID string, ids []string) ([]*model.PropertyValue, error)
|
||||
SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error)
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
|
|||
|
||||
pva1, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u1.Id,
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: groupID,
|
||||
FieldID: fieldA.ID,
|
||||
Value: vala1,
|
||||
|
|
@ -144,7 +144,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
|
|||
|
||||
pvb1, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u1.Id,
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: groupID,
|
||||
FieldID: fieldB.ID,
|
||||
Value: valab1,
|
||||
|
|
@ -153,7 +153,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
|
|||
|
||||
pva2, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u2.Id,
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: groupID,
|
||||
FieldID: fieldA.ID,
|
||||
Value: vala2,
|
||||
|
|
@ -162,7 +162,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
|
|||
|
||||
pva3, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u3.Id,
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: groupID,
|
||||
FieldID: fieldA.ID,
|
||||
Value: vala1,
|
||||
|
|
@ -171,7 +171,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
|
|||
|
||||
pva4, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u3.Id,
|
||||
TargetType: "user",
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: groupID,
|
||||
FieldID: fieldC.ID,
|
||||
Value: valc2,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,24 @@ func (_m *PostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledg
|
|||
return r0
|
||||
}
|
||||
|
||||
// DeleteAllForPost provides a mock function with given fields: postID
|
||||
func (_m *PostAcknowledgementStore) DeleteAllForPost(postID string) error {
|
||||
ret := _m.Called(postID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteAllForPost")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(postID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Get provides a mock function with given fields: postID, userID
|
||||
func (_m *PostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) {
|
||||
ret := _m.Called(postID, userID)
|
||||
|
|
|
|||
|
|
@ -156,6 +156,24 @@ func (_m *PostStore) Delete(rctx request.CTX, postID string, timestamp int64, de
|
|||
return r0
|
||||
}
|
||||
|
||||
// DeleteAllPostRemindersForPost provides a mock function with given fields: postId
|
||||
func (_m *PostStore) DeleteAllPostRemindersForPost(postId string) error {
|
||||
ret := _m.Called(postId)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteAllPostRemindersForPost")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(postId)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Get provides a mock function with given fields: rctx, id, opts, userID, sanitizeOptions
|
||||
func (_m *PostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
ret := _m.Called(rctx, id, opts, userID, sanitizeOptions)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,36 @@ func (_m *PropertyValueStore) Create(value *model.PropertyValue) (*model.Propert
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// CreateMany provides a mock function with given fields: values
|
||||
func (_m *PropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
ret := _m.Called(values)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateMany")
|
||||
}
|
||||
|
||||
var r0 []*model.PropertyValue
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) ([]*model.PropertyValue, error)); ok {
|
||||
return rf(values)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) []*model.PropertyValue); ok {
|
||||
r0 = rf(values)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.PropertyValue)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func([]*model.PropertyValue) error); ok {
|
||||
r1 = rf(values)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: groupID, id
|
||||
func (_m *PropertyValueStore) Delete(groupID string, id string) error {
|
||||
ret := _m.Called(groupID, id)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
|
||||
func TestPropertyValueStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
|
||||
t.Run("CreatePropertyValue", func(t *testing.T) { testCreatePropertyValue(t, rctx, ss) })
|
||||
t.Run("CreateManyPropertyValues", func(t *testing.T) { testCreateManyPropertyValues(t, rctx, ss) })
|
||||
t.Run("CreatePropertyValueWithArray", func(t *testing.T) { testCreatePropertyValueWithArray(t, rctx, ss) })
|
||||
t.Run("GetPropertyValue", func(t *testing.T) { testGetPropertyValue(t, rctx, ss) })
|
||||
t.Run("GetManyPropertyValues", func(t *testing.T) { testGetManyPropertyValues(t, rctx, ss) })
|
||||
|
|
@ -76,6 +77,225 @@ func testCreatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
|
|||
})
|
||||
}
|
||||
|
||||
func testCreateManyPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
t.Run("should return nil when given empty slice", func(t *testing.T) {
|
||||
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, values)
|
||||
})
|
||||
|
||||
t.Run("should fail if any property value is not valid", func(t *testing.T) {
|
||||
validValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"valid value"`),
|
||||
}
|
||||
|
||||
invalidValue := &model.PropertyValue{
|
||||
TargetID: "", // Invalid: empty target ID
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"invalid value"`),
|
||||
}
|
||||
|
||||
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{validValue, invalidValue})
|
||||
require.Zero(t, values)
|
||||
require.ErrorContains(t, err, "model.property_value.is_valid.app_error")
|
||||
|
||||
// Verify no values were created
|
||||
results, err := ss.PropertyValue().SearchPropertyValues(model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{validValue.TargetID},
|
||||
PerPage: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results)
|
||||
})
|
||||
|
||||
t.Run("should be able to create multiple property values", func(t *testing.T) {
|
||||
value1 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"value 1"`),
|
||||
}
|
||||
|
||||
value2 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"value 2"`),
|
||||
}
|
||||
|
||||
value3 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"value 3"`),
|
||||
}
|
||||
|
||||
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2, value3})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 3)
|
||||
|
||||
// Verify all values have IDs and timestamps set
|
||||
for i, value := range values {
|
||||
require.NotZero(t, value.ID)
|
||||
require.NotZero(t, value.CreateAt)
|
||||
require.NotZero(t, value.UpdateAt)
|
||||
require.Zero(t, value.DeleteAt)
|
||||
require.Equal(t, json.RawMessage(fmt.Sprintf(`"value %d"`, i+1)), value.Value)
|
||||
}
|
||||
|
||||
// Verify values can be retrieved from database
|
||||
retrievedValues, err := ss.PropertyValue().GetMany("", []string{value1.ID, value2.ID, value3.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, retrievedValues, 3)
|
||||
})
|
||||
|
||||
t.Run("should handle array values", func(t *testing.T) {
|
||||
value1 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`["option1", "option2"]`),
|
||||
}
|
||||
|
||||
value2 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`["option3", "option4", "option5"]`),
|
||||
}
|
||||
|
||||
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 2)
|
||||
|
||||
// Verify array values are preserved
|
||||
var arrayValues1 []string
|
||||
require.NoError(t, json.Unmarshal(values[0].Value, &arrayValues1))
|
||||
require.Equal(t, []string{"option1", "option2"}, arrayValues1)
|
||||
|
||||
var arrayValues2 []string
|
||||
require.NoError(t, json.Unmarshal(values[1].Value, &arrayValues2))
|
||||
require.Equal(t, []string{"option3", "option4", "option5"}, arrayValues2)
|
||||
})
|
||||
|
||||
t.Run("should enforce uniqueness constraints", func(t *testing.T) {
|
||||
groupID := model.NewId()
|
||||
targetID := model.NewId()
|
||||
fieldID := model.NewId()
|
||||
|
||||
// Create first value
|
||||
value1 := &model.PropertyValue{
|
||||
TargetID: targetID,
|
||||
TargetType: "test_type",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldID,
|
||||
Value: json.RawMessage(`"first value"`),
|
||||
}
|
||||
|
||||
_, err := ss.PropertyValue().Create(value1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to create duplicate value
|
||||
value2 := &model.PropertyValue{
|
||||
TargetID: targetID,
|
||||
TargetType: "test_type",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldID,
|
||||
Value: json.RawMessage(`"duplicate value"`),
|
||||
}
|
||||
|
||||
value3 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"unique value"`),
|
||||
}
|
||||
|
||||
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value2, value3})
|
||||
require.Error(t, err)
|
||||
require.Zero(t, values)
|
||||
|
||||
// Verify the unique value was not created due to transaction rollback
|
||||
results, err := ss.PropertyValue().SearchPropertyValues(model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{value3.TargetID},
|
||||
PerPage: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results)
|
||||
})
|
||||
|
||||
t.Run("should create values with same field but different targets", func(t *testing.T) {
|
||||
groupID := model.NewId()
|
||||
fieldID := model.NewId()
|
||||
|
||||
value1 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldID,
|
||||
Value: json.RawMessage(`"target 1 value"`),
|
||||
}
|
||||
|
||||
value2 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldID,
|
||||
Value: json.RawMessage(`"target 2 value"`),
|
||||
}
|
||||
|
||||
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 2)
|
||||
|
||||
// Verify both values were created successfully
|
||||
for _, value := range values {
|
||||
require.NotZero(t, value.ID)
|
||||
require.Equal(t, groupID, value.GroupID)
|
||||
require.Equal(t, fieldID, value.FieldID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should create values across different groups", func(t *testing.T) {
|
||||
value1 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"group 1 value"`),
|
||||
}
|
||||
|
||||
value2 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"group 2 value"`),
|
||||
}
|
||||
|
||||
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 2)
|
||||
|
||||
// Verify values are in different groups
|
||||
require.NotEqual(t, values[0].GroupID, values[1].GroupID)
|
||||
require.Contains(t, string(values[0].Value), "group 1")
|
||||
require.Contains(t, string(values[1].Value), "group 2")
|
||||
})
|
||||
}
|
||||
|
||||
func testGetPropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
t.Run("should fail on nonexisting value", func(t *testing.T) {
|
||||
value, err := ss.PropertyValue().Get("", model.NewId())
|
||||
|
|
|
|||
|
|
@ -6118,6 +6118,22 @@ func (s *TimerLayerPostStore) Delete(rctx request.CTX, postID string, timestamp
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) DeleteAllPostRemindersForPost(postId string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.PostStore.DeleteAllPostRemindersForPost(postId)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PostStore.DeleteAllPostRemindersForPost", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
|
@ -6869,6 +6885,22 @@ func (s *TimerLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostA
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostAcknowledgementStore) DeleteAllForPost(postID string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.PostAcknowledgementStore.DeleteAllForPost(postID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.DeleteAllForPost", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
|
@ -7589,6 +7621,22 @@ func (s *TimerLayerPropertyValueStore) Create(value *model.PropertyValue) (*mode
|
|||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.PropertyValueStore.CreateMany(values)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.CreateMany", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPropertyValueStore) Delete(groupID string, id string) error {
|
||||
start := time.Now()
|
||||
|
||||
|
|
|
|||
|
|
@ -1741,6 +1741,14 @@
|
|||
"id": "api.config.update_config.translations.app_error",
|
||||
"translation": "Failed to update server translations."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.comment_required",
|
||||
"translation": "Please add a comment explaining why you’re flagging this message."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.comment_too_long",
|
||||
"translation": "Message flagging reason cannot be longer than {{.MaxLength}} characters."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.disabled",
|
||||
"translation": "Content flagging feature is disabled."
|
||||
|
|
@ -1749,6 +1757,26 @@
|
|||
"id": "api.content_flagging.error.license",
|
||||
"translation": "Your license does not support content flagging."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.not_available_on_team",
|
||||
"translation": "Content flagging feature is not enabled on this team."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.post_not_in_progress",
|
||||
"translation": "The flagged post must be in pending or assigned status to be kept or removed."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.reason_invalid",
|
||||
"translation": "Unknown reason specified for flagging message."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.reason_required",
|
||||
"translation": "Please select a reason for flagging this message."
|
||||
},
|
||||
{
|
||||
"id": "api.content_flagging.error.reviewer_only",
|
||||
"translation": "You do not have permission to view this resource."
|
||||
},
|
||||
{
|
||||
"id": "api.context.404.app_error",
|
||||
"translation": "Sorry, we could not find the page."
|
||||
|
|
@ -5138,6 +5166,102 @@
|
|||
"id": "app.compliance.save.saving.app_error",
|
||||
"translation": "We encountered an error saving the compliance report."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.can_flag_post.in_progress",
|
||||
"translation": "Cannot flag this post as is already flagged."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.can_flag_post.removed",
|
||||
"translation": "Cannot flag this post it was removed in a previous flagging request."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.can_flag_post.retained",
|
||||
"translation": "Cannot flag this post as it was retained in a previous flagging request."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.can_flag_post.unknown",
|
||||
"translation": "Cannot flag this post as it is in unknown status."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.create_property_values.app_error",
|
||||
"translation": "Unable to save property values for the flagged post."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.delete_post.app_error",
|
||||
"translation": "Unable to soft-delete the flagged post."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.flag_post.marshal_comment.app_error",
|
||||
"translation": "Failed to marshal flagging user's comment"
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.flag_post.marshal_reason.app_error",
|
||||
"translation": "Failed to marshal flagging user's reason"
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.flag_post_confirmation.message",
|
||||
"translation": "The message from @{{.username}} has been flagged for review. You will be notified once it is reviewed by a Content Reviewer. "
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.get_group.error",
|
||||
"translation": "Failed to get Content Flagging bot."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.get_status_property.app_error",
|
||||
"translation": "Failed to get Status property field."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.get_users_in_team.app_error",
|
||||
"translation": "Failed to search reviewers in team."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.keep_flag_post.marshal_comment.app_error",
|
||||
"translation": "Failed to marshal reviewer comment"
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.keep_post.status_update.app_error",
|
||||
"translation": "Failed to update flagged post status when undeleting flagged post "
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.keep_post.undelete.app_error",
|
||||
"translation": "Failed to update post in database when attempting to undelete the flagged post."
|
||||
},
|
||||
{
|
||||
"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.no_status_property.app_error",
|
||||
"translation": "Cannot fetch flagged post as the post is not flagged."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.permanently_delete.app_error",
|
||||
"translation": "Failed to overwrite post with scrubbed post when permanently deleting flagged post."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.permanently_delete.marshal_comment.app_error",
|
||||
"translation": "Failed to marshal reviewer comment"
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.permanently_delete.update_property_value.app_error",
|
||||
"translation": "Failed to update flagged post status when permanently deleting flagged post."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.restore_file_info.app_error",
|
||||
"translation": "Failed to restore file info for the flagged post."
|
||||
},
|
||||
{
|
||||
"id": "app.content_flagging.search_property_fields.app_error",
|
||||
"translation": "Failed to search Content Flagging property fields."
|
||||
},
|
||||
{
|
||||
"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_status_property.app_error",
|
||||
"translation": "Failed to search Property Values for the flagged post."
|
||||
},
|
||||
{
|
||||
"id": "app.create_basic_user.save_member.app_error",
|
||||
"translation": "Unable to create default team memberships"
|
||||
|
|
@ -6846,6 +6970,10 @@
|
|||
"id": "app.post_persistent_notification.delete_by_team.app_error",
|
||||
"translation": "Unable to delete the persistent notifications by team."
|
||||
},
|
||||
{
|
||||
"id": "app.post_priority.delete_for_post.app_error",
|
||||
"translation": "Failed to permanently delete post priority data from database for post."
|
||||
},
|
||||
{
|
||||
"id": "app.post_priority.delete_persistent_notification_post.app_error",
|
||||
"translation": "Failed to delete persistent notification post"
|
||||
|
|
@ -7212,6 +7340,10 @@
|
|||
"id": "app.system.complete_onboarding_request.no_first_user",
|
||||
"translation": "Onboarding can only be completed by a System Administrator."
|
||||
},
|
||||
{
|
||||
"id": "app.system.content_review_bot.bot_displayname",
|
||||
"translation": "Content Review"
|
||||
},
|
||||
{
|
||||
"id": "app.system.get_by_name.app_error",
|
||||
"translation": "Unable to find the system variable."
|
||||
|
|
|
|||
|
|
@ -560,17 +560,17 @@ func (_m *MockAppIface) PermanentDeleteChannel(rctx request.CTX, channel *model.
|
|||
return r0
|
||||
}
|
||||
|
||||
// PreparePostForClient provides a mock function with given fields: rctx, post, isNewPost, includeDeleted, includePriority
|
||||
func (_m *MockAppIface) PreparePostForClient(rctx request.CTX, post *model.Post, isNewPost bool, includeDeleted bool, includePriority bool) *model.Post {
|
||||
ret := _m.Called(rctx, post, isNewPost, includeDeleted, includePriority)
|
||||
// PreparePostForClient provides a mock function with given fields: rctx, post, opts
|
||||
func (_m *MockAppIface) PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
ret := _m.Called(rctx, post, opts)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for PreparePostForClient")
|
||||
}
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, bool, bool, bool) *model.Post); ok {
|
||||
r0 = rf(rctx, post, isNewPost, includeDeleted, includePriority)
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.PreparePostForClientOpts) *model.Post); ok {
|
||||
r0 = rf(rctx, post, opts)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Post)
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ type AppIface interface {
|
|||
DeleteAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) *model.AppError
|
||||
SaveAcknowledgementsForPost(rctx request.CTX, postID string, userIDs []string) ([]*model.PostAcknowledgement, *model.AppError)
|
||||
GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError)
|
||||
PreparePostForClient(rctx request.CTX, post *model.Post, isNewPost, includeDeleted, includePriority bool) *model.Post
|
||||
PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post
|
||||
}
|
||||
|
||||
// errNotFound allows checking against Store.ErrNotFound errors without making Store a dependency.
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ func (scs *Service) handlePostError(postId string, task syncTask, rc *model.Remo
|
|||
}
|
||||
|
||||
// Populate metadata for the retry post
|
||||
post = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, false, false, true)
|
||||
post = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
|
||||
|
||||
syncMsg := model.NewSyncMsg(task.channelID)
|
||||
syncMsg.Posts = []*model.Post{post}
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ func (scs *Service) fetchPostsForSync(sd *syncData) error {
|
|||
// Populate metadata for all posts before syncing
|
||||
for i, post := range sd.posts {
|
||||
if post != nil {
|
||||
sd.posts[i] = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, false, false, true)
|
||||
sd.posts[i] = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -427,3 +427,11 @@ const (
|
|||
AuditEventUpdateIncomingHook = "updateIncomingHook" // update incoming webhook
|
||||
AuditEventUpdateOutgoingHook = "updateOutgoingHook" // update outgoing webhook
|
||||
)
|
||||
|
||||
// Content Flagging
|
||||
const (
|
||||
AuditEventFlagPost = "flagPost" // flag post for review
|
||||
AuditEventGetFlaggedPost = "getFlaggedPost" // get flagged post details
|
||||
AuditEventPermanentlyRemoveFlaggedPost = "permanentlyRemoveFlaggedPost" // permanently remove flagged post
|
||||
AuditEventKeepFlaggedPost = "keepFlaggedPost" // keep flagged post
|
||||
)
|
||||
|
|
|
|||
|
|
@ -734,6 +734,21 @@ func (c *Client4) GetFlaggingConfiguration(ctx context.Context) (*ContentFlaggin
|
|||
return &config, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) FlagPostForContentReview(ctx context.Context, postId string, flagRequest *FlagContentRequest) (*Response, error) {
|
||||
buf, err := json.Marshal(flagRequest)
|
||||
if err != nil {
|
||||
return nil, NewAppError("FlagPostForContentReview", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
r, err := c.DoAPIPost(ctx, fmt.Sprintf("%s/post/%s/flag", c.contentFlaggingRoute(), postId), string(buf))
|
||||
if err != nil {
|
||||
return BuildResponse(r), err
|
||||
}
|
||||
|
||||
defer closeBody(r)
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetTeamPostFlaggingFeatureStatus(ctx context.Context, teamId string) (map[string]bool, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/team/"+teamId+"/status", "")
|
||||
if err != nil {
|
||||
|
|
@ -747,6 +762,32 @@ func (c *Client4) GetTeamPostFlaggingFeatureStatus(ctx context.Context, teamId s
|
|||
return status, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetPostPropertyValues(ctx context.Context, postId string) (*[]PropertyValue, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/post/"+postId+"/field_values", "")
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
var propertyValues []PropertyValue
|
||||
if err := json.NewDecoder(r.Body).Decode(&propertyValues); err != nil {
|
||||
return nil, nil, NewAppError("GetFlaggingConfiguration", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return &propertyValues, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetContentFlaggedPost(ctx context.Context, postId string) (*Post, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/post/"+postId, "")
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
var post Post
|
||||
if err := json.NewDecoder(r.Body).Decode(&post); err != nil {
|
||||
return nil, nil, NewAppError("GetContentFlaggedPost", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return &post, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) bookmarksRoute(channelId string) string {
|
||||
return c.channelRoute(channelId) + "/bookmarks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,64 @@
|
|||
|
||||
package model
|
||||
|
||||
const ContentFlaggingGroupName = "content_flagging"
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
ContentFlaggingGroupName = "content_flagging"
|
||||
ContentFlaggingPostType = PostCustomTypePrefix + "spillage_report"
|
||||
ContentFlaggingBotUsername = "content-review"
|
||||
|
||||
commentMaxRunes = 1000
|
||||
)
|
||||
|
||||
const (
|
||||
ContentFlaggingStatusPending = "Pending"
|
||||
ContentFlaggingStatusAssigned = "Assigned"
|
||||
ContentFlaggingStatusRemoved = "Removed"
|
||||
ContentFlaggingStatusRetained = "Retained"
|
||||
)
|
||||
|
||||
type FlagContentRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func (f *FlagContentRequest) IsValid(commentRequired bool, validReasons []string) *AppError {
|
||||
if f.Reason == "" {
|
||||
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.reason_required", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !slices.Contains(validReasons, f.Reason) {
|
||||
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.reason_invalid", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if commentRequired && f.Comment == "" {
|
||||
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.comment_required", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(f.Comment) > commentMaxRunes {
|
||||
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.comment_too_long", map[string]any{"MaxLength": commentMaxRunes}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type FlagContentActionRequest struct {
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func (f *FlagContentActionRequest) IsValid(commentRequired bool) *AppError {
|
||||
if commentRequired && f.Comment == "" {
|
||||
return NewAppError("FlagContentActionRequest.IsValid", "api.content_flagging.error.comment_required", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(f.Comment) > commentMaxRunes {
|
||||
return NewAppError("FlagContentActionRequest.IsValid", "api.content_flagging.error.comment_too_long", map[string]any{"MaxLength": commentMaxRunes}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,6 +227,9 @@ func (cfs *ContentFlaggingSettings) IsValid() *AppError {
|
|||
}
|
||||
|
||||
type ContentFlaggingReportingConfig struct {
|
||||
Reasons *[]string `json:"reasons"`
|
||||
ReporterCommentRequired *bool `json:"reporter_comment_required"`
|
||||
Reasons *[]string `json:"reasons"`
|
||||
ReporterCommentRequired *bool `json:"reporter_comment_required"`
|
||||
ReviewerCommentRequired *bool `json:"reviewer_comment_required"`
|
||||
NotifyReporterOnDismissal *bool `json:"notify_reporter_on_dismissal,omitempty"`
|
||||
NotifyReporterOnRemoval *bool `json:"notify_reporter_on_removal,omitempty"`
|
||||
}
|
||||
|
|
|
|||
118
server/public/model/content_flagging_test.go
Normal file
118
server/public/model/content_flagging_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFlagContentRequest_IsValid(t *testing.T) {
|
||||
validReasons := []string{"spam", "harassment", "inappropriate"}
|
||||
|
||||
t.Run("valid request without comment", func(t *testing.T) {
|
||||
req := &FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "",
|
||||
}
|
||||
err := req.IsValid(false, validReasons)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid request with comment", func(t *testing.T) {
|
||||
req := &FlagContentRequest{
|
||||
Reason: "harassment",
|
||||
Comment: "This is inappropriate content",
|
||||
}
|
||||
err := req.IsValid(false, validReasons)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid request with comment when required", func(t *testing.T) {
|
||||
req := &FlagContentRequest{
|
||||
Reason: "inappropriate",
|
||||
Comment: "This violates community guidelines",
|
||||
}
|
||||
err := req.IsValid(true, validReasons)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing comment when required", func(t *testing.T) {
|
||||
req := &FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "",
|
||||
}
|
||||
err := req.IsValid(true, validReasons)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "api.content_flagging.error.comment_required", err.Id)
|
||||
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("missing reason", func(t *testing.T) {
|
||||
req := &FlagContentRequest{
|
||||
Reason: "",
|
||||
Comment: "Some comment",
|
||||
}
|
||||
err := req.IsValid(false, validReasons)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "api.content_flagging.error.reason_required", err.Id)
|
||||
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("invalid reason", func(t *testing.T) {
|
||||
req := &FlagContentRequest{
|
||||
Reason: "invalid_reason",
|
||||
Comment: "Some comment",
|
||||
}
|
||||
err := req.IsValid(false, validReasons)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "api.content_flagging.error.reason_invalid", err.Id)
|
||||
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("comment too long", func(t *testing.T) {
|
||||
longComment := strings.Repeat("a", commentMaxRunes+1)
|
||||
req := &FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: longComment,
|
||||
}
|
||||
err := req.IsValid(false, validReasons)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "api.content_flagging.error.comment_too_long", err.Id)
|
||||
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
|
||||
assert.Equal(t, commentMaxRunes, err.params["MaxLength"])
|
||||
})
|
||||
|
||||
t.Run("comment at max length", func(t *testing.T) {
|
||||
maxLengthComment := strings.Repeat("a", commentMaxRunes)
|
||||
req := &FlagContentRequest{
|
||||
Reason: "harassment",
|
||||
Comment: maxLengthComment,
|
||||
}
|
||||
err := req.IsValid(false, validReasons)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("unicode comment length validation", func(t *testing.T) {
|
||||
// Test with unicode characters that take multiple bytes
|
||||
unicodeComment := strings.Repeat("🚀", commentMaxRunes+1)
|
||||
req := &FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: unicodeComment,
|
||||
}
|
||||
err := req.IsValid(false, validReasons)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "api.content_flagging.error.comment_too_long", err.Id)
|
||||
})
|
||||
|
||||
t.Run("empty valid reasons list", func(t *testing.T) {
|
||||
req := &FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "Some comment",
|
||||
}
|
||||
err := req.IsValid(false, []string{})
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "api.content_flagging.error.reason_invalid", err.Id)
|
||||
})
|
||||
}
|
||||
|
|
@ -1115,3 +1115,11 @@ func DefaultUpdatePostOptions() *UpdatePostOptions {
|
|||
IsRestorePost: false,
|
||||
}
|
||||
}
|
||||
|
||||
type PreparePostForClientOpts struct {
|
||||
IsNewPost bool
|
||||
IsEditPost bool
|
||||
IncludePriority bool
|
||||
RetainContent bool
|
||||
IncludeDeleted bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import (
|
|||
const (
|
||||
PropertyValueTargetIDMaxRunes = 255
|
||||
PropertyValueTargetTypeMaxRunes = 255
|
||||
|
||||
PropertyValueTargetTypePost = "post"
|
||||
PropertyValueTargetTypeUser = "user"
|
||||
)
|
||||
|
||||
type PropertyValue struct {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ const (
|
|||
WebsocketEventCPAFieldUpdated WebsocketEventType = "custom_profile_attributes_field_updated"
|
||||
WebsocketEventCPAFieldDeleted WebsocketEventType = "custom_profile_attributes_field_deleted"
|
||||
WebsocketEventCPAValuesUpdated WebsocketEventType = "custom_profile_attributes_values_updated"
|
||||
WebsocketContentFlaggingReportValueUpdated WebsocketEventType = "content_flagging_report_value_updated"
|
||||
|
||||
WebSocketMsgTypeResponse = "response"
|
||||
WebSocketMsgTypeEvent = "event"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
HostedCustomerTypes,
|
||||
ChannelBookmarkTypes,
|
||||
ScheduledPostTypes,
|
||||
ContentFlaggingTypes,
|
||||
} from 'mattermost-redux/action_types';
|
||||
import {getStandardAnalytics} from 'mattermost-redux/actions/admin';
|
||||
import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
|
||||
|
|
@ -646,6 +647,9 @@ export function handleEvent(msg) {
|
|||
case SocketEvents.CPA_FIELD_DELETED:
|
||||
dispatch(handleCustomAttributesDeleted(msg));
|
||||
break;
|
||||
case SocketEvents.CONTENT_FLAGGING_REPORT_VALUE_CHANGED:
|
||||
dispatch(handleContentFlaggingReportValueChanged(msg));
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
|
|
@ -1976,3 +1980,10 @@ export function handleCustomAttributesDeleted(msg) {
|
|||
data: msg.data.field_id,
|
||||
};
|
||||
}
|
||||
|
||||
export function handleContentFlaggingReportValueChanged(msg) {
|
||||
return {
|
||||
type: ContentFlaggingTypes.CONTENT_FLAGGING_REPORT_VALUE_UPDATED,
|
||||
data: msg.data,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
|
||||
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
|
||||
|
||||
import {
|
||||
getContentFlaggingConfig,
|
||||
getPostContentFlaggingValues,
|
||||
loadPostContentFlaggingFields,
|
||||
} from 'mattermost-redux/actions/content_flagging';
|
||||
import {
|
||||
contentFlaggingConfig,
|
||||
contentFlaggingFields,
|
||||
postContentFlaggingValues,
|
||||
} from 'mattermost-redux/selectors/entities/content_flagging';
|
||||
|
||||
import {makeUseEntity} from 'components/common/hooks/useEntity';
|
||||
|
||||
export const useContentFlaggingFields = makeUseEntity<NameMappedPropertyFields | undefined>({
|
||||
name: 'useContentFlaggingFields',
|
||||
fetch: loadPostContentFlaggingFields,
|
||||
selector: contentFlaggingFields,
|
||||
});
|
||||
|
||||
export const usePostContentFlaggingValues = makeUseEntity<Array<PropertyValue<unknown>>>({
|
||||
name: 'usePostContentFlaggingValues',
|
||||
fetch: getPostContentFlaggingValues,
|
||||
selector: postContentFlaggingValues,
|
||||
});
|
||||
|
||||
export const useContentFlaggingConfig = makeUseEntity<ContentFlaggingConfig>({
|
||||
name: 'useContentFlaggingConfig',
|
||||
fetch: getContentFlaggingConfig,
|
||||
selector: contentFlaggingConfig,
|
||||
});
|
||||
|
|
@ -31,6 +31,7 @@ export type OwnProps = {
|
|||
isEditHistory?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableActions?: boolean;
|
||||
usePostAsSource?: boolean;
|
||||
}
|
||||
|
||||
function makeMapStateToProps() {
|
||||
|
|
@ -42,7 +43,13 @@ function makeMapStateToProps() {
|
|||
|
||||
var fileInfos: FileInfo[];
|
||||
|
||||
if (ownProps.isEditHistory) {
|
||||
if (ownProps.usePostAsSource) {
|
||||
if (ownProps.post.metadata && ownProps.post.metadata.files) {
|
||||
fileInfos = ownProps.post.metadata.files;
|
||||
} else {
|
||||
fileInfos = [];
|
||||
}
|
||||
} else if (ownProps.isEditHistory) {
|
||||
fileInfos = getFilesForEditHistory(state, ownProps.post);
|
||||
} else {
|
||||
fileInfos = selectFilesForPost(state, postId);
|
||||
|
|
|
|||
|
|
@ -52,4 +52,12 @@
|
|||
height: 110px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.FlagPostModal__request-error {
|
||||
display: flex;
|
||||
width: 90%;
|
||||
align-items: center;
|
||||
color: var(--error-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react';
|
||||
import {screen, waitFor, act} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
|
|
@ -14,6 +16,9 @@ import type {GlobalState} from 'types/store';
|
|||
|
||||
import FlagPostModal from './flag_post_modal';
|
||||
|
||||
jest.mock('mattermost-redux/client');
|
||||
const mockedClient4 = jest.mocked(Client4);
|
||||
|
||||
describe('components/FlagPostModal', () => {
|
||||
const baseState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
|
|
@ -86,4 +91,45 @@ describe('components/FlagPostModal', () => {
|
|||
|
||||
expect(screen.getByTestId('FlagPostModal__comment_section_title')).toHaveTextContent('Comment (optional)');
|
||||
});
|
||||
|
||||
it('should call Client4.flagPost when submit button is clicked with valid form data', async () => {
|
||||
const mockFlagPost = jest.fn().mockResolvedValue({});
|
||||
mockedClient4.flagPost = mockFlagPost;
|
||||
|
||||
const onExited = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<FlagPostModal
|
||||
postId={'post_id'}
|
||||
onExited={onExited}
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
// Select a reason
|
||||
await userEvent.click(screen.getByText('Select a reason for flagging'));
|
||||
await userEvent.click(screen.getByText('Reason 1'));
|
||||
|
||||
// Add a comment
|
||||
const commentTextbox = screen.getByPlaceholderText('Describe your concern...');
|
||||
await act(async () => {
|
||||
await userEvent.type(commentTextbox, 'This is inappropriate content');
|
||||
});
|
||||
|
||||
// Click submit
|
||||
const submitButton = screen.getByText('Submit');
|
||||
await act(async () => {
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// Verify API call was made
|
||||
await waitFor(() => {
|
||||
expect(mockFlagPost).toHaveBeenCalledWith('post_id', 'Reason 1', 'This is inappropriate content');
|
||||
});
|
||||
|
||||
// Verify modal was closed
|
||||
await waitFor(() => {
|
||||
expect(onExited).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import {useDispatch, useSelector} from 'react-redux';
|
|||
import ReactSelect, {type StylesConfig} from 'react-select';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {PostPreviewMetadata} from '@mattermost/types/posts';
|
||||
|
||||
import {getContentFlaggingConfig} from 'mattermost-redux/actions/content_flagging';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {contentFlaggingConfig} from 'mattermost-redux/selectors/entities/content_flagging';
|
||||
import {getPost} from 'mattermost-redux/selectors/entities/posts';
|
||||
|
|
@ -47,6 +49,8 @@ export default function FlagPostModal({postId, onExited}: Props) {
|
|||
const [reason, setReason] = React.useState<string>('');
|
||||
const [commentError, setCommentError] = React.useState<string>('');
|
||||
const [reasonError, setReasonError] = React.useState<string>('');
|
||||
const [requestError, setRequestError] = React.useState<string>('');
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false);
|
||||
const [showCommentPreview, setShowCommentPreview] = React.useState<boolean>(false);
|
||||
|
||||
const label = formatMessage({id: 'flag_message_modal.heading', defaultMessage: 'Flag message'});
|
||||
|
|
@ -67,7 +71,7 @@ export default function FlagPostModal({postId, onExited}: Props) {
|
|||
return [];
|
||||
}
|
||||
|
||||
return contentFlaggingSettings.reasons.map((reason) => ({
|
||||
return contentFlaggingSettings.reasons.map((reason: string) => ({
|
||||
value: reason,
|
||||
label: reason,
|
||||
}));
|
||||
|
|
@ -138,16 +142,24 @@ export default function FlagPostModal({postId, onExited}: Props) {
|
|||
return hasError;
|
||||
}, [comment, contentFlaggingSettings?.reporter_comment_required, formatMessage, reason]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const hasError = validateForm();
|
||||
if (hasError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement the flagging action here in a follow up PR
|
||||
|
||||
onExited();
|
||||
}, [validateForm, onExited]);
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await Client4.flagPost(post.id, reason, comment);
|
||||
onExited();
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
setRequestError((error as ServerError).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [validateForm, post.id, reason, comment, onExited]);
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
|
|
@ -163,6 +175,7 @@ export default function FlagPostModal({postId, onExited}: Props) {
|
|||
confirmButtonText={submitButtonText}
|
||||
onExited={onExited}
|
||||
autoCloseOnConfirmButton={false}
|
||||
isConfirmDisabled={submitting}
|
||||
>
|
||||
<div className='FlagPostModal FlagPostModal__body'>
|
||||
<div className='FlagPostModal__section FlagPostModal__post_preview'>
|
||||
|
|
@ -233,6 +246,12 @@ export default function FlagPostModal({postId, onExited}: Props) {
|
|||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
{requestError &&
|
||||
<div className='FlagPostModal__request-error'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
<span>{requestError}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act} from '@testing-library/react';
|
||||
import type {ComponentProps} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type {Post, PostType} from '@mattermost/types/posts';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
|
|
@ -17,6 +19,13 @@ import PostMarkdown from './post_markdown';
|
|||
jest.mock('components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer', () => {
|
||||
return jest.fn(() => <div data-testid='post-preview-property-renderer-mock'>{'PostPreviewPropertyRenderer Mock'}</div>);
|
||||
});
|
||||
jest.mock('mattermost-redux/client');
|
||||
|
||||
jest.mock('components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal', () => {
|
||||
return jest.fn(() => <div data-testid='keep-remove-flagged-message-confirmation-modal'>{'KeepRemoveFlaggedMessageConfirmationModal Mock'}</div>);
|
||||
});
|
||||
|
||||
const mockedClient4 = jest.mocked(Client4);
|
||||
|
||||
describe('components/PostMarkdown', () => {
|
||||
const baseProps: ComponentProps<typeof PostMarkdown> = {
|
||||
|
|
@ -289,11 +298,18 @@ describe('components/PostMarkdown', () => {
|
|||
expect(screen.queryByText('world!', {exact: true})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render data spillage card', () => {
|
||||
test('should render data spillage card', async () => {
|
||||
const reportedPost = TestHelper.getPostMock({
|
||||
id: 'reported_post_id',
|
||||
message: 'This is the reported post',
|
||||
user_id: 'user_id_1',
|
||||
channel_id: 'channel_id_1',
|
||||
});
|
||||
|
||||
const dataSpillageReportPost = TestHelper.getPostMock({
|
||||
type: PostTypes.CUSTOM_DATA_SPILLAGE_REPORT as PostType,
|
||||
props: {
|
||||
reported_post_id: 'reported_post_id',
|
||||
reported_post_id: reportedPost.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -302,7 +318,10 @@ describe('components/PostMarkdown', () => {
|
|||
message: 'See ~test',
|
||||
post: dataSpillageReportPost,
|
||||
};
|
||||
|
||||
mockedClient4.getFlaggedPost = jest.fn().mockResolvedValue(reportedPost);
|
||||
renderWithContext(<PostMarkdown {...props}/>, state);
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.queryByTestId('data-spillage-report')).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {Post} from '@mattermost/types/posts';
|
|||
import {Posts} from 'mattermost-redux/constants';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
import DataSpillageReport from 'components/post_view/data_spillage_report/data_spillage_report';
|
||||
import {DataSpillageReport} from 'components/post_view/data_spillage_report/data_spillage_report';
|
||||
|
||||
import {PostTypes} from 'utils/constants';
|
||||
import {isChannelNamesMap, type TextFormattingOptions} from 'utils/text_formatting';
|
||||
|
|
|
|||
|
|
@ -5,12 +5,21 @@ import {screen} from '@testing-library/react';
|
|||
import React from 'react';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import DataSpillageAction from './data_spillage_actions';
|
||||
|
||||
describe('DataSpillageAction', () => {
|
||||
test('should render both action buttons', () => {
|
||||
renderWithContext(<DataSpillageAction/>);
|
||||
const flaggedPost = TestHelper.getPostMock();
|
||||
const reportingUser = TestHelper.getUserMock();
|
||||
|
||||
renderWithContext(
|
||||
<DataSpillageAction
|
||||
flaggedPost={flaggedPost}
|
||||
reportingUser={reportingUser}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('data-spillage-action')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('data-spillage-action-remove-message')).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,59 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {useCallback} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import './data_spillage_actions.scss';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import './data_spillage_actions.scss';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {closeModal, openModal} from 'actions/views/modals';
|
||||
|
||||
import KeepRemoveFlaggedMessageConfirmationModal
|
||||
from 'components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
type Props = {
|
||||
flaggedPost: Post;
|
||||
reportingUser: UserProfile;
|
||||
}
|
||||
|
||||
export default function DataSpillageAction({flaggedPost, reportingUser}: Props) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleRemoveMessage = useCallback(() => {
|
||||
const data = {
|
||||
modalId: ModalIdentifiers.REMOVE_FLAGGED_POST,
|
||||
dialogType: KeepRemoveFlaggedMessageConfirmationModal,
|
||||
dialogProps: {
|
||||
flaggedPost,
|
||||
reportingUser,
|
||||
action: 'remove' as const,
|
||||
onExited: () => closeModal(ModalIdentifiers.REMOVE_FLAGGED_POST),
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(openModal(data));
|
||||
}, [dispatch, flaggedPost, reportingUser]);
|
||||
|
||||
const handleKeepMessage = useCallback(() => {
|
||||
const data = {
|
||||
modalId: ModalIdentifiers.REMOVE_FLAGGED_POST,
|
||||
dialogType: KeepRemoveFlaggedMessageConfirmationModal,
|
||||
dialogProps: {
|
||||
flaggedPost,
|
||||
reportingUser,
|
||||
action: 'keep' as const,
|
||||
onExited: () => closeModal(ModalIdentifiers.REMOVE_FLAGGED_POST),
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(openModal(data));
|
||||
}, [dispatch, flaggedPost, reportingUser]);
|
||||
|
||||
export default function DataSpillageAction() {
|
||||
return (
|
||||
<div
|
||||
className='DataSpillageAction'
|
||||
|
|
@ -14,6 +62,7 @@ export default function DataSpillageAction() {
|
|||
<button
|
||||
className='btn btn-danger btn-sm'
|
||||
data-testid='data-spillage-action-remove-message'
|
||||
onClick={handleRemoveMessage}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='data_spillage_report.remove_message.button_text'
|
||||
|
|
@ -24,6 +73,7 @@ export default function DataSpillageAction() {
|
|||
<button
|
||||
className='btn btn-tertiary btn-sm'
|
||||
data-testid='data-spillage-action-keep-message'
|
||||
onClick={handleKeepMessage}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='data_spillage_report.keep_message.button_text'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import {selectPostFromRightHandSideSearch} from 'actions/views/rhs';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
export default function DataSpillageFooter({post}: Props) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (post) {
|
||||
dispatch(selectPostFromRightHandSideSearch(post));
|
||||
}
|
||||
}, [dispatch, post]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='DataSpillageFooter'
|
||||
data-testid='data-spillage-footer'
|
||||
>
|
||||
<button
|
||||
className='btn btn-primary btn-sm'
|
||||
data-testid='data-spillage-action-view-details'
|
||||
onClick={onClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='data_spillage_report.view_details.button_text'
|
||||
defaultMessage='View details'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,38 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import DataSpillageReport from 'components/post_view/data_spillage_report/data_spillage_report';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {DataSpillageReport} from 'components/post_view/data_spillage_report/data_spillage_report';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
jest.mock('components/common/hooks/useUser');
|
||||
jest.mock('components/common/hooks/useChannel');
|
||||
jest.mock('components/common/hooks/usePost');
|
||||
jest.mock('mattermost-redux/actions/posts');
|
||||
jest.mock('components/common/hooks/useContentFlaggingFields');
|
||||
jest.mock('mattermost-redux/client');
|
||||
|
||||
const mockedUseUser = require('components/common/hooks/useUser').useUser as jest.MockedFunction<any>;
|
||||
const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction<any>;
|
||||
const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
|
||||
|
||||
const mockGetPost = require('mattermost-redux/actions/posts').getPost as jest.MockedFunction<any>;
|
||||
const useContentFlaggingFields = require('components/common/hooks/useContentFlaggingFields').useContentFlaggingFields as jest.MockedFunction<any>;
|
||||
const usePostContentFlaggingValues = require('components/common/hooks/useContentFlaggingFields').usePostContentFlaggingValues as jest.MockedFunction<any>;
|
||||
|
||||
const mockedClient4 = jest.mocked(Client4);
|
||||
|
||||
describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
|
||||
const reportingUser = TestHelper.getUserMock({
|
||||
id: 'ewgposajm3fwpjbqu1t6scncia',
|
||||
|
|
@ -57,11 +77,6 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
|
|||
[reportedPostAuthor.id]: reportedPostAuthor,
|
||||
},
|
||||
},
|
||||
posts: {
|
||||
posts: {
|
||||
[reportedPost.id]: reportedPost,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
channels: {
|
||||
[reportedPostChannel.id]: reportedPostChannel,
|
||||
|
|
@ -75,6 +90,231 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const contentFlaggingFields = {
|
||||
action_time: {
|
||||
id: 'wcdwx96ratrrbq5uuhyz3zq48r',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'action_time',
|
||||
type: 'text',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661623,
|
||||
update_at: 1756788661623,
|
||||
delete_at: 0,
|
||||
},
|
||||
actor_comment: {
|
||||
id: 'f3s8fsgn978bbne96s6tqpdife',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'actor_comment',
|
||||
type: 'text',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661624,
|
||||
update_at: 1756788661624,
|
||||
delete_at: 0,
|
||||
},
|
||||
actor_user_id: {
|
||||
id: 'z1bpj14kgfdjzmnuyy6oqcyufh',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'actor_user_id',
|
||||
type: 'user',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661626,
|
||||
update_at: 1756788661626,
|
||||
delete_at: 0,
|
||||
},
|
||||
flagged_post_id: {
|
||||
id: 'jssh4fbn9jrfxjf4fsr7zdu65y',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'flagged_post_id',
|
||||
type: 'text',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661623,
|
||||
update_at: 1756788661623,
|
||||
delete_at: 0,
|
||||
},
|
||||
reporting_comment: {
|
||||
id: 'sx7h53tdsbfb985edkmze71j3c',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'reporting_comment',
|
||||
type: 'text',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661625,
|
||||
update_at: 1756788661625,
|
||||
delete_at: 0,
|
||||
},
|
||||
reporting_reason: {
|
||||
id: '5knyqectdfbi98rab3zz4hsyhh',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'reporting_reason',
|
||||
type: 'select',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661624,
|
||||
update_at: 1756788661624,
|
||||
delete_at: 0,
|
||||
},
|
||||
reporting_time: {
|
||||
id: '5cib5g3ag3gs3gxyg7awjd6csh',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'reporting_time',
|
||||
type: 'text',
|
||||
attrs: {
|
||||
subType: 'timestamp',
|
||||
},
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661625,
|
||||
update_at: 1756788661625,
|
||||
delete_at: 0,
|
||||
},
|
||||
reporting_user_id: {
|
||||
id: '1is7ir68bp8nup3rr1pp6d7fsr',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'reporting_user_id',
|
||||
type: 'user',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661625,
|
||||
update_at: 1756788661625,
|
||||
delete_at: 0,
|
||||
},
|
||||
reviewer_user_id: {
|
||||
id: 'g6hrg3uugbyqzyyb9kx8jgpbwh',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'reviewer_user_id',
|
||||
type: 'user',
|
||||
attrs: null,
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661624,
|
||||
update_at: 1756788661624,
|
||||
delete_at: 0,
|
||||
},
|
||||
status: {
|
||||
id: 'kd9n7tf9n3ynjczqpkpjkbzgoh',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
attrs: {
|
||||
options: [
|
||||
{
|
||||
color: 'light_grey',
|
||||
name: 'Pending',
|
||||
},
|
||||
{
|
||||
color: 'dark_blue',
|
||||
name: 'Assigned',
|
||||
},
|
||||
{
|
||||
color: 'dark_red',
|
||||
name: 'Removed',
|
||||
},
|
||||
{
|
||||
color: 'light_blue',
|
||||
name: 'Retained',
|
||||
},
|
||||
],
|
||||
},
|
||||
target_id: '',
|
||||
target_type: '',
|
||||
create_at: 1756788661623,
|
||||
update_at: 1756788661623,
|
||||
delete_at: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const postContentFlaggingValues = [
|
||||
{
|
||||
id: 'cnth3s1rot88zpz3hwy99uet7y',
|
||||
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
|
||||
target_type: 'post',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
field_id: 'kd9n7tf9n3ynjczqpkpjkbzgoh',
|
||||
value: 'Pending',
|
||||
create_at: 1756790533486,
|
||||
update_at: 1756790533486,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'gg5aq8iefpn978po54fd9xf1br',
|
||||
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
|
||||
target_type: 'post',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
field_id: '5knyqectdfbi98rab3zz4hsyhh',
|
||||
value: 'Sensitive data',
|
||||
create_at: 1756790533487,
|
||||
update_at: 1756790533487,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'nbooe396mf8zjq5pjk931gtf5y',
|
||||
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
|
||||
target_type: 'post',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
field_id: '1is7ir68bp8nup3rr1pp6d7fsr',
|
||||
value: reportingUser.id,
|
||||
create_at: 1756790533487,
|
||||
update_at: 1756790533487,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'dxzrb4g9xfn5jn5mgxcnmqauzo',
|
||||
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
|
||||
target_type: 'post',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
field_id: '5cib5g3ag3gs3gxyg7awjd6csh',
|
||||
value: 1756790533486,
|
||||
create_at: 1756790533488,
|
||||
update_at: 1756790533488,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'mx4ez9di7bgebfa8y8r5uodjkc',
|
||||
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
|
||||
target_type: 'post',
|
||||
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
|
||||
field_id: 'sx7h53tdsbfb985edkmze71j3c',
|
||||
value: 'Please review this post for potential violations',
|
||||
create_at: 1756790533488,
|
||||
update_at: 1756790533488,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
// Mock all hooks before any component rendering
|
||||
mockedUseUser.mockImplementation((userId: string) => {
|
||||
if (userId === reportingUser.id) {
|
||||
return reportingUser;
|
||||
} else if (userId === reportedPostAuthor.id) {
|
||||
return reportedPostAuthor;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockedUsePost.mockReturnValue(null);
|
||||
mockUseChannel.mockReturnValue(reportedPostChannel);
|
||||
|
||||
// Mock the action to return a resolved promise instead of dispatching
|
||||
mockGetPost.mockResolvedValue({type: 'MOCK_ACTION', data: reportedPost});
|
||||
|
||||
useContentFlaggingFields.mockReturnValue(contentFlaggingFields);
|
||||
usePostContentFlaggingValues.mockReturnValue(postContentFlaggingValues);
|
||||
mockedClient4.getFlaggedPost = jest.fn().mockResolvedValue(reportedPost);
|
||||
});
|
||||
|
||||
it('should render selected fields when not in RHS', async () => {
|
||||
renderWithContext(
|
||||
<DataSpillageReport
|
||||
|
|
@ -84,6 +324,8 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
|
|||
baseState,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
// validate title
|
||||
const title = screen.queryByTestId('property-card-title');
|
||||
expect(title).toBeVisible();
|
||||
|
|
@ -94,10 +336,10 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
|
|||
expect(screen.queryAllByTestId('select-property')).toHaveLength(2);
|
||||
|
||||
const statusFieldValue = screen.queryAllByTestId('select-property')[0];
|
||||
expect(statusFieldValue).toHaveTextContent('Flag dismissed');
|
||||
expect(statusFieldValue).toHaveTextContent('Pending');
|
||||
|
||||
const reasonFieldValue = screen.queryAllByTestId('select-property')[1];
|
||||
expect(reasonFieldValue).toHaveTextContent('Inappropriate content');
|
||||
expect(reasonFieldValue).toHaveTextContent('Sensitive data');
|
||||
|
||||
const postPreview = screen.queryByTestId('post-preview-property');
|
||||
expect(postPreview).toBeVisible();
|
||||
|
|
@ -119,11 +361,17 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
|
|||
baseState,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
const flaggedBy = screen.queryAllByTestId('user-property')[0];
|
||||
expect(flaggedBy).toBeVisible();
|
||||
expect(flaggedBy).toHaveTextContent('reporting_user');
|
||||
|
||||
const comment = screen.queryByTestId('text-property');
|
||||
const postId = screen.queryAllByTestId('text-property')[0];
|
||||
expect(postId).toBeVisible();
|
||||
expect(postId).toHaveTextContent(reportedPost.id);
|
||||
|
||||
const comment = screen.queryAllByTestId('text-property')[1];
|
||||
expect(comment).toBeVisible();
|
||||
expect(comment).toHaveTextContent('Please review this post for potential violations');
|
||||
|
||||
|
|
@ -135,10 +383,6 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
|
|||
expect(team).toBeVisible();
|
||||
expect(team).toHaveTextContent('Reported Post Team');
|
||||
|
||||
const postedBy = screen.queryAllByTestId('user-property')[1];
|
||||
expect(postedBy).toBeVisible();
|
||||
expect(postedBy).toHaveTextContent('reported_post_author');
|
||||
|
||||
const reportedAt = screen.queryAllByTestId('timestamp-property')[0];
|
||||
expect(reportedAt).toBeVisible();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,361 +1,54 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {ContentFlaggingStatus} from '@mattermost/types/content_flagging';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {
|
||||
PropertyField,
|
||||
PropertyValue,
|
||||
} from '@mattermost/types/properties';
|
||||
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
|
||||
|
||||
import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
|
||||
import {getUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import AtMention from 'components/at_mention';
|
||||
import {useChannel} from 'components/common/hooks/useChannel';
|
||||
import {usePost} from 'components/common/hooks/usePost';
|
||||
import {useContentFlaggingFields, usePostContentFlaggingValues} from 'components/common/hooks/useContentFlaggingFields';
|
||||
import {useUser} from 'components/common/hooks/useUser';
|
||||
import DataSpillageAction from 'components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions';
|
||||
import type {PropertiesCardViewMetadata} from 'components/properties_card_view/properties_card_view';
|
||||
import PropertiesCardView from 'components/properties_card_view/properties_card_view';
|
||||
|
||||
import {DataSpillagePropertyNames} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import './data_spillage_report.scss';
|
||||
import DataSpillageFooter from './data_spillage_footer/data_spillage_footer';
|
||||
import {getSyntheticPropertyFields, getSyntheticPropertyValues} from './synthetic_data';
|
||||
|
||||
// TODO: this function will be replaced with actual data fetched from API in a later PR
|
||||
function getDummyPropertyFields(): PropertyField[] {
|
||||
return [
|
||||
{
|
||||
id: 'status_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.Status,
|
||||
type: 'select',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
attrs: {
|
||||
editable: false,
|
||||
options: [
|
||||
{
|
||||
id: 'option_pending_review',
|
||||
name: 'Pending review',
|
||||
color: 'light_gray',
|
||||
},
|
||||
{
|
||||
id: 'option_reviewer_assigned',
|
||||
name: 'Reviewer assigned',
|
||||
color: 'light_blue',
|
||||
},
|
||||
{
|
||||
id: 'option_dismissed',
|
||||
name: 'Flag dismissed',
|
||||
color: 'dark_blue',
|
||||
},
|
||||
{
|
||||
id: 'option_removed',
|
||||
name: 'Removed',
|
||||
color: 'dark_red',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reporting_user_id_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.FlaggedBy,
|
||||
type: 'user',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'reason_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.Reason,
|
||||
type: 'select',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'comment_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.Comment,
|
||||
type: 'text',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'reporting_time_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.ReportingTime,
|
||||
type: 'text',
|
||||
attrs: {subType: 'timestamp'},
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'reviewer_user_id_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.ReviewingUser,
|
||||
type: 'user',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
attrs: {
|
||||
editable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actor_user_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.ActionBy,
|
||||
type: 'user',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'actor_comment_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.ActionComment,
|
||||
type: 'text',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'action_time_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.ActionTime,
|
||||
type: 'text',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_preview_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.Message,
|
||||
type: 'text',
|
||||
attrs: {subType: 'post'},
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'channel_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.PostedIn,
|
||||
type: 'text',
|
||||
attrs: {subType: 'channel'},
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'team_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.Team,
|
||||
type: 'text',
|
||||
attrs: {subType: 'team'},
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_author_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.PostedBy,
|
||||
type: 'user',
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_creation_time_field_id',
|
||||
group_id: 'content_flagging_group_id',
|
||||
name: DataSpillagePropertyNames.PostedAt,
|
||||
type: 'text',
|
||||
attrs: {subType: 'timestamp'},
|
||||
target_type: 'post',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: this function will be replaced with actual data fetched from API in a later PR
|
||||
function getDummyPropertyValues(postId: string, channelId: string, teamId: string, authorId: string, postCreateAt: number): Array<PropertyValue<unknown>> {
|
||||
return [
|
||||
{
|
||||
id: 'status_value_id',
|
||||
field_id: 'status_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: 'Flag dismissed',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'reporting_user_value_id',
|
||||
field_id: 'reporting_user_id_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: 'ewgposajm3fwpjbqu1t6scncia',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'reason_value_id',
|
||||
field_id: 'reason_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: 'Inappropriate content',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'comment_value_id',
|
||||
field_id: 'comment_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: 'Please review this post for potential violations.',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'reporting_time_value_id',
|
||||
field_id: 'reporting_time_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: new Date(2025, 0, 1, 0, 1, 0, 0).getTime(),
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_preview_value_id',
|
||||
field_id: 'post_preview_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: postId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'channel_value_id',
|
||||
field_id: 'channel_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: channelId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'team_value_id',
|
||||
field_id: 'team_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: teamId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_author_value_id',
|
||||
field_id: 'post_author_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: authorId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_creation_time_value_id',
|
||||
field_id: 'post_creation_time_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: postCreateAt,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
|
||||
// No reviewer assigned yet
|
||||
{
|
||||
id: 'reviewer_user_value_id',
|
||||
field_id: 'reviewer_user_id_field_id',
|
||||
target_id: 'reported_post_id',
|
||||
target_type: 'post',
|
||||
group_id: 'content_flagging_group_id',
|
||||
value: '',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const fieldOrder = [
|
||||
'status_field_id',
|
||||
'reason_field_id',
|
||||
'post_preview_field_id',
|
||||
'reporting_user_id_field_id',
|
||||
'comment_field_id',
|
||||
'reporting_time_field_id',
|
||||
'reviewer_user_id_field_id',
|
||||
'actor_user_field_id',
|
||||
'actor_comment_field_id',
|
||||
'action_time_field_id',
|
||||
'channel_field_id',
|
||||
'team_field_id',
|
||||
'post_author_field_id',
|
||||
'post_creation_time_field_id',
|
||||
// The order of fields to be displayed in the report, from top to bottom.
|
||||
const orderedFieldName = [
|
||||
'status',
|
||||
'reporting_reason',
|
||||
'actor_user_id',
|
||||
'actor_comment',
|
||||
'action_time',
|
||||
'post_preview',
|
||||
'post_id',
|
||||
'reviewer',
|
||||
'reporting_user_id',
|
||||
'reporting_time',
|
||||
'reporting_comment',
|
||||
'channel',
|
||||
'team',
|
||||
'post_author',
|
||||
'post_creation_time',
|
||||
];
|
||||
|
||||
const shortModeFieldOrder = [
|
||||
'status_field_id',
|
||||
'reason_field_id',
|
||||
'post_preview_field_id',
|
||||
'reviewer_user_id_field_id',
|
||||
'status',
|
||||
'reporting_reason',
|
||||
'post_preview',
|
||||
'reviewer',
|
||||
];
|
||||
|
||||
type Props = {
|
||||
|
|
@ -363,37 +56,66 @@ type Props = {
|
|||
isRHS?: boolean;
|
||||
};
|
||||
|
||||
export default function DataSpillageReport({post, isRHS}: Props) {
|
||||
const dispatch = useDispatch();
|
||||
export function DataSpillageReport({post, isRHS}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const [propertyFields, setPropertyFields] = useState<PropertyField[]>([]);
|
||||
const [propertyValues, setPropertyValues] = useState<Array<PropertyValue<unknown>>>([]);
|
||||
const loaded = useRef(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const reportedPostId = post.props.reported_post_id as string;
|
||||
const reportedPost = usePost(reportedPostId);
|
||||
|
||||
const naturalPropertyFields = useContentFlaggingFields('fetch');
|
||||
const naturalPropertyValues = usePostContentFlaggingValues(reportedPostId);
|
||||
|
||||
const [reportedPost, setReportedPost] = useState<Post>();
|
||||
const channel = useChannel(reportedPost?.channel_id || '');
|
||||
|
||||
const reportingUserFieldId = propertyFields.find((field) => field.name === DataSpillagePropertyNames.FlaggedBy);
|
||||
useEffect(() => {
|
||||
const work = async () => {
|
||||
if (!loaded.current && !reportedPost) {
|
||||
// We need to obtain the post directly from action bypassing the selectors
|
||||
// because the post might be soft-deleted and the post reducers do not store deleted posts
|
||||
// in the store.
|
||||
const post = await loadFlaggedPost(reportedPostId);
|
||||
if (post) {
|
||||
setReportedPost(post);
|
||||
loaded.current = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
work();
|
||||
}, [dispatch, reportedPost, reportedPostId]);
|
||||
|
||||
const propertyFields = useMemo((): NameMappedPropertyFields => {
|
||||
if (!naturalPropertyFields || !Object.keys(naturalPropertyFields).length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const syntheticFields = getSyntheticPropertyFields(naturalPropertyFields.status.group_id);
|
||||
return {...naturalPropertyFields, ...syntheticFields};
|
||||
}, [naturalPropertyFields]);
|
||||
|
||||
const propertyValues = useMemo((): Array<PropertyValue<unknown>> => {
|
||||
if (!naturalPropertyValues || !naturalPropertyValues.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const syntheticValues = getSyntheticPropertyValues(
|
||||
naturalPropertyValues[0].group_id,
|
||||
reportedPostId,
|
||||
reportedPost?.channel_id || '',
|
||||
channel?.team_id || '',
|
||||
reportedPost?.user_id || '',
|
||||
reportedPost?.create_at || 0,
|
||||
);
|
||||
return [...naturalPropertyValues, ...syntheticValues];
|
||||
}, [channel?.team_id, naturalPropertyValues, reportedPost?.channel_id, reportedPost?.create_at, reportedPost?.user_id, reportedPostId]);
|
||||
|
||||
const reportingUserFieldId = propertyFields[DataSpillagePropertyNames.FlaggedBy];
|
||||
const reportingUserIdValue = propertyValues.find((value) => value.field_id === reportingUserFieldId?.id);
|
||||
const reportingUser = useSelector((state: GlobalState) => getUser(state, reportingUserIdValue ? reportingUserIdValue.value as string : ''));
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportingUser && reportingUserIdValue && reportedPost) {
|
||||
dispatch(getMissingProfilesByIds([
|
||||
reportingUserIdValue.value as string,
|
||||
reportedPost.user_id,
|
||||
]));
|
||||
}
|
||||
}, [dispatch, reportedPost, reportingUser, reportingUserIdValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportedPost && channel) {
|
||||
// TODO: this function will be replaced with actual data fetched from API in a later PR
|
||||
setPropertyFields(getDummyPropertyFields());
|
||||
setPropertyValues(getDummyPropertyValues(reportedPostId, reportedPost.channel_id, channel.team_id, reportedPost.user_id, post.create_at));
|
||||
}
|
||||
}, [reportedPost, reportedPostId, channel, post.create_at]);
|
||||
const reportingUserId = reportingUserIdValue ? reportingUserIdValue.value as string : '';
|
||||
const reportingUser = useUser(reportingUserId);
|
||||
|
||||
const title = formatMessage({
|
||||
id: 'data_spillage_report_post.title',
|
||||
|
|
@ -404,6 +126,46 @@ export default function DataSpillageReport({post, isRHS}: Props) {
|
|||
|
||||
const mode = isRHS ? 'full' : 'short';
|
||||
|
||||
const metadata = useMemo<PropertiesCardViewMetadata>(() => {
|
||||
return {
|
||||
post_preview: {
|
||||
getPost: loadFlaggedPost,
|
||||
fetchDeletedPost: true,
|
||||
},
|
||||
reporting_comment: {
|
||||
placeholder: formatMessage({id: 'data_spillage_report_post.reporting_comment.placeholder', defaultMessage: 'No comment'}),
|
||||
},
|
||||
};
|
||||
}, [formatMessage]);
|
||||
|
||||
const footer = useMemo(() => {
|
||||
if (isRHS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (<DataSpillageFooter post={post}/>);
|
||||
}, [isRHS, post]);
|
||||
|
||||
const actionRow = useMemo(() => {
|
||||
if (!reportedPost || !reportingUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let showActionRow;
|
||||
if (!propertyFields || !propertyValues) {
|
||||
showActionRow = true;
|
||||
} else {
|
||||
const status = propertyValues.find((value) => value.field_id === propertyFields.status.id)?.value as string | undefined;
|
||||
showActionRow = reportedPost && reportingUser && status && (status === ContentFlaggingStatus.Pending || status === ContentFlaggingStatus.Assigned);
|
||||
}
|
||||
|
||||
return showActionRow ? (
|
||||
<DataSpillageAction
|
||||
flaggedPost={reportedPost}
|
||||
reportingUser={reportingUser}
|
||||
/>) : null;
|
||||
}, [propertyFields, propertyValues, reportedPost, reportingUser]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`DataSpillageReport mode_${mode}`}
|
||||
|
|
@ -414,11 +176,17 @@ export default function DataSpillageReport({post, isRHS}: Props) {
|
|||
title={title}
|
||||
propertyFields={propertyFields}
|
||||
propertyValues={propertyValues}
|
||||
fieldOrder={fieldOrder}
|
||||
fieldOrder={orderedFieldName}
|
||||
shortModeFieldOrder={shortModeFieldOrder}
|
||||
actionsRow={<DataSpillageAction/>}
|
||||
actionsRow={actionRow}
|
||||
mode={mode}
|
||||
metadata={metadata}
|
||||
footer={footer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFlaggedPost(postId: string) {
|
||||
return Client4.getFlaggedPost(postId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
|
||||
|
||||
export function getSyntheticPropertyFields(groupId: string): NameMappedPropertyFields {
|
||||
return {
|
||||
post_id: {
|
||||
id: 'post_id_field_id',
|
||||
group_id: groupId,
|
||||
name: 'post_id',
|
||||
type: 'text',
|
||||
attrs: {},
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
post_preview: {
|
||||
id: 'post_preview_field_id',
|
||||
group_id: groupId,
|
||||
name: 'post_preview',
|
||||
type: 'text',
|
||||
attrs: {subType: 'post'},
|
||||
create_at: 0,
|
||||
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,
|
||||
name: 'channel',
|
||||
type: 'text',
|
||||
attrs: {subType: 'channel'},
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
team: {
|
||||
id: 'team_field_id',
|
||||
group_id: groupId,
|
||||
name: 'team',
|
||||
type: 'text',
|
||||
attrs: {subType: 'team'},
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
post_author: {
|
||||
id: 'post_author_field_id',
|
||||
group_id: groupId,
|
||||
name: 'post_author',
|
||||
type: 'user',
|
||||
attrs: {},
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
post_creation_time: {
|
||||
id: 'post_creation_time_field_id',
|
||||
group_id: groupId,
|
||||
name: 'post_creation_time',
|
||||
type: 'text',
|
||||
attrs: {subType: 'timestamp'},
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getSyntheticPropertyValues(groupId: string, reportedPostId: string, channelId: string, teamId: string, postAuthorId: string, postCreateAt: number): Array<PropertyValue<unknown>> {
|
||||
return [
|
||||
{
|
||||
id: 'post_preview_value_id',
|
||||
field_id: 'post_preview_field_id',
|
||||
target_id: reportedPostId,
|
||||
target_type: 'post',
|
||||
group_id: groupId,
|
||||
value: reportedPostId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_id_value_id',
|
||||
field_id: 'post_id_field_id',
|
||||
target_id: reportedPostId,
|
||||
target_type: 'post',
|
||||
group_id: groupId,
|
||||
value: reportedPostId,
|
||||
create_at: 0,
|
||||
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',
|
||||
target_id: reportedPostId,
|
||||
target_type: 'post',
|
||||
group_id: groupId,
|
||||
value: channelId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'team_value_id',
|
||||
field_id: 'team_field_id',
|
||||
target_id: reportedPostId,
|
||||
target_type: 'post',
|
||||
group_id: groupId,
|
||||
value: teamId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_author_value_id',
|
||||
field_id: 'post_author_field_id',
|
||||
target_id: reportedPostId,
|
||||
target_type: 'post',
|
||||
group_id: groupId,
|
||||
value: postAuthorId,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
id: 'post_creation_time_value_id',
|
||||
field_id: 'post_creation_time_field_id',
|
||||
target_id: reportedPostId,
|
||||
target_type: 'post',
|
||||
group_id: groupId,
|
||||
value: postCreateAt,
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ export type OwnProps = {
|
|||
metadata: PostPreviewMetadata;
|
||||
preventClickAction?: boolean;
|
||||
previewFooterMessage?: string;
|
||||
usePostAsSource?: boolean;
|
||||
}
|
||||
|
||||
function makeMapStateToProps() {
|
||||
|
|
@ -39,7 +40,7 @@ function makeMapStateToProps() {
|
|||
let user = null;
|
||||
let embedVisible = false;
|
||||
let channelDisplayName = ownProps.metadata.channel_display_name;
|
||||
const previewPost = getPost(state, ownProps.metadata.post_id);
|
||||
const previewPost = ownProps.metadata.post || getPost(state, ownProps.metadata.post_id);
|
||||
|
||||
if (previewPost && previewPost.user_id) {
|
||||
user = getUser(state, previewPost.user_id);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const PostMessagePreview = (props: Props) => {
|
|||
compactDisplay={compactDisplay}
|
||||
isInPermalink={true}
|
||||
handleFileDropdownOpened={handleFileDropdownOpened}
|
||||
usePostAsSource={props.usePostAsSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,17 @@ import ShowMore from 'components/post_view/show_more';
|
|||
import type {AttachmentTextOverflowType} from 'components/post_view/show_more/show_more';
|
||||
|
||||
import Pluggable from 'plugins/pluggable';
|
||||
import {PostTypes} from 'utils/constants';
|
||||
import type {TextFormattingOptions} from 'utils/text_formatting';
|
||||
import * as Utils from 'utils/utils';
|
||||
|
||||
import type {PostPluginComponent} from 'types/store/plugins';
|
||||
|
||||
// These posts types must not be rendered with the collapsible "Show More" container.
|
||||
const FULL_HEIGHT_POST_TYPES = new Set([
|
||||
PostTypes.CUSTOM_DATA_SPILLAGE_REPORT,
|
||||
]);
|
||||
|
||||
type Props = {
|
||||
post: Post; /* The post to render the message for */
|
||||
enableFormatting?: boolean; /* Set to enable Markdown formatting */
|
||||
|
|
@ -150,13 +156,8 @@ export default class PostMessageView extends React.PureComponent<Props, State> {
|
|||
const channel = getChannel(store.getState(), post.channel_id);
|
||||
const isSharedChannel = channel?.shared || false;
|
||||
|
||||
return (
|
||||
<ShowMore
|
||||
checkOverflow={this.state.checkOverflow}
|
||||
text={message}
|
||||
overflowType={overflowType}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
const body = (
|
||||
<>
|
||||
<div
|
||||
id={id}
|
||||
className='post-message__text'
|
||||
|
|
@ -180,6 +181,21 @@ export default class PostMessageView extends React.PureComponent<Props, State> {
|
|||
onHeightChange={this.handleHeightReceived}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (FULL_HEIGHT_POST_TYPES.has(postType)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShowMore
|
||||
checkOverflow={this.state.checkOverflow}
|
||||
text={message}
|
||||
overflowType={overflowType}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
{body}
|
||||
</ShowMore>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,56 +2,144 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {defineMessages, FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {
|
||||
NameMappedPropertyFields,
|
||||
PropertyField,
|
||||
PropertyValue,
|
||||
} from '@mattermost/types/properties';
|
||||
|
||||
import PropertyValueRenderer from './propertyValueRenderer/propertyValueRenderer';
|
||||
|
||||
import './properties_card_view.scss';
|
||||
|
||||
export type PostPreviewFieldMetadata = {
|
||||
getPost?: (postId: string) => Promise<Post>;
|
||||
fetchDeletedPost?: boolean;
|
||||
};
|
||||
|
||||
export type TextFieldMetadata = {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type FieldMetadata = PostPreviewFieldMetadata | TextFieldMetadata;
|
||||
|
||||
export type PropertiesCardViewMetadata = {
|
||||
[key: string]: FieldMetadata;
|
||||
}
|
||||
|
||||
type OrderedRow = {
|
||||
field: PropertyField;
|
||||
value: PropertyValue<unknown>;
|
||||
};
|
||||
|
||||
const fieldNameMessages = defineMessages({
|
||||
status: {
|
||||
id: 'property_card.field.status.label',
|
||||
defaultMessage: 'Status',
|
||||
},
|
||||
reporting_reason: {
|
||||
id: 'property_card.field.reporting_reason.label',
|
||||
defaultMessage: 'Reason',
|
||||
},
|
||||
post_preview: {
|
||||
id: 'property_card.field.post_preview.label',
|
||||
defaultMessage: 'Message',
|
||||
},
|
||||
post_id: {
|
||||
id: 'property_card.field.post_id.label',
|
||||
defaultMessage: 'Post ID',
|
||||
},
|
||||
reviewer: {
|
||||
id: 'property_card.field.reviewer_user_id.label',
|
||||
defaultMessage: 'Reviewer',
|
||||
},
|
||||
reporting_user_id: {
|
||||
id: 'property_card.field.reporting_user_id.label',
|
||||
defaultMessage: 'Flagged by',
|
||||
},
|
||||
reporting_comment: {
|
||||
id: 'property_card.field.reporting_comment.label',
|
||||
defaultMessage: 'Comment',
|
||||
},
|
||||
channel: {
|
||||
id: 'property_card.field.channel.label',
|
||||
defaultMessage: 'Channel',
|
||||
},
|
||||
team: {
|
||||
id: 'property_card.field.team.label',
|
||||
defaultMessage: 'Team',
|
||||
},
|
||||
post_author: {
|
||||
id: 'property_card.field.post_author.label',
|
||||
defaultMessage: 'Posted by',
|
||||
},
|
||||
post_creation_time: {
|
||||
id: 'property_card.field.post_creation_time.label',
|
||||
defaultMessage: 'Posted at',
|
||||
},
|
||||
reporting_time: {
|
||||
id: 'property_card.field.reporting_time.label',
|
||||
defaultMessage: 'Flagged at',
|
||||
},
|
||||
actor_user_id: {
|
||||
id: 'property_card.field.actor_user_id.label',
|
||||
defaultMessage: 'Reviewed by',
|
||||
},
|
||||
action_time: {
|
||||
id: 'property_card.field.action_time.label',
|
||||
defaultMessage: 'Reviewed at',
|
||||
},
|
||||
actor_comment: {
|
||||
id: 'property_card.field.actor_comment.label',
|
||||
defaultMessage: 'Reviewer\'s comment',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
title: React.ReactNode;
|
||||
propertyFields: PropertyField[];
|
||||
propertyFields: NameMappedPropertyFields;
|
||||
fieldOrder: Array<PropertyField['id']>;
|
||||
shortModeFieldOrder: Array<PropertyField['id']>;
|
||||
propertyValues: Array<PropertyValue<unknown>>;
|
||||
mode?: 'short' | 'full';
|
||||
actionsRow?: React.ReactNode;
|
||||
metadata?: PropertiesCardViewMetadata;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionsRow}: Props) {
|
||||
const orderedRows = useMemo<Array<{field: PropertyField; value: PropertyValue<unknown>}>>(() => {
|
||||
if (!propertyFields.length || !fieldOrder.length || !propertyValues.length) {
|
||||
export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionsRow, metadata, footer}: Props) {
|
||||
const orderedRows = useMemo<OrderedRow[]>(() => {
|
||||
const hasRequiredData =
|
||||
Object.keys(propertyFields).length > 0 &&
|
||||
fieldOrder.length > 0 &&
|
||||
propertyValues.length > 0;
|
||||
|
||||
if (!hasRequiredData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fieldsById = propertyFields.reduce((acc, field) => {
|
||||
acc[field.id] = field;
|
||||
return acc;
|
||||
}, {} as {[key: string]: PropertyField});
|
||||
// Create lookup map for efficient value retrieval
|
||||
const valuesByFieldId = new Map(
|
||||
propertyValues.map((value) => [value.field_id, value]),
|
||||
);
|
||||
|
||||
const valuesByFieldId = propertyValues.reduce((acc, value) => {
|
||||
acc[value.field_id] = value;
|
||||
return acc;
|
||||
}, {} as {[key: string]: PropertyValue<unknown>});
|
||||
// Determine which field order to use
|
||||
const currentFieldOrder = mode === 'short' ? shortModeFieldOrder : fieldOrder;
|
||||
|
||||
const fieldOrderToUse = mode === 'short' ? shortModeFieldOrder : fieldOrder;
|
||||
return fieldOrderToUse.map((fieldId) => {
|
||||
const field = fieldsById[fieldId];
|
||||
const value = valuesByFieldId[fieldId];
|
||||
// Build ordered rows, filtering out incomplete entries
|
||||
return currentFieldOrder.
|
||||
map((fieldName) => {
|
||||
const field = propertyFields[fieldName];
|
||||
const value = field ? valuesByFieldId.get(field.id) : undefined;
|
||||
|
||||
return {
|
||||
field,
|
||||
value,
|
||||
};
|
||||
}).filter((entry) => Boolean(entry.value));
|
||||
return field && value ? {field, value} : null;
|
||||
}).
|
||||
filter((row): row is OrderedRow => row !== null);
|
||||
}, [fieldOrder, mode, propertyFields, propertyValues, shortModeFieldOrder]);
|
||||
|
||||
if (orderedRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='PropertyCardView'
|
||||
|
|
@ -67,6 +155,8 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
|
|||
<div className='PropertyCardView_fields'>
|
||||
{
|
||||
orderedRows.map(({field, value}) => {
|
||||
const translation = fieldNameMessages[field.name as keyof typeof fieldNameMessages];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
|
|
@ -74,13 +164,14 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
|
|||
data-testid='property-card-row'
|
||||
>
|
||||
<div className='field'>
|
||||
{field.name}
|
||||
{translation ? <FormattedMessage {...translation}/> : field.name}
|
||||
</div>
|
||||
|
||||
<div className='value'>
|
||||
<PropertyValueRenderer
|
||||
field={field}
|
||||
value={value}
|
||||
metadata={metadata ? metadata[field.name] : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -103,6 +194,8 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
|
|||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act, waitFor} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
|
@ -8,19 +9,26 @@ import type {Post} from '@mattermost/types/posts';
|
|||
import type {PropertyValue} from '@mattermost/types/properties';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import PostPreviewPropertyRenderer from './post_preview_property_renderer';
|
||||
|
||||
jest.mock('components/common/hooks/usePost');
|
||||
jest.mock('components/common/hooks/useChannel');
|
||||
jest.mock('components/common/hooks/use_team');
|
||||
jest.mock('components/common/hooks/usePost');
|
||||
jest.mock('mattermost-redux/client');
|
||||
|
||||
const mockUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
|
||||
const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction<any>;
|
||||
const mockUseTeam = require('components/common/hooks/use_team').useTeam as jest.MockedFunction<any>;
|
||||
const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
|
||||
const mockedClient4 = jest.mocked(Client4);
|
||||
|
||||
describe('PostPreviewPropertyRenderer', () => {
|
||||
const mockUser: UserProfile = {
|
||||
|
|
@ -59,9 +67,13 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
value: {
|
||||
value: 'post-id-123',
|
||||
} as PropertyValue<string>,
|
||||
metadata: {
|
||||
fetchDeletedPost: true,
|
||||
getPost: (postId: string) => Client4.getFlaggedPost(postId),
|
||||
},
|
||||
};
|
||||
|
||||
const baseState = {
|
||||
const baseState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
users: {
|
||||
profiles: {
|
||||
|
|
@ -69,11 +81,6 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
},
|
||||
currentUserId: mockUser.id,
|
||||
},
|
||||
posts: {
|
||||
posts: {
|
||||
[mockPost.id]: mockPost,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
channels: {
|
||||
[mockChannel.id]: mockChannel,
|
||||
|
|
@ -90,15 +97,16 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
posts: {posts: {}},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedUsePost.mockReturnValue(null);
|
||||
mockedClient4.getFlaggedPost.mockResolvedValue(mockPost);
|
||||
});
|
||||
|
||||
it('should render PostMessagePreview when all data is available', () => {
|
||||
mockUsePost.mockReturnValue(mockPost);
|
||||
it('should render PostMessagePreview when all data is available', async () => {
|
||||
mockUseChannel.mockReturnValue(mockChannel);
|
||||
mockUseTeam.mockReturnValue(mockTeam);
|
||||
|
||||
|
|
@ -107,26 +115,29 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
baseState,
|
||||
);
|
||||
|
||||
expect(getByTestId('post-preview-property')).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('post-preview-property')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(getByText('Test post message')).toBeVisible();
|
||||
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should return null when post is not found', () => {
|
||||
mockUsePost.mockReturnValue(null);
|
||||
it('should return null when post is not found', async () => {
|
||||
mockUseChannel.mockReturnValue(mockChannel);
|
||||
mockUseTeam.mockReturnValue(mockTeam);
|
||||
mockedClient4.getFlaggedPost.mockRejectedValue({message: 'Post not found'});
|
||||
|
||||
const {container} = renderWithContext(
|
||||
<PostPreviewPropertyRenderer {...defaultProps}/>,
|
||||
baseState,
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when channel is not found', () => {
|
||||
mockUsePost.mockReturnValue(mockPost);
|
||||
it('should return null when channel is not found', async () => {
|
||||
mockUseChannel.mockReturnValue(null);
|
||||
mockUseTeam.mockReturnValue(mockTeam);
|
||||
|
||||
|
|
@ -135,11 +146,11 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
baseState,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when team is not found', () => {
|
||||
mockUsePost.mockReturnValue(mockPost);
|
||||
it('should return null when team is not found', async () => {
|
||||
mockUseChannel.mockReturnValue(mockChannel);
|
||||
mockUseTeam.mockReturnValue(null);
|
||||
|
||||
|
|
@ -148,16 +159,16 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
baseState,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle private channel', () => {
|
||||
it('should handle private channel', async () => {
|
||||
const privateChannel = {
|
||||
...mockChannel,
|
||||
type: 'P' as const,
|
||||
};
|
||||
|
||||
mockUsePost.mockReturnValue(mockPost);
|
||||
mockUseChannel.mockReturnValue(privateChannel);
|
||||
mockUseTeam.mockReturnValue(mockTeam);
|
||||
|
||||
|
|
@ -166,12 +177,13 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
baseState,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(getByTestId('post-preview-property')).toBeVisible();
|
||||
expect(getByText('Test post message')).toBeVisible();
|
||||
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should handle missing display names gracefully', () => {
|
||||
it('should handle missing display names gracefully', async () => {
|
||||
const channelWithoutDisplayName = {
|
||||
...mockChannel,
|
||||
display_name: '',
|
||||
|
|
@ -182,7 +194,6 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
name: '',
|
||||
};
|
||||
|
||||
mockUsePost.mockReturnValue(mockPost);
|
||||
mockUseChannel.mockReturnValue(channelWithoutDisplayName);
|
||||
mockUseTeam.mockReturnValue(teamWithoutName);
|
||||
|
||||
|
|
@ -191,12 +202,13 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
baseState,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(getByTestId('post-preview-property')).toBeVisible();
|
||||
expect(getByText('Test post message')).toBeVisible();
|
||||
expect(getByText('Originally posted in ~')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should handle post with file attachments', () => {
|
||||
it('should handle post with file attachments', async () => {
|
||||
const postWithAttachments = {
|
||||
...mockPost,
|
||||
message: 'Post with file attachment',
|
||||
|
|
@ -212,63 +224,31 @@ describe('PostPreviewPropertyRenderer', () => {
|
|||
},
|
||||
{
|
||||
id: 'file-id-2',
|
||||
name: 'image.jpg',
|
||||
extension: 'jpg',
|
||||
name: 'file.txt',
|
||||
extension: 'txt',
|
||||
size: 512000,
|
||||
mime_type: 'image/jpeg',
|
||||
mime_type: 'text/plain;charset=UTF-8',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} as Post;
|
||||
|
||||
const stateWithFiles = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
posts: {
|
||||
posts: {
|
||||
[postWithAttachments.id]: postWithAttachments,
|
||||
},
|
||||
},
|
||||
files: {
|
||||
fileIdsByPostId: {
|
||||
[postWithAttachments.id]: ['file-id-1', 'file-id-2'],
|
||||
},
|
||||
files: {
|
||||
'file-id-1': {
|
||||
id: 'file-id-1',
|
||||
name: 'document.pdf',
|
||||
extension: 'pdf',
|
||||
size: 1024000,
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
'file-id-2': {
|
||||
id: 'file-id-2',
|
||||
name: 'image.jpg',
|
||||
extension: 'jpg',
|
||||
size: 512000,
|
||||
mime_type: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockUsePost.mockReturnValue(postWithAttachments);
|
||||
mockedClient4.getFlaggedPost.mockResolvedValue(postWithAttachments);
|
||||
mockUseChannel.mockReturnValue(mockChannel);
|
||||
mockUseTeam.mockReturnValue(mockTeam);
|
||||
|
||||
const {getByTestId, getByText} = renderWithContext(
|
||||
<PostPreviewPropertyRenderer {...defaultProps}/>,
|
||||
stateWithFiles,
|
||||
baseState,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(getByTestId('post-preview-property')).toBeVisible();
|
||||
expect(getByText('Post with file attachment')).toBeVisible();
|
||||
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
|
||||
|
||||
// Assert that file attachments are visible
|
||||
expect(getByText('document.pdf')).toBeVisible();
|
||||
expect(getByText('image.jpg')).toBeVisible();
|
||||
expect(getByText('file.txt')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,28 +1,67 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import type {PostPreviewMetadata} from '@mattermost/types/posts';
|
||||
import type {Post, PostPreviewMetadata} from '@mattermost/types/posts';
|
||||
import type {PropertyValue} from '@mattermost/types/properties';
|
||||
|
||||
import {useTeam} from 'components/common/hooks/use_team';
|
||||
import {useChannel} from 'components/common/hooks/useChannel';
|
||||
import {usePost} from 'components/common/hooks/usePost';
|
||||
import PostMessagePreview from 'components/post_view/post_message_preview';
|
||||
import type {PostPreviewFieldMetadata} from 'components/properties_card_view/properties_card_view';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type Props = {
|
||||
value: PropertyValue<unknown>;
|
||||
metadata?: PostPreviewFieldMetadata;
|
||||
}
|
||||
|
||||
export default function PostPreviewPropertyRenderer({value}: Props) {
|
||||
const post = usePost(value.value as string);
|
||||
export default function PostPreviewPropertyRenderer({value, metadata}: Props) {
|
||||
const postId = value.value as string;
|
||||
|
||||
const [post, setPost] = useState<Post>();
|
||||
const channel = useChannel(post?.channel_id || '');
|
||||
const team = useTeam(channel?.team_id || '');
|
||||
|
||||
const loaded = useRef(false);
|
||||
|
||||
const postFromStore = usePost(postId);
|
||||
|
||||
useEffect(() => {
|
||||
const usePostFromStore = Boolean(!metadata?.getPost && postFromStore);
|
||||
const allowDeletedPost = postFromStore?.delete_at !== 0 && !metadata?.fetchDeletedPost;
|
||||
if (usePostFromStore && allowDeletedPost) {
|
||||
setPost(postFromStore);
|
||||
loaded.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPost = async () => {
|
||||
const canLoadPost = metadata?.getPost && !loaded.current && !post;
|
||||
if (!canLoadPost) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchedPost = await metadata.getPost!(postId);
|
||||
if (fetchedPost) {
|
||||
setPost(fetchedPost);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error occurred while fetching post for post preview property renderer', error);
|
||||
} finally {
|
||||
loaded.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
loadPost();
|
||||
}, [metadata, post, postFromStore, postId]);
|
||||
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
if (!post || !channel || !team) {
|
||||
|
|
@ -56,6 +95,7 @@ export default function PostPreviewPropertyRenderer({value}: Props) {
|
|||
handleFileDropdownOpened={noop}
|
||||
preventClickAction={true}
|
||||
previewFooterMessage={postPreviewFooterMessage}
|
||||
usePostAsSource={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import type {
|
|||
PropertyValue,
|
||||
} from '@mattermost/types/properties';
|
||||
|
||||
import type {
|
||||
FieldMetadata,
|
||||
PostPreviewFieldMetadata,
|
||||
TextFieldMetadata,
|
||||
} from 'components/properties_card_view/properties_card_view';
|
||||
|
||||
import ChannelPropertyRenderer from './channel_property_renderer/channel_property_renderer';
|
||||
import PostPreviewPropertyRenderer from './post_preview_property_renderer/post_preview_property_renderer';
|
||||
import SelectPropertyRenderer from './select_property_renderer/selectPropertyRenderer';
|
||||
|
|
@ -21,15 +27,17 @@ import './property_value_renderer.scss';
|
|||
type Props = {
|
||||
field: PropertyField;
|
||||
value: PropertyValue<unknown>;
|
||||
metadata?: FieldMetadata;
|
||||
};
|
||||
|
||||
export default function PropertyValueRenderer({field, value}: Props) {
|
||||
export default function PropertyValueRenderer({field, value, metadata}: Props) {
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<RenderTextSubtype
|
||||
field={field}
|
||||
value={value}
|
||||
metadata={metadata}
|
||||
/>
|
||||
);
|
||||
case 'user':
|
||||
|
|
@ -51,7 +59,7 @@ export default function PropertyValueRenderer({field, value}: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
function RenderTextSubtype({field, value}: Props) {
|
||||
function RenderTextSubtype({field, value, metadata}: Props) {
|
||||
if (field.type !== 'text') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -59,9 +67,19 @@ function RenderTextSubtype({field, value}: Props) {
|
|||
const subType = field.attrs?.subType ?? 'text';
|
||||
switch (subType) {
|
||||
case 'text':
|
||||
return <TextPropertyRenderer value={value}/>;
|
||||
return (
|
||||
<TextPropertyRenderer
|
||||
value={value}
|
||||
metadata={metadata as TextFieldMetadata}
|
||||
/>
|
||||
);
|
||||
case 'post':
|
||||
return <PostPreviewPropertyRenderer value={value}/>;
|
||||
return (
|
||||
<PostPreviewPropertyRenderer
|
||||
value={value}
|
||||
metadata={metadata as PostPreviewFieldMetadata}
|
||||
/>
|
||||
);
|
||||
case 'channel':
|
||||
return <ChannelPropertyRenderer value={value}/>;
|
||||
case 'team':
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function getOptionColors(colorName: string): {backgroundColor: string; color: st
|
|||
switch (colorName) {
|
||||
case 'light_blue':
|
||||
return {
|
||||
backgroundColor: 'rgba(var(--button-bg-rgb), 0.08)',
|
||||
backgroundColor: 'var(--sidebar-text-active-border)',
|
||||
color: '#FFF',
|
||||
};
|
||||
case 'dark_blue':
|
||||
|
|
@ -49,8 +49,12 @@ function getOptionColors(colorName: string): {backgroundColor: string; color: st
|
|||
backgroundColor: 'var(--error-text)',
|
||||
color: '#FFF',
|
||||
};
|
||||
case 'light_grey':
|
||||
return {
|
||||
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
|
||||
color: 'rgba(var(--center-channel-color-rgb), 1)',
|
||||
};
|
||||
default:
|
||||
// Default is light grey color
|
||||
return {
|
||||
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
|
||||
color: 'rgba(var(--center-channel-color-rgb), 1)',
|
||||
|
|
|
|||
|
|
@ -5,17 +5,28 @@ import React from 'react';
|
|||
|
||||
import type {PropertyValue} from '@mattermost/types/properties';
|
||||
|
||||
import type {TextFieldMetadata} from 'components/properties_card_view/properties_card_view';
|
||||
|
||||
type Props = {
|
||||
value: PropertyValue<unknown>;
|
||||
metadata?: TextFieldMetadata;
|
||||
}
|
||||
|
||||
export default function TextPropertyRenderer({value}: Props) {
|
||||
export default function TextPropertyRenderer({value, metadata}: Props) {
|
||||
return (
|
||||
<span
|
||||
className='TextProperty'
|
||||
data-testid='text-property'
|
||||
>
|
||||
{value.value}
|
||||
{Boolean(value.value) && value.value}
|
||||
|
||||
{
|
||||
!value.value && metadata?.placeholder && (
|
||||
<span className='TextProperty__placeholder'>
|
||||
{metadata.placeholder}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
#KeepRemoveFlaggedMessageConfirmationModal {
|
||||
.modal-content {
|
||||
width: 704px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.section{
|
||||
&.comment_section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section_title {
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
button#PreviewInputTextButton {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
textarea#RemoveFlaggedMessageConfirmationModal__comment {
|
||||
min-height: 90px !important;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.request_error {
|
||||
display: flex;
|
||||
width: 90%;
|
||||
align-items: center;
|
||||
color: var(--error-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import AtMention from 'components/at_mention';
|
||||
import {useChannel} from 'components/common/hooks/useChannel';
|
||||
import {useContentFlaggingConfig} from 'components/common/hooks/useContentFlaggingFields';
|
||||
import {useUser} from 'components/common/hooks/useUser';
|
||||
import type {TextboxElement} from 'components/textbox';
|
||||
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
|
||||
|
||||
import './remove_flagged_message_confirmation_modal.scss';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type Props = {
|
||||
action: 'keep' | 'remove';
|
||||
onExited: () => void;
|
||||
flaggedPost: Post;
|
||||
reportingUser: UserProfile;
|
||||
}
|
||||
|
||||
export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExited, flaggedPost, reportingUser}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const flaggedPostAuthor = useUser(flaggedPost.user_id);
|
||||
const flaggedPostChannel = useChannel(flaggedPost.channel_id);
|
||||
const contentFlaggingConfig = useContentFlaggingConfig(flaggedPostChannel?.team_id || '');
|
||||
|
||||
const [comment, setComment] = React.useState<string>('');
|
||||
const [commentError, setCommentError] = React.useState<string>('');
|
||||
const [requestError, setRequestError] = React.useState<string>('');
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false);
|
||||
const [showCommentPreview, setShowCommentPreview] = React.useState<boolean>(false);
|
||||
|
||||
const handleCommentChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
|
||||
setComment(e.target.value);
|
||||
|
||||
if (contentFlaggingConfig?.reviewer_comment_required && e.target.value.trim() === '') {
|
||||
setCommentError(formatMessage({id: 'keep_remove_flag_content_modal.comment_required.error', defaultMessage: 'Please add a comment.'}));
|
||||
} else {
|
||||
setCommentError('');
|
||||
}
|
||||
}, [contentFlaggingConfig?.reviewer_comment_required, formatMessage]);
|
||||
|
||||
const handleToggleCommentPreview = useCallback(() => {
|
||||
setShowCommentPreview((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const removeActionLabel = formatMessage({id: 'keep_remove_flag_content_modal.action_remove.title', defaultMessage: 'Remove message from channel'});
|
||||
const keepActionLabel = formatMessage({id: 'keep_remove_flag_content_modal.action_keep.title', defaultMessage: 'Keep message'});
|
||||
|
||||
const removeActionBody = formatMessage({
|
||||
id: 'keep_remove_flag_content_modal.action_remove.body',
|
||||
defaultMessage: 'You are about to remove a message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.',
|
||||
}, {
|
||||
br: <br/>,
|
||||
flaggedPostChannel: flaggedPostChannel?.display_name,
|
||||
reportingUser: <AtMention mentionName={reportingUser?.username || ''}/>,
|
||||
flaggedPostAuthor: <AtMention mentionName={flaggedPostAuthor?.username || ''}/>,
|
||||
});
|
||||
const keepActionBody = formatMessage({
|
||||
id: 'keep_remove_flag_content_modal.action_keep.body',
|
||||
defaultMessage: 'You are about to keep a flagged message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.',
|
||||
}, {
|
||||
br: <br/>,
|
||||
flaggedPostChannel: flaggedPostChannel?.display_name,
|
||||
reportingUser: <AtMention mentionName={reportingUser?.username || ''}/>,
|
||||
flaggedPostAuthor: <AtMention mentionName={flaggedPostAuthor?.username || ''}/>,
|
||||
});
|
||||
|
||||
const removeActionBodySubTextReporterNotification = formatMessage({
|
||||
id: 'keep_remove_flag_content_modal.action_remove.subtext.notify_reporter',
|
||||
defaultMessage: 'If you confirm, the message will be removed from the channel and a notification will be sent to the reporter of the flag. This action cannot be reverted.',
|
||||
});
|
||||
const removeActionBodySubTextNoReporterNotification = formatMessage({
|
||||
id: 'keep_remove_flag_content_modal.action_remove.subtext.no_notify_reporter',
|
||||
defaultMessage: 'If you confirm, the message will be removed from the channel. This action cannot be reverted.',
|
||||
});
|
||||
|
||||
const keepActionBodySubTextReporterNotification = formatMessage({
|
||||
id: 'keep_remove_flag_content_modal.action_keep.subtext.notify_reporter',
|
||||
defaultMessage: 'If you confirm, the message will be visible to all channel members and a notification will be sent to the reporter of the flag.',
|
||||
});
|
||||
const keepActionBodySubTextNoReporterNotification = formatMessage({
|
||||
id: 'keep_remove_flag_content_modal.action_keep.subtext.no_notify_reporter',
|
||||
defaultMessage: 'If you confirm, the message will be visible to all channel members.',
|
||||
});
|
||||
|
||||
const requiredCommentSectionTitle = formatMessage({id: 'remove_flag_post_confirm_modal.required_comment.title', defaultMessage: 'Comment (required)'});
|
||||
const optionalCommentSectionTitle = formatMessage({id: 'remove_flag_post_confirm_modal.optional_comment.title', defaultMessage: 'Comment (optional)'});
|
||||
|
||||
const commentPlaceholder = formatMessage({id: 'keep_remove_flag_content_modal.comment.placeholder', defaultMessage: 'Add your comment here'});
|
||||
const removeMessageButtonText = formatMessage({id: 'keep_remove_flag_content_modal.action_remove.button_text', defaultMessage: 'Remove message'});
|
||||
const keepMessageButtonText = formatMessage({id: 'keep_remove_flag_content_modal.action_keep.button_text', defaultMessage: 'Keep message'});
|
||||
|
||||
let label;
|
||||
let subtext;
|
||||
let body;
|
||||
let buttonText;
|
||||
let confirmButtonClass;
|
||||
|
||||
if (action === 'remove') {
|
||||
label = removeActionLabel;
|
||||
body = removeActionBody;
|
||||
buttonText = removeMessageButtonText;
|
||||
confirmButtonClass = 'btn-danger';
|
||||
|
||||
if (contentFlaggingConfig?.notify_reporter_on_removal) {
|
||||
subtext = removeActionBodySubTextReporterNotification;
|
||||
} else {
|
||||
subtext = removeActionBodySubTextNoReporterNotification;
|
||||
}
|
||||
} else {
|
||||
label = keepActionLabel;
|
||||
body = keepActionBody;
|
||||
buttonText = keepMessageButtonText;
|
||||
confirmButtonClass = 'btn-primary';
|
||||
|
||||
if (contentFlaggingConfig?.notify_reporter_on_dismissal) {
|
||||
subtext = keepActionBodySubTextReporterNotification;
|
||||
} else {
|
||||
subtext = keepActionBodySubTextNoReporterNotification;
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = useCallback(() => {
|
||||
let hasErrors = false;
|
||||
|
||||
if (contentFlaggingConfig?.reviewer_comment_required && comment.trim() === '') {
|
||||
setCommentError(formatMessage({id: 'keep_remove_flag_content_modal.comment_required.error', defaultMessage: 'Please add a comment.'}));
|
||||
hasErrors = true;
|
||||
} else {
|
||||
setCommentError('');
|
||||
}
|
||||
|
||||
return hasErrors;
|
||||
}, [comment, contentFlaggingConfig?.reviewer_comment_required, formatMessage]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const hasError = validateForm();
|
||||
if (hasError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionFunc = action === 'remove' ? Client4.removeFlaggedPost : Client4.keepFlaggedPost;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await actionFunc(flaggedPost.id, comment);
|
||||
onExited();
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
setRequestError((error as ServerError).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [action, comment, flaggedPost.id, onExited, validateForm]);
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
id='KeepRemoveFlaggedMessageConfirmationModal'
|
||||
dataTestId='keep-remove-flagged-message-confirmation-modal'
|
||||
ariaLabel={label}
|
||||
modalHeaderText={label}
|
||||
compassDesign={true}
|
||||
keyboardEscape={true}
|
||||
enforceFocus={false}
|
||||
handleConfirm={handleConfirm}
|
||||
handleCancel={noop}
|
||||
onExited={onExited}
|
||||
confirmButtonText={buttonText}
|
||||
confirmButtonClassName={confirmButtonClass}
|
||||
autoCloseOnConfirmButton={false}
|
||||
isConfirmDisabled={submitting}
|
||||
>
|
||||
<div className='body'>
|
||||
<div className='section'>
|
||||
{body}
|
||||
<br/>
|
||||
<br/>
|
||||
{subtext}
|
||||
</div>
|
||||
|
||||
<div className='section comment_section'>
|
||||
<div
|
||||
className='section_title'
|
||||
>
|
||||
{contentFlaggingConfig?.reviewer_comment_required ? requiredCommentSectionTitle : optionalCommentSectionTitle}
|
||||
</div>
|
||||
|
||||
<AdvancedTextbox
|
||||
id='RemoveFlaggedMessageConfirmationModal__comment'
|
||||
channelId={flaggedPost.channel_id}
|
||||
value={comment}
|
||||
onChange={handleCommentChange}
|
||||
createMessage={commentPlaceholder}
|
||||
preview={showCommentPreview}
|
||||
togglePreview={handleToggleCommentPreview}
|
||||
useChannelMentions={false}
|
||||
onKeyPress={() => {}}
|
||||
hasError={false}
|
||||
errorMessage={commentError}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
{requestError &&
|
||||
<div className='request_error'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
<span>{requestError}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@ import Textbox, {type Props} from 'components/textbox/textbox';
|
|||
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
jest.mock('components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal', () => {
|
||||
return jest.fn(() => <div data-testid='keep-remove-flagged-message-confirmation-modal'>{'KeepRemoveFlaggedMessageConfirmationModal Mock'}</div>);
|
||||
});
|
||||
|
||||
describe('components/TextBox', () => {
|
||||
const baseProps: Props = {
|
||||
channelId: 'channelId',
|
||||
|
|
|
|||
|
|
@ -4009,9 +4009,11 @@
|
|||
"custom_status.suggestions.recent_title": "RECENT",
|
||||
"custom_status.suggestions.title": "SUGGESTIONS",
|
||||
"custom_status.suggestions.working_from_home": "Working from home",
|
||||
"data_spillage_report_post.reporting_comment.placeholder": "No comment.",
|
||||
"data_spillage_report_post.title": "{user} flagged a message for review",
|
||||
"data_spillage_report.keep_message.button_text": "Keep message",
|
||||
"data_spillage_report.remove_message.button_text": "Remove message",
|
||||
"data_spillage_report.view_details.button_text": "View details",
|
||||
"date_separator.today": "Today",
|
||||
"date_separator.tomorrow": "Tomorrow",
|
||||
"date_separator.yesterday": "Yesterday",
|
||||
|
|
@ -4655,6 +4657,18 @@
|
|||
"joinChannel.JoinButton": "Join",
|
||||
"joinChannel.joiningButton": "Joining...",
|
||||
"katex.error": "Couldn't compile your Latex code. Please review the syntax and try again.",
|
||||
"keep_remove_flag_content_modal.action_keep.body": "You are about to keep a flagged message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.",
|
||||
"keep_remove_flag_content_modal.action_keep.button_text": "Keep message",
|
||||
"keep_remove_flag_content_modal.action_keep.subtext.no_notify_reporter": "If you confirm, the message will be visible to all channel members.",
|
||||
"keep_remove_flag_content_modal.action_keep.subtext.notify_reporter": "If you confirm, the message will be visible to all channel members and a notification will be sent to the reporter of the flag.",
|
||||
"keep_remove_flag_content_modal.action_keep.title": "Keep message",
|
||||
"keep_remove_flag_content_modal.action_remove.body": "You are about to remove a message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.",
|
||||
"keep_remove_flag_content_modal.action_remove.button_text": "Remove message",
|
||||
"keep_remove_flag_content_modal.action_remove.subtext.no_notify_reporter": "If you confirm, the message will be removed from the channel. This action cannot be reverted.",
|
||||
"keep_remove_flag_content_modal.action_remove.subtext.notify_reporter": "If you confirm, the message will be removed from the channel and a notification will be sent to the reporter of the flag. This action cannot be reverted.",
|
||||
"keep_remove_flag_content_modal.action_remove.title": "Remove message from channel",
|
||||
"keep_remove_flag_content_modal.comment_required.error": "Please add a comment.",
|
||||
"keep_remove_flag_content_modal.comment.placeholder": "Add your comment here",
|
||||
"last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.",
|
||||
"last_users_message.added_to_team.type": "were **added to the team** by {actor}.",
|
||||
"last_users_message.first": "{firstUser} and ",
|
||||
|
|
@ -5238,6 +5252,21 @@
|
|||
"promote_to_user_modal.promote": "Promote",
|
||||
"promote_to_user_modal.title": "Promote guest {username} to member",
|
||||
"property_card.actions_row.label": "Actions",
|
||||
"property_card.field.action_time.label": "Reviewed at",
|
||||
"property_card.field.actor_comment.label": "Reviewer's comment",
|
||||
"property_card.field.actor_user_id.label": "Reviewed by",
|
||||
"property_card.field.channel.label": "Channel",
|
||||
"property_card.field.post_author.label": "Posted by",
|
||||
"property_card.field.post_creation_time.label": "Posted at",
|
||||
"property_card.field.post_id.label": "Post ID",
|
||||
"property_card.field.post_preview.label": "Message",
|
||||
"property_card.field.reporting_comment.label": "Comment",
|
||||
"property_card.field.reporting_reason.label": "Reason",
|
||||
"property_card.field.reporting_time.label": "Flagged at",
|
||||
"property_card.field.reporting_user_id.label": "Flagged by",
|
||||
"property_card.field.reviewer_user_id.label": "Reviewer",
|
||||
"property_card.field.status.label": "Status",
|
||||
"property_card.field.team.label": "Team",
|
||||
"public_private_selector.private.description": "Only invited members",
|
||||
"public_private_selector.private.title": "Private",
|
||||
"public_private_selector.public.description": "Anyone",
|
||||
|
|
@ -5264,6 +5293,8 @@
|
|||
"reaction.usersAndOthersReacted": "{users} and {otherUsers, number} other {otherUsers, plural, one {user} other {users}}",
|
||||
"reaction.usersReacted": "{users} and {lastUser}",
|
||||
"reaction.you": "You",
|
||||
"remove_flag_post_confirm_modal.optional_comment.title": "Comment (optional)",
|
||||
"remove_flag_post_confirm_modal.required_comment.title": "Comment (required)",
|
||||
"remove_group_confirm_button": "Yes, Remove Group and {memberCount, plural, one {Member} other {Members}}",
|
||||
"remove_group_confirm_message": "{memberCount, number} {memberCount, plural, one {member} other {members}} associated to this group will be removed from the team. Are you sure you wish to remove this group and {memberCount} {memberCount, plural, one {member} other {members}}?",
|
||||
"remove_group_confirm_title": "Remove Group and {memberCount, number} {memberCount, plural, one {Member} other {Members}}",
|
||||
|
|
|
|||
|
|
@ -5,4 +5,7 @@ import keyMirror from 'mattermost-redux/utils/key_mirror';
|
|||
|
||||
export default keyMirror({
|
||||
RECEIVED_CONTENT_FLAGGING_CONFIG: null,
|
||||
RECEIVED_POST_CONTENT_FLAGGING_FIELDS: null,
|
||||
RECEIVED_POST_CONTENT_FLAGGING_VALUES: null,
|
||||
CONTENT_FLAGGING_REPORT_VALUE_UPDATED: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
|
||||
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
|
||||
|
||||
import {TeamTypes, ContentFlaggingTypes} from 'mattermost-redux/action_types';
|
||||
import {logError} from 'mattermost-redux/actions/errors';
|
||||
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
|
||||
import {DelayedDataLoader} from 'mattermost-redux/utils/data_loader';
|
||||
|
||||
export function getTeamContentFlaggingStatus(teamId: string): ActionFuncAsync<{enabled: boolean}> {
|
||||
return async (dispatch, getState) => {
|
||||
|
|
@ -33,12 +35,12 @@ export function getTeamContentFlaggingStatus(teamId: string): ActionFuncAsync<{e
|
|||
};
|
||||
}
|
||||
|
||||
export function getContentFlaggingConfig(): ActionFuncAsync<ContentFlaggingConfig> {
|
||||
export function getContentFlaggingConfig(teamId?: string): ActionFuncAsync<ContentFlaggingConfig> {
|
||||
return async (dispatch, getState) => {
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await Client4.getContentFlaggingConfig();
|
||||
response = await Client4.getContentFlaggingConfig(teamId);
|
||||
|
||||
dispatch({
|
||||
type: ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_CONFIG,
|
||||
|
|
@ -53,3 +55,65 @@ export function getContentFlaggingConfig(): ActionFuncAsync<ContentFlaggingConfi
|
|||
return {data: response};
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostContentFlaggingFields(): ActionFuncAsync<NameMappedPropertyFields> {
|
||||
return async (dispatch, getState) => {
|
||||
let data;
|
||||
try {
|
||||
data = await Client4.getPostContentFlaggingFields();
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_FIELDS,
|
||||
data,
|
||||
});
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPostContentFlaggingFields(): ActionFuncAsync<NameMappedPropertyFields> {
|
||||
// Use data loader and fetch data to manage multiple, simultaneous dispatches
|
||||
return async (dispatch, getState, {loaders}: any) => {
|
||||
if (!loaders.postContentFlaggingFieldsLoader) {
|
||||
loaders.postContentFlaggingFieldsLoader = new DelayedDataLoader<NameMappedPropertyFields>({
|
||||
fetchBatch: () => dispatch(getPostContentFlaggingFields()),
|
||||
maxBatchSize: 1,
|
||||
wait: 200,
|
||||
});
|
||||
}
|
||||
|
||||
const loader = loaders.postContentFlaggingFieldsLoader;
|
||||
loader.queue([true]);
|
||||
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostContentFlaggingValues(postId: string): ActionFuncAsync<Array<PropertyValue<unknown>>> {
|
||||
return async (dispatch, getState) => {
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await Client4.getPostContentFlaggingValues(postId);
|
||||
|
||||
dispatch({
|
||||
type: ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_VALUES,
|
||||
data: {
|
||||
postId,
|
||||
values: response,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {data: response};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,13 +150,13 @@ export function postPinnedChanged(postId: string, isPinned: boolean, updateAt =
|
|||
};
|
||||
}
|
||||
|
||||
export function getPost(postId: string): ActionFuncAsync<Post> {
|
||||
export function getPost(postId: string, includeDeleted?: boolean, retainContent?: boolean): ActionFuncAsync<Post> {
|
||||
return async (dispatch, getState) => {
|
||||
let post;
|
||||
const crtEnabled = isCollapsedThreadsEnabled(getState());
|
||||
|
||||
try {
|
||||
post = await Client4.getPost(postId);
|
||||
post = await Client4.getPost(postId, includeDeleted, retainContent);
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch({type: PostTypes.GET_POSTS_FAILURE, error});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,19 @@
|
|||
|
||||
import {combineReducers} from 'redux';
|
||||
|
||||
import type {
|
||||
ContentFlaggingConfig,
|
||||
ContentFlaggingState,
|
||||
} from '@mattermost/types/content_flagging';
|
||||
import type {
|
||||
NameMappedPropertyFields,
|
||||
PropertyValue,
|
||||
} from '@mattermost/types/properties';
|
||||
|
||||
import type {MMReduxAction} from 'mattermost-redux/action_types';
|
||||
import {ContentFlaggingTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
function settings(state = {}, action: MMReduxAction) {
|
||||
function settings(state: ContentFlaggingState['settings'] = {} as ContentFlaggingConfig, action: MMReduxAction) {
|
||||
switch (action.type) {
|
||||
case ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_CONFIG: {
|
||||
return {
|
||||
|
|
@ -19,6 +28,52 @@ function settings(state = {}, action: MMReduxAction) {
|
|||
}
|
||||
}
|
||||
|
||||
function fields(state: ContentFlaggingState['fields'] = {} as NameMappedPropertyFields, action: MMReduxAction) {
|
||||
switch (action.type) {
|
||||
case ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_FIELDS: {
|
||||
return {
|
||||
...state,
|
||||
...action.data,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function postValues(state: ContentFlaggingState['postValues'] = {}, action: MMReduxAction) {
|
||||
switch (action.type) {
|
||||
case ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_VALUES: {
|
||||
return {
|
||||
...state,
|
||||
[action.data.postId]: action.data.values,
|
||||
};
|
||||
}
|
||||
case ContentFlaggingTypes.CONTENT_FLAGGING_REPORT_VALUE_UPDATED: {
|
||||
const postId = action.data.target_id as string;
|
||||
const existingPropertyValues = state[postId] || {};
|
||||
const updatedPropertyValues = JSON.parse(action.data.property_values);
|
||||
|
||||
const valuesByFieldId = {} as Record<string, PropertyValue<unknown>>;
|
||||
existingPropertyValues.forEach((property: PropertyValue<unknown>) => {
|
||||
valuesByFieldId[property.field_id] = property;
|
||||
});
|
||||
updatedPropertyValues.forEach((property: PropertyValue<unknown>) => {
|
||||
valuesByFieldId[property.field_id] = property;
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
[postId]: Object.values(valuesByFieldId),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
settings,
|
||||
fields,
|
||||
postValues,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ function profiles(state: UsersState['profiles'] = {}, action: MMReduxAction) {
|
|||
}, {});
|
||||
}
|
||||
case UserTypes.RECEIVED_PROFILES_LIST: {
|
||||
const users: UserProfile[] = action.data;
|
||||
const users: UserProfile[] = action.data || [] as UserProfile[];
|
||||
|
||||
return users.reduce(receiveUserProfile, state);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,17 @@ export const contentFlaggingFeatureEnabled = (state: GlobalState): boolean => {
|
|||
return featureFlagEnabled && featureEnabled;
|
||||
};
|
||||
|
||||
export const contentFlaggingConfig = (state: GlobalState) => state.entities.contentFlagging.settings;
|
||||
export const contentFlaggingConfig = (state: GlobalState) => {
|
||||
const config = state.entities.contentFlagging.settings;
|
||||
return (config && Object.keys(config).length) ? config : undefined;
|
||||
};
|
||||
|
||||
export const contentFlaggingFields = (state: GlobalState) => {
|
||||
const fields = state.entities.contentFlagging.fields;
|
||||
return (fields && Object.keys(fields).length) ? fields : undefined;
|
||||
};
|
||||
|
||||
export const postContentFlaggingValues = (state: GlobalState, postId: string) => {
|
||||
const values = state.entities.contentFlagging.postValues || {};
|
||||
return values[postId];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -484,6 +484,7 @@ export const ModalIdentifiers = {
|
|||
ATTRIBUTE_MODAL_LDAP: 'attribute_modal_ldap',
|
||||
ATTRIBUTE_MODAL_SAML: 'attribute_modal_saml',
|
||||
FLAG_POST: 'flag_post',
|
||||
REMOVE_FLAGGED_POST: 'remove_flagged_post',
|
||||
};
|
||||
|
||||
export const UserStatuses = {
|
||||
|
|
@ -701,6 +702,7 @@ export const SocketEvents = {
|
|||
CPA_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
|
||||
CPA_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
|
||||
CPA_VALUES_UPDATED: 'custom_profile_attributes_values_updated',
|
||||
CONTENT_FLAGGING_REPORT_VALUE_CHANGED: 'content_flagging_report_value_updated',
|
||||
};
|
||||
|
||||
export const TutorialSteps = {
|
||||
|
|
@ -1482,20 +1484,8 @@ export const ZoomSettings = {
|
|||
};
|
||||
|
||||
export const DataSpillagePropertyNames = {
|
||||
Status: 'Status',
|
||||
FlaggedBy: 'Flagged by',
|
||||
Reason: 'Reason',
|
||||
Comment: 'Comment',
|
||||
ReportingTime: 'Reporting Time',
|
||||
ReviewingUser: 'Reviewing User',
|
||||
ActionBy: 'Action By',
|
||||
ActionComment: 'Action Comment',
|
||||
ActionTime: 'Action Time',
|
||||
Message: 'Message',
|
||||
PostedIn: 'Posted in',
|
||||
Team: 'Team',
|
||||
PostedBy: 'Posted by',
|
||||
PostedAt: 'Posted at',
|
||||
FlaggedBy: 'reporting_user_id',
|
||||
Status: 'status',
|
||||
};
|
||||
|
||||
export const Constants = {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,12 @@ import type {
|
|||
import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageResponse, PaginatedPostList, FilesUsageResponse, PostAcknowledgement, PostAnalytics, PostInfo} from '@mattermost/types/posts';
|
||||
import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
import type {ProductNotices} from '@mattermost/types/product_notices';
|
||||
import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties';
|
||||
import type {
|
||||
NameMappedPropertyFields,
|
||||
UserPropertyField,
|
||||
UserPropertyFieldPatch,
|
||||
PropertyValue,
|
||||
} from '@mattermost/types/properties';
|
||||
import type {Reaction} from '@mattermost/types/reactions';
|
||||
import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
|
||||
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
|
||||
|
|
@ -2206,9 +2211,9 @@ export default class Client4 {
|
|||
);
|
||||
};
|
||||
|
||||
getPost = (postId: string) => {
|
||||
getPost = (postId: string, includeDeleted?: boolean, retainContent?: boolean) => {
|
||||
return this.doFetch<Post>(
|
||||
`${this.getPostRoute(postId)}`,
|
||||
`${this.getPostRoute(postId)}${buildQueryString({include_deleted: includeDeleted, retain_content: retainContent})}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
|
@ -4621,9 +4626,60 @@ export default class Client4 {
|
|||
);
|
||||
};
|
||||
|
||||
getContentFlaggingConfig = () => {
|
||||
getContentFlaggingConfig = (teamId?: string) => {
|
||||
return this.doFetch<ContentFlaggingConfig>(
|
||||
`${this.getContentFlaggingRoute()}/flag/config`,
|
||||
`${this.getContentFlaggingRoute()}/flag/config${buildQueryString({team_id: teamId})}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
flagPost = (postId: string, reason: string, comment?: string) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getContentFlaggingRoute()}/post/${postId}/flag`,
|
||||
{
|
||||
method: 'post',
|
||||
body: JSON.stringify({reason, comment: JSON.stringify(comment)}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
removeFlaggedPost = (postId: string, comment?: string) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getContentFlaggingRoute()}/post/${postId}/remove`,
|
||||
{
|
||||
method: 'put',
|
||||
body: JSON.stringify({comment: JSON.stringify(comment)}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
keepFlaggedPost = (postId: string, comment?: string) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getContentFlaggingRoute()}/post/${postId}/keep`,
|
||||
{
|
||||
method: 'put',
|
||||
body: JSON.stringify({comment: JSON.stringify(comment)}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
getPostContentFlaggingFields = () => {
|
||||
return this.doFetch<NameMappedPropertyFields>(
|
||||
`${this.getContentFlaggingRoute()}/fields`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
getPostContentFlaggingValues = (postId: string) => {
|
||||
return this.doFetch<Array<PropertyValue<unknown>>>(
|
||||
`${this.getContentFlaggingRoute()}/post/${postId}/field_values`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
getFlaggedPost = (postId: string) => {
|
||||
return this.doFetch<Post>(
|
||||
`${this.getContentFlaggingRoute()}/post/${postId}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Post} from './posts';
|
||||
import type {
|
||||
NameMappedPropertyFields,
|
||||
PropertyValue,
|
||||
} from './properties';
|
||||
|
||||
export type ContentFlaggingEvent = 'flagged' | 'assigned' | 'removed' | 'dismissed';
|
||||
|
||||
export type NotificationTarget = 'reviewers' | 'author' | 'reporter';
|
||||
|
|
@ -8,4 +14,22 @@ export type NotificationTarget = 'reviewers' | 'author' | 'reporter';
|
|||
export type ContentFlaggingConfig = {
|
||||
reasons: string[];
|
||||
reporter_comment_required: boolean;
|
||||
reviewer_comment_required: boolean;
|
||||
notify_reporter_on_dismissal?: boolean;
|
||||
notify_reporter_on_removal?: boolean;
|
||||
};
|
||||
|
||||
export type ContentFlaggingState = {
|
||||
settings?: ContentFlaggingConfig;
|
||||
fields?: NameMappedPropertyFields;
|
||||
postValues?: {
|
||||
[key: Post['id']]: Array<PropertyValue<unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
export enum ContentFlaggingStatus {
|
||||
Pending = 'Pending',
|
||||
Assigned = 'Assigned',
|
||||
Removed = 'Removed',
|
||||
Retained = 'Retained',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export type PropertyField = {
|
|||
delete_at: number;
|
||||
};
|
||||
|
||||
export type NameMappedPropertyFields = {[key: PropertyField['name']]: PropertyField};
|
||||
|
||||
export type PropertyValue<T> = {
|
||||
id: string;
|
||||
target_id: string;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {ChannelBookmarksState} from './channel_bookmarks';
|
|||
import type {ChannelCategoriesState} from './channel_categories';
|
||||
import type {ChannelsState} from './channels';
|
||||
import type {CloudState, CloudUsage} from './cloud';
|
||||
import type {ContentFlaggingConfig} from './content_flagging';
|
||||
import type {ContentFlaggingState} from './content_flagging';
|
||||
import type {EmojisState} from './emojis';
|
||||
import type {FilesState} from './files';
|
||||
import type {GeneralState} from './general';
|
||||
|
|
@ -83,9 +83,7 @@ export type GlobalState = {
|
|||
remotes?: Record<string, RemoteClusterInfo[]>;
|
||||
remotesByRemoteId?: Record<string, RemoteClusterInfo>;
|
||||
};
|
||||
contentFlagging: {
|
||||
settings?: ContentFlaggingConfig;
|
||||
};
|
||||
contentFlagging: ContentFlaggingState;
|
||||
};
|
||||
errors: any[];
|
||||
requests: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue