mattermost/server/channels/app/post_acknowledgements.go
Harshil Sharma c21ef29f02
Flag post API (#33765)
* Added enable/disable setting and feature flag

* added rest of notifgication settings

* Added backend for content flagging setting and populated notification values from server side defaults

* WIP user selector

* Added common reviewers UI

* Added additonal reviewers section

* WIP

* WIP

* Team table base

* Added search in teams

* Added search in teams

* Added additional settings section

* WIP

* Inbtegrated reviewers settings

* WIP

* WIP

* Added server side validation

* cleanup

* cleanup

* [skip ci]

* Some refactoring

* type fixes

* lint fix

* test: add content flagging settings test file

* test: add comprehensive unit tests for content flagging settings

* enhanced tests

* test: add test file for content flagging additional settings

* test: add comprehensive unit tests for ContentFlaggingAdditionalSettingsSection

* Added additoonal settings test

* test: add empty test file for team reviewers section

* test: add comprehensive unit tests for TeamReviewersSection component

* test: update tests to handle async data fetching in team reviewers section

* test: add empty test file for content reviewers component

* feat: add comprehensive unit tests for ContentFlaggingContentReviewers component

* Added ContentFlaggingContentReviewersContentFlaggingContentReviewers test

* test: add notification settings test file for content flagging

* test: add comprehensive unit tests for content flagging notification settings

* Added ContentFlaggingNotificationSettingsSection tests

* test: add user profile pill test file

* test: add comprehensive unit tests for UserProfilePill component

* refactor: Replace enzyme shallow with renderWithContext in user_profile_pill tests

* Added UserProfilePill tests

* test: add empty test file for content reviewers team option

* test: add comprehensive unit tests for TeamOptionComponent

* Added TeamOptionComponent tests

* test: add empty test file for reason_option component

* test: add comprehensive unit tests for ReasonOption component

* Added ReasonOption tests

* cleanup

* Fixed i18n error

* fixed e2e test lijnt issues

* Updated test cases

* Added snaoshot

* Updated snaoshot

* lint fix

* WIP

* lint fix

* Added post flagging properties setup

* review fixes

* updated snapshot

* CI

* Added base APIs

* Fetched team status data on load and team switch

* WIP

* Review fixes

* wip

* WIP

* Removed an test, updated comment

* CI

* Added tests

* Added tests

* Lint fix

* Added API specs

* Fixed types

* CI fixes

* API tests

* lint fixes

* Set env variable so API routes are regiustered

* Test update

* term renaming and disabling API tests on MySQL

* typo

* Updated store type definition

* Minor tweaks

* Added tests

* Removed error in app startup when content flaghging setup fails

* Updated sync condition:

* Flag message modal basE

* added post preview

* displaying options

* Adde comment input

* Updated tests and docs

* finction rename

* WIP

* Updated tests

* refactor

* lint fix

* MOved to data migration

* lint fix

* CI

* added new migration mocks

* Used setup for tests

* some comment

* Removed unnecesseery nil check

* Form validation

* WIP tests

* WIP tests

* WIP tests

* fix: mock content flagging config selector with correct reasons format

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

* fix: add mock for getContentFlaggingConfig in flag post modal test

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

* Updated error code order in API docs

* removed empty files

* Added tests

* lint fixes

* minor tweak

* lint fix

* type fix

* fixed test

* nit

* test enhancements

* API WIP

* API WIP

* creating values

* creating content flagging channel and properties

* Able to save properties

* Added another property field

* WIP

* WIP

* Added validations

* Added data validations and hidden post if confifgured to

* lint fixes

* Added API spec

* Added some tests

* Added tests for getContentReviewBot

* test: add comprehensive tests for getContentReviewChannels function

* Added more app layer tests

* Added TestCanFlagPost

* test: Add comprehensive tests for FlagPost function

* Added all app layer tests

* Removed a file that was reamoved downstream

* test: add content flagging test file

* test: add comprehensive tests for FlagContentRequest.IsValid method

* Added model tests

* test: add comprehensive tests for SqlPropertyValueStore.CreateMany

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

* Added API tests

* linter fix

* WIP

* sent post flagging confirmation message

* fixed i18n nissues

* fixed i18n nissues

* CI

* Updated test

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

* removed cached group ID

* removed debug log

* review fixes

* Used correct ot name

* CI

* Updated mobile text

* Handled JSON error

* fixerdf i18n

* CI

* Integrate flag post api (#33798)

* WIP

* WIP

* Added API call

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

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

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

* Updated tests

* lint fix

* CI

* Updated to allow special characters in comments

* Handled empty comment

* Used finally

* CI

* Fixed test

* Spillage card integration (#33832)

* Created getContentFlaggingFields API

* created getPostPropertyValues API

* WIP

* Created useContentFlaggingFields hook

* WIP

* WIP

* Added option to retain data for reviewers

* Displayed deleted post's preview

* DIsplayed all properties

* Adding field name i18n

* WIP - managing i18n able texts

* Finished displaying all fields

* Manual cleanup

* lint fixes

* team role filter logic fix

* Fixed tests

* created new API to fetch flagged posts

* lint fix

* Added new client methods

* test: add comprehensive tests for content flagging APIs

* Added new API tests

* fixed openapi spec

* Fixed DataSpillageReport tests

* Fixed PostMarkdown test

* Fixed PostPreviewPropertyRenderer test

* Added metadata to card renderer

* test fixes

* Added no comment placeholder

* Fixed test

* refactor: improve test mocking for data spillage report component

* test mock updates

* Updated reducer

* not resetting mocks

* WIP

* review fixes

* CI

* Fixed

* fixes

* Content flagging actions implementation (#33852)

* Added view detail button

* Created RemoveFlaggedMessageConfirmationModal modal

* Added key and remove flag request modal

* IMplemented delete flagged post

* Handled edge cases of deleting flagged post

* keep message

* UI integration

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

* Added error handling in keep/remove forms

* i18n fixes

* Updated OpenAPI specs

* fixed types

* fixed types

* refactoring

* Fixed tests

* review fixes

* Added new property translations

* Improved test

* fixed test

* CI

* fixes

* CI

* fixed a test

* CI

---------

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
2025-10-02 20:24:29 +05:30

353 lines
13 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
func (a *App) SaveAcknowledgementForPost(rctx request.CTX, postID, userID string) (*model.PostAcknowledgement, *model.AppError) {
return a.saveAcknowledgementForPostWithPost(rctx, nil, userID, postID)
}
func (a *App) saveAcknowledgementForPostWithPost(rctx request.CTX, post *model.Post, userID string, postID ...string) (*model.PostAcknowledgement, *model.AppError) {
if post == nil {
if len(postID) == 0 {
return nil, model.NewAppError("SaveAcknowledgementForPost", "app.acknowledgement.save.missing_post.app_error", nil, "", http.StatusBadRequest)
}
var err *model.AppError
post, err = a.GetSinglePost(rctx, postID[0], false)
if err != nil {
return nil, err
}
}
channel, err := a.GetChannel(rctx, post.ChannelId)
if err != nil {
return nil, err
}
if channel.DeleteAt > 0 {
return nil, model.NewAppError("SaveAcknowledgementForPost", "api.acknowledgement.save.archived_channel.app_error", nil, "", http.StatusForbidden)
}
// Pre-populate the ChannelId to save a DB call in store
acknowledgement := &model.PostAcknowledgement{
PostId: post.Id,
UserId: userID,
ChannelId: post.ChannelId,
}
savedAck, nErr := a.Srv().Store().PostAcknowledgement().SaveWithModel(acknowledgement)
if nErr != nil {
var appErr *model.AppError
switch {
case errors.As(nErr, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("SaveAcknowledgementForPost", "app.acknowledgement.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if appErr := a.ResolvePersistentNotification(rctx, post, userID); appErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonResolvePersistentNotificationError, model.NotificationNoPlatform)
a.Log().LogM(mlog.MlvlNotificationError, "Error resolving persistent notification",
mlog.String("sender_id", userID),
mlog.String("post_id", post.RootId),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonResolvePersistentNotificationError),
mlog.Err(appErr),
)
return nil, appErr
}
// The post is always modified since the UpdateAt always changes
a.Srv().Store().Post().InvalidateLastPostTimeCache(channel.Id)
a.sendAcknowledgementEvent(rctx, model.WebsocketEventAcknowledgementAdded, savedAck, post)
// Trigger post updated event to ensure shared channel sync
a.sendPostUpdateEvent(rctx, post)
return savedAck, nil
}
func (a *App) DeleteAcknowledgementForPost(rctx request.CTX, postID, userID string) *model.AppError {
return a.deleteAcknowledgementForPostWithPost(rctx, nil, userID, postID)
}
func (a *App) deleteAcknowledgementForPostWithPost(rctx request.CTX, post *model.Post, userID string, postID ...string) *model.AppError {
if post == nil {
if len(postID) == 0 {
return model.NewAppError("DeleteAcknowledgementForPost", "app.acknowledgement.delete.missing_post.app_error", nil, "", http.StatusBadRequest)
}
var err *model.AppError
post, err = a.GetSinglePost(rctx, postID[0], false)
if err != nil {
return err
}
}
channel, err := a.GetChannel(rctx, post.ChannelId)
if err != nil {
return err
}
if channel.DeleteAt > 0 {
return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.archived_channel.app_error", nil, "", http.StatusForbidden)
}
oldAck, nErr := a.Srv().Store().PostAcknowledgement().Get(post.Id, userID)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr)
default:
return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if model.GetMillis()-oldAck.AcknowledgedAt > 5*60*1000 {
return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.deadline.app_error", nil, "", http.StatusForbidden)
}
nErr = a.Srv().Store().PostAcknowledgement().Delete(oldAck)
if nErr != nil {
return model.NewAppError("DeleteAcknowledgementForPost", "app.acknowledgement.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
// The post is always modified since the UpdateAt always changes
a.Srv().Store().Post().InvalidateLastPostTimeCache(channel.Id)
a.sendAcknowledgementEvent(rctx, model.WebsocketEventAcknowledgementRemoved, oldAck, post)
// Trigger post updated event to ensure shared channel sync
a.sendPostUpdateEvent(rctx, post)
return nil
}
func (a *App) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) {
acknowledgements, nErr := a.Srv().Store().PostAcknowledgement().GetForPost(postID)
if nErr != nil {
return nil, model.NewAppError("GetAcknowledgementsForPost", "app.acknowledgement.getforpost.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
return acknowledgements, nil
}
func (a *App) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) {
acknowledgements, err := a.Srv().Store().PostAcknowledgement().GetForPosts(postList.Order)
if err != nil {
return nil, model.NewAppError("GetPostAcknowledgementsForPostList", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
acknowledgementsMap := make(map[string][]*model.PostAcknowledgement)
for _, ack := range acknowledgements {
acknowledgementsMap[ack.PostId] = append(acknowledgementsMap[ack.PostId], ack)
}
return acknowledgementsMap, nil
}
// SaveAcknowledgementsForPost saves multiple acknowledgements for a post in a single operation.
func (a *App) SaveAcknowledgementsForPost(rctx request.CTX, postID string, userIDs []string) ([]*model.PostAcknowledgement, *model.AppError) {
if len(userIDs) == 0 {
return []*model.PostAcknowledgement{}, nil
}
post, err := a.GetSinglePost(rctx, postID, false)
if err != nil {
return nil, err
}
channel, err := a.GetChannel(rctx, post.ChannelId)
if err != nil {
return nil, err
}
if channel.DeleteAt > 0 {
return nil, model.NewAppError("SaveAcknowledgementsForPost", "api.acknowledgement.save.archived_channel.app_error", nil, "", http.StatusForbidden)
}
// Create acknowledgements with current timestamp
acknowledgedAt := model.GetMillis()
var acknowledgements []*model.PostAcknowledgement
for _, userID := range userIDs {
acknowledgements = append(acknowledgements, &model.PostAcknowledgement{
PostId: post.Id,
UserId: userID,
ChannelId: post.ChannelId,
AcknowledgedAt: acknowledgedAt,
})
}
// Save all acknowledgements
savedAcks, nErr := a.Srv().Store().PostAcknowledgement().BatchSave(acknowledgements)
if nErr != nil {
var appErr *model.AppError
switch {
case errors.As(nErr, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("SaveAcknowledgementsForPost", "app.acknowledgement.batch_save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
// Resolve persistent notifications for each user
for _, userID := range userIDs {
if appErr := a.ResolvePersistentNotification(rctx, post, userID); appErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonResolvePersistentNotificationError, model.NotificationNoPlatform)
a.Log().LogM(mlog.MlvlNotificationError, "Error resolving persistent notification",
mlog.String("sender_id", userID),
mlog.String("post_id", post.RootId),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonResolvePersistentNotificationError),
mlog.Err(appErr),
)
// We continue processing other acknowledgements even if one fails
}
}
// The post is always modified since the UpdateAt always changes
a.Srv().Store().Post().InvalidateLastPostTimeCache(channel.Id)
// Send WebSocket events for each acknowledgement
for _, ack := range savedAcks {
a.sendAcknowledgementEvent(rctx, model.WebsocketEventAcknowledgementAdded, ack, post)
}
// Trigger post updated event to ensure shared channel sync
a.sendPostUpdateEvent(rctx, post)
return savedAcks, nil
}
func (a *App) sendAcknowledgementEvent(rctx request.CTX, event model.WebsocketEventType, acknowledgement *model.PostAcknowledgement, post *model.Post) {
// send out that a acknowledgement has been added/removed
message := model.NewWebSocketEvent(event, "", post.ChannelId, "", nil, "")
acknowledgementJSON, err := json.Marshal(acknowledgement)
if err != nil {
rctx.Logger().Warn("Failed to encode acknowledgement to JSON", mlog.Err(err))
}
message.Add("acknowledgement", string(acknowledgementJSON))
a.Publish(message)
}
func (a *App) SaveAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, *model.AppError) {
// Get the post to verify it exists and get the channel
post, err := a.GetSinglePost(rctx, acknowledgement.PostId, false)
if err != nil {
return nil, err
}
channel, err := a.GetChannel(rctx, post.ChannelId)
if err != nil {
return nil, err
}
if channel.DeleteAt > 0 {
return nil, model.NewAppError("SaveAcknowledgementForPostWithModel", "api.acknowledgement.save.archived_channel.app_error", nil, "", http.StatusForbidden)
}
// Make sure ChannelId is set
if acknowledgement.ChannelId == "" {
acknowledgement.ChannelId = post.ChannelId
}
savedAck, nErr := a.Srv().Store().PostAcknowledgement().SaveWithModel(acknowledgement)
if nErr != nil {
var appErr *model.AppError
switch {
case errors.As(nErr, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("SaveAcknowledgementForPostWithModel", "app.acknowledgement.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if appErr := a.ResolvePersistentNotification(rctx, post, acknowledgement.UserId); appErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonResolvePersistentNotificationError, model.NotificationNoPlatform)
a.Log().LogM(mlog.MlvlNotificationError, "Error resolving persistent notification",
mlog.String("sender_id", acknowledgement.UserId),
mlog.String("post_id", post.RootId),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonResolvePersistentNotificationError),
mlog.Err(appErr),
)
return nil, appErr
}
// The post is always modified since the UpdateAt always changes
a.Srv().Store().Post().InvalidateLastPostTimeCache(channel.Id)
a.sendAcknowledgementEvent(rctx, model.WebsocketEventAcknowledgementAdded, savedAck, post)
// Trigger post updated event to ensure shared channel sync
a.sendPostUpdateEvent(rctx, post)
return savedAck, nil
}
func (a *App) DeleteAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) *model.AppError {
// Get the post to verify it exists and get the channel
post, err := a.GetSinglePost(rctx, acknowledgement.PostId, false)
if err != nil {
return err
}
channel, err := a.GetChannel(rctx, post.ChannelId)
if err != nil {
return err
}
if channel.DeleteAt > 0 {
return model.NewAppError("DeleteAcknowledgementForPostWithModel", "api.acknowledgement.delete.archived_channel.app_error", nil, "", http.StatusForbidden)
}
nErr := a.Srv().Store().PostAcknowledgement().Delete(acknowledgement)
if nErr != nil {
return model.NewAppError("DeleteAcknowledgementForPostWithModel", "app.acknowledgement.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
// The post is always modified since the UpdateAt always changes
a.Srv().Store().Post().InvalidateLastPostTimeCache(channel.Id)
a.sendAcknowledgementEvent(rctx, model.WebsocketEventAcknowledgementRemoved, acknowledgement, post)
// Trigger post updated event to ensure shared channel sync
a.sendPostUpdateEvent(rctx, post)
return nil
}
func (a *App) sendPostUpdateEvent(rctx request.CTX, post *model.Post) {
if post == nil {
rctx.Logger().Warn("sendPostUpdateEvent called with nil post")
return
}
// Send a post edited event to trigger shared channel sync
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, "", nil, "")
// Prepare the post with metadata for the event
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))
}
}