Files
team/server/channels/app/post_persistent_notification.go
593c4141b4 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

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

* 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: Hanzo Dev <[email protected]>
2025-10-02 20:24:29 +05:30

394 lines
14 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"maps"
"net/http"
"time"
"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"
"github.com/pkg/errors"
)
// ResolvePersistentNotification stops the persistent notifications, if a loggedInUserID(except the post owner) reacts, reply or ack on the post.
// Post-owner can only delete the original post to stop the notifications.
func (a *App) ResolvePersistentNotification(rctx request.CTX, post *model.Post, loggedInUserID string) *model.AppError {
// Ignore the post owner's actions to their own post
if loggedInUserID == post.UserId {
return nil
}
if !a.IsPersistentNotificationsEnabled() {
return nil
}
_, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
// Either the notification post is already deleted or was never a notification post
return nil
default:
return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if !*a.Config().ServiceSettings.AllowPersistentNotificationsForGuests {
user, nErr := a.Srv().Store().User().Get(context.Background(), loggedInUserID)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return model.NewAppError("ResolvePersistentNotification", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
default:
return model.NewAppError("ResolvePersistentNotification", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if user.IsGuest() {
return nil
}
}
stopNotifications := false
if err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error {
if mentions.isUserMentioned(loggedInUserID) {
stopNotifications = true
}
return nil
}); err != nil {
return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Only mentioned users can stop the notifications
if !stopNotifications {
return nil
}
if err := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); err != nil {
return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// DeletePersistentNotification stops the persistent notifications.
func (a *App) DeletePersistentNotification(rctx request.CTX, post *model.Post) *model.AppError {
if !a.IsPersistentNotificationsEnabled() {
return nil
}
_, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
// Either the notification post is already deleted or was never a notification post
return nil
default:
return model.NewAppError("DeletePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if err := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); err != nil {
return model.NewAppError("DeletePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) SendPersistentNotifications() error {
notificationInterval := time.Duration(*a.Config().ServiceSettings.PersistentNotificationIntervalMinutes) * time.Minute
notificationMaxCount := int16(*a.Config().ServiceSettings.PersistentNotificationMaxCount)
// fetch posts for which the "notificationInterval duration" has passed
maxTime := time.Now().Add(-notificationInterval).UnixMilli()
// Pagination loop
for {
notificationPosts, err := a.Srv().Store().PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{
MaxTime: maxTime,
MaxSentCount: notificationMaxCount,
PerPage: 500,
})
if err != nil {
return errors.Wrap(err, "failed to get posts for persistent notifications")
}
// No posts left to send persistent notifications
if len(notificationPosts) == 0 {
break
}
postIds := make([]string, 0, len(notificationPosts))
for _, p := range notificationPosts {
postIds = append(postIds, p.PostId)
}
posts, err := a.Srv().Store().Post().GetPostsByIds(postIds)
if err != nil {
return errors.Wrap(err, "failed to get posts by IDs")
}
// Send notifications
if err := a.forEachPersistentNotificationPost(posts, a.sendPersistentNotifications); err != nil {
return err
}
if err := a.Srv().Store().PostPersistentNotification().UpdateLastActivity(postIds); err != nil {
return errors.Wrapf(err, "failed to update lastActivity for notifications: %v", postIds)
}
}
if err := a.Srv().Store().PostPersistentNotification().DeleteExpired(notificationMaxCount); err != nil {
return errors.Wrap(err, "failed to delete expired notifications")
}
return nil
}
func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(post *model.Post, channel *model.Channel, team *model.Team, mentions *MentionResults, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error) error {
channelsMap, teamsMap, err := a.channelTeamMapsForPosts(posts)
if err != nil {
return err
}
channelGroupMap, channelProfileMap, channelKeywords, channelNotifyProps, err := a.persistentNotificationsAuxiliaryData(channelsMap, teamsMap)
if err != nil {
return err
}
for _, post := range posts {
channel := channelsMap[post.ChannelId]
team := teamsMap[channel.TeamId]
// GMs and DMs don't belong to any team
if channel.IsGroupOrDirect() {
team = &model.Team{}
}
profileMap := channelProfileMap[channel.Id]
mentions := &MentionResults{}
// In DMs, only the "other" user can be mentioned
if channel.Type == model.ChannelTypeDirect {
otherUserId := channel.GetOtherUserIdForDM(post.UserId)
if _, ok := profileMap[otherUserId]; ok {
mentions.addMention(otherUserId, DMMention)
}
} else {
keywords := channelKeywords[channel.Id]
keywords.AddGroupsMap(channelGroupMap[channel.Id])
mentions = getExplicitMentions(post, keywords)
for groupID := range mentions.GroupMentions {
group := channelGroupMap[channel.Id][groupID]
_, err := a.insertGroupMentions(post.UserId, group, channel, profileMap, mentions)
if err != nil {
return errors.Wrapf(err, "failed to include mentions from group - %s for channel - %s", group.Id, channel.Id)
}
}
}
if err := fn(post, channel, team, mentions, profileMap, channelNotifyProps); err != nil {
return err
}
}
return nil
}
func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model.Channel, teamsMap map[string]*model.Team) (map[string]map[string]*model.Group, map[string]model.UserMap, map[string]MentionKeywords, map[string]map[string]model.StringMap, error) {
channelGroupMap := make(map[string]map[string]*model.Group, len(channelsMap))
channelProfileMap := make(map[string]model.UserMap, len(channelsMap))
channelKeywords := make(map[string]MentionKeywords, len(channelsMap))
channelNotifyProps := make(map[string]map[string]model.StringMap, len(channelsMap))
for _, c := range channelsMap {
// In DM, notifications can't be send to any 3rd person.
if c.Type != model.ChannelTypeDirect {
groups, err := a.getGroupsAllowedForReferenceInChannel(c, teamsMap[c.TeamId])
if err != nil {
return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id)
}
channelGroupMap[c.Id] = make(map[string]*model.Group, len(groups))
maps.Copy(channelGroupMap[c.Id], groups)
props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(c.Id, true)
if err != nil {
return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id)
}
channelNotifyProps[c.Id] = props
}
profileMap, err := a.Srv().Store().User().GetAllProfilesInChannel(context.Background(), c.Id, true)
if err != nil {
return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id)
}
channelKeywords[c.Id] = make(MentionKeywords, len(profileMap))
validProfileMap := make(map[string]*model.User, len(profileMap))
for userID, user := range profileMap {
if user.IsBot {
continue
}
validProfileMap[userID] = user
channelKeywords[c.Id].AddUserKeyword(userID, "@"+user.Username)
}
channelProfileMap[c.Id] = validProfileMap
}
return channelGroupMap, channelProfileMap, channelKeywords, channelNotifyProps, nil
}
func (a *App) channelTeamMapsForPosts(posts []*model.Post) (map[string]*model.Channel, map[string]*model.Team, error) {
channelIds := make(model.StringSet)
for _, p := range posts {
channelIds.Add(p.ChannelId)
}
channels, err := a.Srv().Store().Channel().GetChannelsByIds(channelIds.Val(), false)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to get teams by IDs")
}
channelsMap := make(map[string]*model.Channel, len(channels))
for _, c := range channels {
channelsMap[c.Id] = c
}
teamIds := make(model.StringSet)
for _, c := range channels {
if c.TeamId != "" {
teamIds.Add(c.TeamId)
}
}
teams := make([]*model.Team, 0, len(teamIds))
if len(teamIds) > 0 {
teams, err = a.Srv().Store().Team().GetMany(teamIds.Val())
if err != nil {
return nil, nil, errors.Wrap(err, "failed to get teams by IDs")
}
}
teamsMap := make(map[string]*model.Team, len(teams))
for _, t := range teams {
teamsMap[t.Id] = t
}
return channelsMap, teamsMap, nil
}
func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Channel, team *model.Team, mentions *MentionResults, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error {
mentionedUsersList := make(model.StringArray, 0, len(mentions.Mentions))
for id, v := range mentions.Mentions {
// Don't send notification to post owner nor GM mentions
if id != post.UserId && v > GMMention {
mentionedUsersList = append(mentionedUsersList, id)
}
}
sender := profileMap[post.UserId]
notification := &PostNotification{
Post: post,
Channel: channel,
ProfileMap: profileMap,
Sender: sender,
}
if int64(len(mentionedUsersList)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
return errors.Errorf("mentioned users: %d are more than allowed users: %d", len(mentionedUsersList), *a.Config().TeamSettings.MaxNotificationsPerChannel)
}
if a.canSendPushNotifications() {
for _, userID := range mentionedUsersList {
user := profileMap[userID]
if user == nil {
continue
}
rctx := request.EmptyContext(a.Log().With(
mlog.String("receiver_id", userID),
))
status, err := a.GetStatus(userID)
if err != nil {
mlog.Warn("Unable to fetch online status", mlog.Err(err))
status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
isGM := channel.Type == model.ChannelTypeGroup
if a.ShouldSendPushNotification(rctx, profileMap[userID], channelNotifyProps[channel.Id][userID], true, status, post, isGM) {
a.sendPushNotification(
notification,
user,
true,
false,
"",
)
}
}
}
desktopUsers := make([]string, 0, len(mentionedUsersList))
for _, id := range mentionedUsersList {
user := profileMap[id]
if user == nil {
continue
}
if user.NotifyProps[model.DesktopNotifyProp] != model.UserNotifyNone && a.persistentNotificationsAllowedForStatus(id) {
desktopUsers = append(desktopUsers, id)
}
}
if len(desktopUsers) != 0 {
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")
}
for _, u := range desktopUsers {
message := model.NewWebSocketEvent(model.WebsocketEventPersistentNotificationTriggered, team.Id, post.ChannelId, u, nil, "")
message.Add("post", postJSON)
message.Add("channel_type", channel.Type)
message.Add("channel_display_name", notification.GetChannelName(model.ShowUsername, ""))
message.Add("channel_name", channel.Name)
message.Add("sender_name", notification.GetSenderName(model.ShowUsername, *a.Config().ServiceSettings.EnablePostUsernameOverride))
message.Add("team_id", team.Id)
if len(post.FileIds) != 0 {
message.Add("otherFile", "true")
infos, err := a.Srv().Store().FileInfo().GetForPost(post.Id, false, false, true)
if err != nil {
mlog.Warn("Unable to get fileInfo for push notifications.", mlog.String("post_id", post.Id), mlog.Err(err))
}
for _, info := range infos {
if info.IsImage() {
message.Add("image", "true")
break
}
}
}
message.Add("mentions", model.ArrayToJSON(desktopUsers))
a.Publish(message)
}
}
return nil
}
func (a *App) persistentNotificationsAllowedForStatus(userID string) bool {
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(userID); err != nil {
status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
return status.Status != model.StatusDnd && status.Status != model.StatusOutOfOffice
}
func (a *App) IsPersistentNotificationsEnabled() bool {
return a.IsPostPriorityEnabled() && *a.Config().ServiceSettings.AllowPersistentNotifications
}