From c21ef29f02244716f8ef45bae58bf86b686a149b Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:24:29 +0530 Subject: [PATCH] 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) * fix: add mock for getContentFlaggingConfig in flag post modal test Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) * 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) --- api/v4/source/content_flagging.yaml | 195 +++++ server/channels/api4/apitestlib.go | 7 + server/channels/api4/content_flagging.go | 368 ++++++++- server/channels/api4/content_flagging_test.go | 364 +++++++++ server/channels/api4/post.go | 6 +- server/channels/app/bot.go | 9 +- server/channels/app/content_flagging.go | 685 +++++++++++++++- server/channels/app/content_flagging_test.go | 764 +++++++++++++++++- .../channels/app/custom_profile_attributes.go | 2 +- .../app/custom_profile_attributes_test.go | 10 +- server/channels/app/migrations.go | 13 +- server/channels/app/post.go | 10 +- server/channels/app/post_acknowledgements.go | 2 +- server/channels/app/post_metadata.go | 22 +- server/channels/app/post_metadata_test.go | 50 +- .../app/post_persistent_notification.go | 2 +- server/channels/app/post_priority.go | 10 + .../channels/app/properties/property_value.go | 4 + server/channels/app/webhook_test.go | 8 +- .../channels/store/retrylayer/retrylayer.go | 63 ++ .../sqlstore/post_acknowledgements_store.go | 9 + server/channels/store/sqlstore/post_store.go | 8 + .../store/sqlstore/property_value_store.go | 48 +- server/channels/store/store.go | 3 + .../store/storetest/attributes_store.go | 10 +- .../mocks/PostAcknowledgementStore.go | 18 + .../store/storetest/mocks/PostStore.go | 18 + .../storetest/mocks/PropertyValueStore.go | 30 + .../store/storetest/property_value_store.go | 220 +++++ .../channels/store/timerlayer/timerlayer.go | 48 ++ server/i18n/en.json | 132 +++ .../sharedchannel/mock_AppIface_test.go | 10 +- .../services/sharedchannel/service.go | 2 +- .../services/sharedchannel/sync_send.go | 2 +- .../sharedchannel/sync_send_remote.go | 2 +- server/public/model/audit_events.go | 8 + server/public/model/client4.go | 41 + server/public/model/content_flagging.go | 62 +- .../public/model/content_flagging_settings.go | 7 +- server/public/model/content_flagging_test.go | 118 +++ server/public/model/post.go | 8 + server/public/model/property_value.go | 3 + server/public/model/websocket_message.go | 1 + .../src/actions/websocket_actions.jsx | 11 + .../common/hooks/useContentFlaggingFields.ts | 36 + .../components/file_attachment_list/index.ts | 9 +- .../flag_message_modal/flag_post_modal.scss | 8 + .../flag_post_modal.test.tsx | 48 +- .../flag_message_modal/flag_post_modal.tsx | 31 +- .../post_markdown/post_markdown.test.tsx | 23 +- .../post_markdown/post_markdown.tsx | 2 +- .../data_spillage_actions.test.tsx | 11 +- .../data_spillage_actions.tsx | 56 +- .../data_spillage_footer.tsx | 42 + .../data_spillage_report.test.tsx | 270 ++++++- .../data_spillage_report.tsx | 496 +++--------- .../data_spillage_report/synthetic_data.ts | 161 ++++ .../post_view/post_message_preview/index.ts | 3 +- .../post_message_preview.tsx | 1 + .../post_message_view/post_message_view.tsx | 30 +- .../properties_card_view.tsx | 149 +++- .../post_preview_property_renderer.test.tsx | 106 +-- .../post_preview_property_renderer.tsx | 48 +- .../propertyValueRenderer.tsx | 26 +- .../selectPropertyRenderer.tsx | 8 +- .../textPropertyRenderer.tsx | 15 +- ...ve_flagged_message_confirmation_modal.scss | 55 ++ ...ove_flagged_message_confirmation_modal.tsx | 225 ++++++ .../channels/src/components/textbox.test.tsx | 4 + webapp/channels/src/i18n/en.json | 31 + .../src/action_types/content_flagging.ts | 3 + .../src/actions/content_flagging.ts | 68 +- .../mattermost-redux/src/actions/posts.ts | 4 +- .../src/reducers/entities/content_flagging.ts | 57 +- .../src/reducers/entities/users.ts | 2 +- .../selectors/entities/content_flagging.ts | 15 +- webapp/channels/src/utils/constants.tsx | 18 +- webapp/platform/client/src/client4.ts | 66 +- webapp/platform/types/src/content_flagging.ts | 24 + webapp/platform/types/src/properties.ts | 2 + webapp/platform/types/src/store.ts | 6 +- 81 files changed, 4943 insertions(+), 629 deletions(-) create mode 100644 server/public/model/content_flagging_test.go create mode 100644 webapp/channels/src/components/common/hooks/useContentFlaggingFields.ts create mode 100644 webapp/channels/src/components/post_view/data_spillage_report/data_spillage_footer/data_spillage_footer.tsx create mode 100644 webapp/channels/src/components/post_view/data_spillage_report/synthetic_data.ts create mode 100644 webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.scss create mode 100644 webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx diff --git a/api/v4/source/content_flagging.yaml b/api/v4/source/content_flagging.yaml index cd223489dd9..ea4bb02ae05 100644 --- a/api/v4/source/content_flagging.yaml +++ b/api/v4/source/content_flagging.yaml @@ -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. diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 8299eb9bfed..3afbfb10535 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -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) diff --git a/server/channels/api4/content_flagging.go b/server/channels/api4/content_flagging.go index da48f7e66b8..4d896fa1bd6 100644 --- a/server/channels/api4/content_flagging.go +++ b/server/channels/api4/content_flagging.go @@ -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 +} diff --git a/server/channels/api4/content_flagging_test.go b/server/channels/api4/content_flagging_test.go index 7a89df39cb7..790ebd8650b 100644 --- a/server/channels/api4/content_flagging_test.go +++ b/server/channels/api4/content_flagging_test.go @@ -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() diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index 444b7d50371..cd914da734b 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -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) } diff --git a/server/channels/app/bot.go b/server/channels/app/bot.go index 708d2e44538..82dfd631eae 100644 --- a/server/channels/app/bot.go +++ b/server/channels/app/bot.go @@ -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, } diff --git a/server/channels/app/content_flagging.go b/server/channels/app/content_flagging.go index 960e6c5a8d4..d5aef42faab 100644 --- a/server/channels/app/content_flagging.go +++ b/server/channels/app/content_flagging.go @@ -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 +} diff --git a/server/channels/app/content_flagging_test.go b/server/channels/app/content_flagging_test.go index b9ac7b7f42c..086de680248 100644 --- a/server/channels/app/content_flagging_test.go +++ b/server/channels/app/content_flagging_test.go @@ -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) + }) +} diff --git a/server/channels/app/custom_profile_attributes.go b/server/channels/app/custom_profile_attributes.go index d4041e543f1..0f63a4ef3e9 100644 --- a/server/channels/app/custom_profile_attributes.go +++ b/server/channels/app/custom_profile_attributes.go @@ -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, diff --git a/server/channels/app/custom_profile_attributes_test.go b/server/channels/app/custom_profile_attributes_test.go index 5087ee6a459..73e8ee4d98c 100644 --- a/server/channels/app/custom_profile_attributes_test.go +++ b/server/channels/app/custom_profile_attributes_test.go @@ -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)), diff --git a/server/channels/app/migrations.go b/server/channels/app/migrations.go index 837b634476a..82ae1d07246 100644 --- a/server/channels/app/migrations.go +++ b/server/channels/app/migrations.go @@ -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, diff --git a/server/channels/app/post.go b/server/channels/app/post.go index a877242e1fe..a79bf61a4c2 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -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() diff --git a/server/channels/app/post_acknowledgements.go b/server/channels/app/post_acknowledgements.go index 92cd7abe803..7c41420778e 100644 --- a/server/channels/app/post_acknowledgements.go +++ b/server/channels/app/post_acknowledgements.go @@ -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)) diff --git a/server/channels/app/post_metadata.go b/server/channels/app/post_metadata.go index 4722ed53589..bbe8291e900 100644 --- a/server/channels/app/post_metadata.go +++ b/server/channels/app/post_metadata.go @@ -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)} } diff --git a/server/channels/app/post_metadata_test.go b/server/channels/app/post_metadata_test.go index 995ab2fa736..13efd52f09a 100644 --- a/server/channels/app/post_metadata_test.go +++ b/server/channels/app/post_metadata_test.go @@ -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) diff --git a/server/channels/app/post_persistent_notification.go b/server/channels/app/post_persistent_notification.go index 191445a8802..76f03a52e7a 100644 --- a/server/channels/app/post_persistent_notification.go +++ b/server/channels/app/post_persistent_notification.go @@ -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") diff --git a/server/channels/app/post_priority.go b/server/channels/app/post_priority.go index 8bd0f5fa9b0..339311d0ad7 100644 --- a/server/channels/app/post_priority.go +++ b/server/channels/app/post_priority.go @@ -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 +} diff --git a/server/channels/app/properties/property_value.go b/server/channels/app/properties/property_value.go index b3a03f5eeb4..90e48285ec4 100644 --- a/server/channels/app/properties/property_value.go +++ b/server/channels/app/properties/property_value.go @@ -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) } diff --git a/server/channels/app/webhook_test.go b/server/channels/app/webhook_test.go index 824e94c820a..cdaff30b5bc 100644 --- a/server/channels/app/webhook_test.go +++ b/server/channels/app/webhook_test.go @@ -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)) }) diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 87a1d2c0c1b..4f71e70aba7 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -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 diff --git a/server/channels/store/sqlstore/post_acknowledgements_store.go b/server/channels/store/sqlstore/post_acknowledgements_store.go index fb6f78775c1..923cc2aa0e8 100644 --- a/server/channels/store/sqlstore/post_acknowledgements_store.go +++ b/server/channels/store/sqlstore/post_acknowledgements_store.go @@ -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 diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index 4045f7ea5d3..d444cb4909c 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -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, diff --git a/server/channels/store/sqlstore/property_value_store.go b/server/channels/store/sqlstore/property_value_store.go index 1343c875a19..0404c5c625d 100644 --- a/server/channels/store/sqlstore/property_value_store.go +++ b/server/channels/store/sqlstore/property_value_store.go @@ -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( diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 29830985d29..310ee9f7ab9 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -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) diff --git a/server/channels/store/storetest/attributes_store.go b/server/channels/store/storetest/attributes_store.go index c57b64569d5..24b479dc02d 100644 --- a/server/channels/store/storetest/attributes_store.go +++ b/server/channels/store/storetest/attributes_store.go @@ -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, diff --git a/server/channels/store/storetest/mocks/PostAcknowledgementStore.go b/server/channels/store/storetest/mocks/PostAcknowledgementStore.go index 6b56eac0a1b..b0e1077de1e 100644 --- a/server/channels/store/storetest/mocks/PostAcknowledgementStore.go +++ b/server/channels/store/storetest/mocks/PostAcknowledgementStore.go @@ -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) diff --git a/server/channels/store/storetest/mocks/PostStore.go b/server/channels/store/storetest/mocks/PostStore.go index 26cf841ae2c..a1cb04e5d12 100644 --- a/server/channels/store/storetest/mocks/PostStore.go +++ b/server/channels/store/storetest/mocks/PostStore.go @@ -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) diff --git a/server/channels/store/storetest/mocks/PropertyValueStore.go b/server/channels/store/storetest/mocks/PropertyValueStore.go index acf7ddd72c8..b8a9cfbe736 100644 --- a/server/channels/store/storetest/mocks/PropertyValueStore.go +++ b/server/channels/store/storetest/mocks/PropertyValueStore.go @@ -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) diff --git a/server/channels/store/storetest/property_value_store.go b/server/channels/store/storetest/property_value_store.go index 33e78a021b0..6cfedcf0788 100644 --- a/server/channels/store/storetest/property_value_store.go +++ b/server/channels/store/storetest/property_value_store.go @@ -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()) diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 0e3e5e42013..1425641ea0a 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -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() diff --git a/server/i18n/en.json b/server/i18n/en.json index 6b12ae19b35..dd66dc0debf 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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." diff --git a/server/platform/services/sharedchannel/mock_AppIface_test.go b/server/platform/services/sharedchannel/mock_AppIface_test.go index 8a79085e47e..13e3d194b2e 100644 --- a/server/platform/services/sharedchannel/mock_AppIface_test.go +++ b/server/platform/services/sharedchannel/mock_AppIface_test.go @@ -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) diff --git a/server/platform/services/sharedchannel/service.go b/server/platform/services/sharedchannel/service.go index b073395c673..daf07f167ec 100644 --- a/server/platform/services/sharedchannel/service.go +++ b/server/platform/services/sharedchannel/service.go @@ -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. diff --git a/server/platform/services/sharedchannel/sync_send.go b/server/platform/services/sharedchannel/sync_send.go index f47b009c5df..6b7496a7f60 100644 --- a/server/platform/services/sharedchannel/sync_send.go +++ b/server/platform/services/sharedchannel/sync_send.go @@ -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} diff --git a/server/platform/services/sharedchannel/sync_send_remote.go b/server/platform/services/sharedchannel/sync_send_remote.go index 364693002f2..f28479e121c 100644 --- a/server/platform/services/sharedchannel/sync_send_remote.go +++ b/server/platform/services/sharedchannel/sync_send_remote.go @@ -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}) } } diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 4282c1b6c3a..967d385cda2 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -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 +) diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 881ba7340d0..f4a70dbd865 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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" } diff --git a/server/public/model/content_flagging.go b/server/public/model/content_flagging.go index 542029ec869..79da2741cf9 100644 --- a/server/public/model/content_flagging.go +++ b/server/public/model/content_flagging.go @@ -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 +} diff --git a/server/public/model/content_flagging_settings.go b/server/public/model/content_flagging_settings.go index ed0243c46fe..5d0ee611ed2 100644 --- a/server/public/model/content_flagging_settings.go +++ b/server/public/model/content_flagging_settings.go @@ -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"` } diff --git a/server/public/model/content_flagging_test.go b/server/public/model/content_flagging_test.go new file mode 100644 index 00000000000..6f1ec23763a --- /dev/null +++ b/server/public/model/content_flagging_test.go @@ -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) + }) +} diff --git a/server/public/model/post.go b/server/public/model/post.go index e99f87a8c95..0113484c227 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -1115,3 +1115,11 @@ func DefaultUpdatePostOptions() *UpdatePostOptions { IsRestorePost: false, } } + +type PreparePostForClientOpts struct { + IsNewPost bool + IsEditPost bool + IncludePriority bool + RetainContent bool + IncludeDeleted bool +} diff --git a/server/public/model/property_value.go b/server/public/model/property_value.go index 8f1ba110198..61ba5c7ceab 100644 --- a/server/public/model/property_value.go +++ b/server/public/model/property_value.go @@ -14,6 +14,9 @@ import ( const ( PropertyValueTargetIDMaxRunes = 255 PropertyValueTargetTypeMaxRunes = 255 + + PropertyValueTargetTypePost = "post" + PropertyValueTargetTypeUser = "user" ) type PropertyValue struct { diff --git a/server/public/model/websocket_message.go b/server/public/model/websocket_message.go index f0bee096c55..9aea36af0e3 100644 --- a/server/public/model/websocket_message.go +++ b/server/public/model/websocket_message.go @@ -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" diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index eb8493514cc..024d8bbc95c 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -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, + }; +} diff --git a/webapp/channels/src/components/common/hooks/useContentFlaggingFields.ts b/webapp/channels/src/components/common/hooks/useContentFlaggingFields.ts new file mode 100644 index 00000000000..1f6b71e12f8 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useContentFlaggingFields.ts @@ -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({ + name: 'useContentFlaggingFields', + fetch: loadPostContentFlaggingFields, + selector: contentFlaggingFields, +}); + +export const usePostContentFlaggingValues = makeUseEntity>>({ + name: 'usePostContentFlaggingValues', + fetch: getPostContentFlaggingValues, + selector: postContentFlaggingValues, +}); + +export const useContentFlaggingConfig = makeUseEntity({ + name: 'useContentFlaggingConfig', + fetch: getContentFlaggingConfig, + selector: contentFlaggingConfig, +}); diff --git a/webapp/channels/src/components/file_attachment_list/index.ts b/webapp/channels/src/components/file_attachment_list/index.ts index 7e203ede73d..a170e4db223 100644 --- a/webapp/channels/src/components/file_attachment_list/index.ts +++ b/webapp/channels/src/components/file_attachment_list/index.ts @@ -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); diff --git a/webapp/channels/src/components/flag_message_modal/flag_post_modal.scss b/webapp/channels/src/components/flag_message_modal/flag_post_modal.scss index cd8d138e27c..5467b1a63e5 100644 --- a/webapp/channels/src/components/flag_message_modal/flag_post_modal.scss +++ b/webapp/channels/src/components/flag_message_modal/flag_post_modal.scss @@ -52,4 +52,12 @@ height: 110px !important; } } + + .FlagPostModal__request-error { + display: flex; + width: 90%; + align-items: center; + color: var(--error-text); + font-size: 12px; + } } diff --git a/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx b/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx index c2eeb6a3c64..c6a1c3389cf 100644 --- a/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx +++ b/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx @@ -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 = { 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( + , + 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(); + }); + }); }); diff --git a/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx b/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx index 687f60c0292..c64ca3c343b 100644 --- a/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx +++ b/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx @@ -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(''); const [commentError, setCommentError] = React.useState(''); const [reasonError, setReasonError] = React.useState(''); + const [requestError, setRequestError] = React.useState(''); + const [submitting, setSubmitting] = React.useState(false); const [showCommentPreview, setShowCommentPreview] = React.useState(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 (
@@ -233,6 +246,12 @@ export default function FlagPostModal({postId, onExited}: Props) { maxLength={1000} />
+ {requestError && +
+ + {requestError} +
+ }
); diff --git a/webapp/channels/src/components/post_markdown/post_markdown.test.tsx b/webapp/channels/src/components/post_markdown/post_markdown.test.tsx index a9888364a3a..628d8a73e8d 100644 --- a/webapp/channels/src/components/post_markdown/post_markdown.test.tsx +++ b/webapp/channels/src/components/post_markdown/post_markdown.test.tsx @@ -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(() =>
{'PostPreviewPropertyRenderer Mock'}
); }); +jest.mock('mattermost-redux/client'); + +jest.mock('components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal', () => { + return jest.fn(() =>
{'KeepRemoveFlaggedMessageConfirmationModal Mock'}
); +}); + +const mockedClient4 = jest.mocked(Client4); describe('components/PostMarkdown', () => { const baseProps: ComponentProps = { @@ -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(, state); + await act(async () => {}); expect(screen.queryByTestId('data-spillage-report')).toBeInTheDocument(); }); diff --git a/webapp/channels/src/components/post_markdown/post_markdown.tsx b/webapp/channels/src/components/post_markdown/post_markdown.tsx index 6cef8637c2d..a074a926eb1 100644 --- a/webapp/channels/src/components/post_markdown/post_markdown.tsx +++ b/webapp/channels/src/components/post_markdown/post_markdown.tsx @@ -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'; diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.test.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.test.tsx index 9a7bb013aaa..f250bf2e2a1 100644 --- a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.test.tsx +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.test.tsx @@ -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(); + const flaggedPost = TestHelper.getPostMock(); + const reportingUser = TestHelper.getUserMock(); + + renderWithContext( + , + ); expect(screen.getByTestId('data-spillage-action')).toBeInTheDocument(); expect(screen.getByTestId('data-spillage-action-remove-message')).toBeInTheDocument(); diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.tsx index 8059fe79a18..1061d80700c 100644 --- a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.tsx +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions.tsx @@ -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 (
{ + if (post) { + dispatch(selectPostFromRightHandSideSearch(post)); + } + }, [dispatch, post]); + + return ( +
+ +
+ ); +} diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx index 1ef6b9f35b2..4e6c95a860c 100644 --- a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.test.tsx @@ -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; +const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction; +const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction; + +const mockGetPost = require('mattermost-redux/actions/posts').getPost as jest.MockedFunction; +const useContentFlaggingFields = require('components/common/hooks/useContentFlaggingFields').useContentFlaggingFields as jest.MockedFunction; +const usePostContentFlaggingValues = require('components/common/hooks/useContentFlaggingFields').usePostContentFlaggingValues as jest.MockedFunction; + +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( { 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(); diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx index 50abfdf8f5f..b89afe43c74 100644 --- a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx @@ -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> { - 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([]); - const [propertyValues, setPropertyValues] = useState>>([]); + 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(); 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> => { + 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(() => { + 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 (); + }, [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 ? ( + ) : null; + }, [propertyFields, propertyValues, reportedPost, reportingUser]); + return (
} + actionsRow={actionRow} mode={mode} + metadata={metadata} + footer={footer} />
); } + +async function loadFlaggedPost(postId: string) { + return Client4.getFlaggedPost(postId); +} diff --git a/webapp/channels/src/components/post_view/data_spillage_report/synthetic_data.ts b/webapp/channels/src/components/post_view/data_spillage_report/synthetic_data.ts new file mode 100644 index 00000000000..07ed4e99c18 --- /dev/null +++ b/webapp/channels/src/components/post_view/data_spillage_report/synthetic_data.ts @@ -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> { + 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, + }, + ]; +} diff --git a/webapp/channels/src/components/post_view/post_message_preview/index.ts b/webapp/channels/src/components/post_view/post_message_preview/index.ts index f01a2b8b9ae..800a6d54abe 100644 --- a/webapp/channels/src/components/post_view/post_message_preview/index.ts +++ b/webapp/channels/src/components/post_view/post_message_preview/index.ts @@ -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); diff --git a/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx b/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx index 5ebeda948d3..66c946fef29 100644 --- a/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx +++ b/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx @@ -62,6 +62,7 @@ const PostMessagePreview = (props: Props) => { compactDisplay={compactDisplay} isInPermalink={true} handleFileDropdownOpened={handleFileDropdownOpened} + usePostAsSource={props.usePostAsSource} /> ); } diff --git a/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx b/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx index 2a4dc38383f..2c178e18be0 100644 --- a/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx +++ b/webapp/channels/src/components/post_view/post_message_view/post_message_view.tsx @@ -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 { const channel = getChannel(store.getState(), post.channel_id); const isSharedChannel = channel?.shared || false; - return ( - + const body = ( + <>
{ onHeightChange={this.handleHeightReceived} /> )} + + ); + + if (FULL_HEIGHT_POST_TYPES.has(postType)) { + return body; + } + + return ( + + {body} ); } diff --git a/webapp/channels/src/components/properties_card_view/properties_card_view.tsx b/webapp/channels/src/components/properties_card_view/properties_card_view.tsx index 3073d4ff3e8..9ad70bf4676 100644 --- a/webapp/channels/src/components/properties_card_view/properties_card_view.tsx +++ b/webapp/channels/src/components/properties_card_view/properties_card_view.tsx @@ -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; + fetchDeletedPost?: boolean; +}; + +export type TextFieldMetadata = { + placeholder?: string; +}; + +export type FieldMetadata = PostPreviewFieldMetadata | TextFieldMetadata; + +export type PropertiesCardViewMetadata = { + [key: string]: FieldMetadata; +} + +type OrderedRow = { + field: PropertyField; + value: PropertyValue; +}; + +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; shortModeFieldOrder: Array; propertyValues: Array>; 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}>>(() => { - if (!propertyFields.length || !fieldOrder.length || !propertyValues.length) { +export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionsRow, metadata, footer}: Props) { + const orderedRows = useMemo(() => { + 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}); + // 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 (
{ orderedRows.map(({field, value}) => { + const translation = fieldNameMessages[field.name as keyof typeof fieldNameMessages]; + return (
- {field.name} + {translation ? : field.name}
@@ -103,6 +194,8 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
} + + {footer}
); diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.test.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.test.tsx index 969f03c41bd..1aff6a7f5e4 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.test.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.test.tsx @@ -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; const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction; const mockUseTeam = require('components/common/hooks/use_team').useTeam as jest.MockedFunction; +const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction; +const mockedClient4 = jest.mocked(Client4); describe('PostPreviewPropertyRenderer', () => { const mockUser: UserProfile = { @@ -59,9 +67,13 @@ describe('PostPreviewPropertyRenderer', () => { value: { value: 'post-id-123', } as PropertyValue, + metadata: { + fetchDeletedPost: true, + getPost: (postId: string) => Client4.getFlaggedPost(postId), + }, }; - const baseState = { + const baseState: DeepPartial = { 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( , 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( , - 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(); }); }); diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx index 958825712d4..f8e3844f7e2 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx @@ -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; + 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(); 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} /> ); diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/propertyValueRenderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/propertyValueRenderer.tsx index 6fedfee9956..fccf49f2f5a 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/propertyValueRenderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/propertyValueRenderer.tsx @@ -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; + metadata?: FieldMetadata; }; -export default function PropertyValueRenderer({field, value}: Props) { +export default function PropertyValueRenderer({field, value, metadata}: Props) { switch (field.type) { case 'text': return ( ); 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 ; + return ( + + ); case 'post': - return ; + return ( + + ); case 'channel': return ; case 'team': diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/select_property_renderer/selectPropertyRenderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/select_property_renderer/selectPropertyRenderer.tsx index 3592e06b93b..a3029b97a6a 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/select_property_renderer/selectPropertyRenderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/select_property_renderer/selectPropertyRenderer.tsx @@ -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)', diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/text_property_renderer/textPropertyRenderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/text_property_renderer/textPropertyRenderer.tsx index 2b057651f92..a1e5adc466f 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/text_property_renderer/textPropertyRenderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/text_property_renderer/textPropertyRenderer.tsx @@ -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; + metadata?: TextFieldMetadata; } -export default function TextPropertyRenderer({value}: Props) { +export default function TextPropertyRenderer({value, metadata}: Props) { return ( - {value.value} + {Boolean(value.value) && value.value} + + { + !value.value && metadata?.placeholder && ( + + {metadata.placeholder} + + ) + } ); } diff --git a/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.scss b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.scss new file mode 100644 index 00000000000..f1ad6597704 --- /dev/null +++ b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.scss @@ -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; + } + } +} + + + + + + + diff --git a/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx new file mode 100644 index 00000000000..17aeb335772 --- /dev/null +++ b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx @@ -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(''); + const [commentError, setCommentError] = React.useState(''); + const [requestError, setRequestError] = React.useState(''); + const [submitting, setSubmitting] = React.useState(false); + const [showCommentPreview, setShowCommentPreview] = React.useState(false); + + const handleCommentChange = useCallback((e: React.ChangeEvent) => { + 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:
, + flaggedPostChannel: flaggedPostChannel?.display_name, + reportingUser: , + flaggedPostAuthor: , + }); + 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:
, + flaggedPostChannel: flaggedPostChannel?.display_name, + reportingUser: , + flaggedPostAuthor: , + }); + + 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 ( + +
+
+ {body} +
+
+ {subtext} +
+ +
+
+ {contentFlaggingConfig?.reviewer_comment_required ? requiredCommentSectionTitle : optionalCommentSectionTitle} +
+ + {}} + hasError={false} + errorMessage={commentError} + maxLength={1000} + /> +
+ {requestError && +
+ + {requestError} +
+ } +
+
+ ); +} diff --git a/webapp/channels/src/components/textbox.test.tsx b/webapp/channels/src/components/textbox.test.tsx index dee749c837b..8801d79cb55 100644 --- a/webapp/channels/src/components/textbox.test.tsx +++ b/webapp/channels/src/components/textbox.test.tsx @@ -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(() =>
{'KeepRemoveFlaggedMessageConfirmationModal Mock'}
); +}); + describe('components/TextBox', () => { const baseProps: Props = { channelId: 'channelId', diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 0a04e8ae34b..28c14b68d26 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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}}", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/content_flagging.ts index 4c4ee695e0f..15282e8ce28 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/content_flagging.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/content_flagging.ts @@ -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, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts index 480f8fde52a..317e56da8c5 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts @@ -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 { +export function getContentFlaggingConfig(teamId?: string): ActionFuncAsync { 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 { + 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 { + // Use data loader and fetch data to manage multiple, simultaneous dispatches + return async (dispatch, getState, {loaders}: any) => { + if (!loaders.postContentFlaggingFieldsLoader) { + loaders.postContentFlaggingFieldsLoader = new DelayedDataLoader({ + fetchBatch: () => dispatch(getPostContentFlaggingFields()), + maxBatchSize: 1, + wait: 200, + }); + } + + const loader = loaders.postContentFlaggingFieldsLoader; + loader.queue([true]); + + return {}; + }; +} + +export function getPostContentFlaggingValues(postId: string): ActionFuncAsync>> { + 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}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index 36f64720f55..08bb3993c27 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -150,13 +150,13 @@ export function postPinnedChanged(postId: string, isPinned: boolean, updateAt = }; } -export function getPost(postId: string): ActionFuncAsync { +export function getPost(postId: string, includeDeleted?: boolean, retainContent?: boolean): ActionFuncAsync { 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}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts index 5dc0008a787..4f69dd102ad 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/content_flagging.ts @@ -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>; + existingPropertyValues.forEach((property: PropertyValue) => { + valuesByFieldId[property.field_id] = property; + }); + updatedPropertyValues.forEach((property: PropertyValue) => { + valuesByFieldId[property.field_id] = property; + }); + + return { + ...state, + [postId]: Object.values(valuesByFieldId), + }; + } + default: + return state; + } +} + export default combineReducers({ settings, + fields, + postValues, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts index 8a196d01a85..9d1d6cf3e05 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts @@ -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); } diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts index 15c59a16bc1..37aa20facfa 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts @@ -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]; +}; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 29eb5c8bed2..ae183ac43c4 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -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 = { diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index f6d47e0c531..f9e6cda6ea4 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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( - `${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( - `${this.getContentFlaggingRoute()}/flag/config`, + `${this.getContentFlaggingRoute()}/flag/config${buildQueryString({team_id: teamId})}`, + {method: 'get'}, + ); + }; + + flagPost = (postId: string, reason: string, comment?: string) => { + return this.doFetch( + `${this.getContentFlaggingRoute()}/post/${postId}/flag`, + { + method: 'post', + body: JSON.stringify({reason, comment: JSON.stringify(comment)}), + }, + ); + }; + + removeFlaggedPost = (postId: string, comment?: string) => { + return this.doFetch( + `${this.getContentFlaggingRoute()}/post/${postId}/remove`, + { + method: 'put', + body: JSON.stringify({comment: JSON.stringify(comment)}), + }, + ); + }; + + keepFlaggedPost = (postId: string, comment?: string) => { + return this.doFetch( + `${this.getContentFlaggingRoute()}/post/${postId}/keep`, + { + method: 'put', + body: JSON.stringify({comment: JSON.stringify(comment)}), + }, + ); + }; + + getPostContentFlaggingFields = () => { + return this.doFetch( + `${this.getContentFlaggingRoute()}/fields`, + {method: 'get'}, + ); + }; + + getPostContentFlaggingValues = (postId: string) => { + return this.doFetch>>( + `${this.getContentFlaggingRoute()}/post/${postId}/field_values`, + {method: 'get'}, + ); + }; + + getFlaggedPost = (postId: string) => { + return this.doFetch( + `${this.getContentFlaggingRoute()}/post/${postId}`, {method: 'get'}, ); }; diff --git a/webapp/platform/types/src/content_flagging.ts b/webapp/platform/types/src/content_flagging.ts index 7fce1cac424..47fb48d5f10 100644 --- a/webapp/platform/types/src/content_flagging.ts +++ b/webapp/platform/types/src/content_flagging.ts @@ -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>; + }; +}; + +export enum ContentFlaggingStatus { + Pending = 'Pending', + Assigned = 'Assigned', + Removed = 'Removed', + Retained = 'Retained', +} diff --git a/webapp/platform/types/src/properties.ts b/webapp/platform/types/src/properties.ts index 8a8e4be3646..b0b578d4396 100644 --- a/webapp/platform/types/src/properties.ts +++ b/webapp/platform/types/src/properties.ts @@ -26,6 +26,8 @@ export type PropertyField = { delete_at: number; }; +export type NameMappedPropertyFields = {[key: PropertyField['name']]: PropertyField}; + export type PropertyValue = { id: string; target_id: string; diff --git a/webapp/platform/types/src/store.ts b/webapp/platform/types/src/store.ts index 4d59e3e40f4..f9acbf8776e 100644 --- a/webapp/platform/types/src/store.ts +++ b/webapp/platform/types/src/store.ts @@ -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; remotesByRemoteId?: Record; }; - contentFlagging: { - settings?: ContentFlaggingConfig; - }; + contentFlagging: ContentFlaggingState; }; errors: any[]; requests: {