diff --git a/api/v4/source/content_flagging.yaml b/api/v4/source/content_flagging.yaml index 5162f7b13dab..e672a4e60c12 100644 --- a/api/v4/source/content_flagging.yaml +++ b/api/v4/source/content_flagging.yaml @@ -441,3 +441,44 @@ 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}/exposure_report: + post: + summary: Generate and download a post exposure report + description: | + Generates a CSV report listing the users who may have been exposed to a flagged post, derived from the channel membership history between the post's creation and it being flagged, and from each member's channel read state. All other content reviewers of the post's team are notified that an exposure report has been generated. + The user must be a content reviewer of the team to which the post belongs to, and the post must be flagged. The report remains available after the review is closed, in any status. + Exposure reports are not available for direct or group message channels. + 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 flagged post to generate the exposure report for + operationId: GenerateCFPostExposureReport + responses: + '200': + description: Report generated successfully. The response body is a CSV file. Metadata and the reporting window are emitted as `#`-prefixed comment lines above the header row. + headers: + Content-Disposition: + schema: + type: string + description: Specifies the suggested filename for the downloaded file (e.g. `attachment; filename="post-exposure-{post_id}-{timestamp}.csv"`). + content: + text/csv: + schema: + type: string + '400': + description: Bad request - Invalid post ID, or the post is in a direct or group message channel. + '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 post is not flagged. + '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/AGENTS.md b/server/AGENTS.md index 9e51dfa9060c..88f179d54423 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -4,4 +4,5 @@ Never run `go mod tidy` directly. Always run `make modules-tidy` instead — it After editing `i18n/en.json`, always run `make i18n-extract` — it regenerates the file with strings in the required order. +Prefer request-scoped loggers when logging from request paths. If a method needs to log and does not have access to the request logger, it is reasonable to add `request.CTX` to the method signature when the caller can provide it. In the store layer, do not use `context.Context` in store method signatures. Use `request.CTX` and only call `rctx.Context()` inside internals that require a standard `context.Context`. diff --git a/server/channels/api4/content_flagging.go b/server/channels/api4/content_flagging.go index dc25481679cb..b015151355ef 100644 --- a/server/channels/api4/content_flagging.go +++ b/server/channels/api4/content_flagging.go @@ -29,6 +29,7 @@ func (api *API) InitContentFlagging() { api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(contentFlaggingRequired(removeFlaggedPost))).Methods(http.MethodPut) api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(contentFlaggingRequired(keepFlaggedPost))).Methods(http.MethodPut) api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/report", api.APISessionRequired(contentFlaggingRequired(generateFlaggedPostReport))).Methods(http.MethodPost) + api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/exposure_report", api.APISessionRequired(contentFlaggingRequired(generatePostExposureReport))).Methods(http.MethodPost) api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/reviewers/search", api.APISessionRequired(contentFlaggingRequired(searchReviewers))).Methods(http.MethodGet) api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/assign/{content_reviewer_id:[A-Za-z0-9]+}", api.APISessionRequired(contentFlaggingRequired(assignFlaggedPostReviewer))).Methods(http.MethodPost) diff --git a/server/channels/api4/content_flagging_report.go b/server/channels/api4/content_flagging_report.go index e9634ecc904f..e9337a73abb9 100644 --- a/server/channels/api4/content_flagging_report.go +++ b/server/channels/api4/content_flagging_report.go @@ -4,6 +4,7 @@ package api4 import ( + "bytes" "encoding/json" "errors" "fmt" @@ -106,3 +107,68 @@ func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Reques auditRec.Success() } + +func generatePostExposureReport(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + c.RequirePostId() + if c.Err != nil { + return + } + + postId := c.Params.PostId + userId := c.AppContext.Session().UserId + + auditRec := c.MakeAuditRecord(model.AuditEventGeneratePostExposureReport, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + model.AddEventParameterToAuditRec(auditRec, "flaggedPostId", 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 + } + + requireTeamContentReviewer(c, userId, channel.TeamId) + if c.Err != nil { + return + } + + requireFlaggedPost(c, postId) + if c.Err != nil { + return + } + + report, appErr := c.App.ComputePostExposure(c.AppContext, postId) + if appErr != nil { + c.Err = appErr + return + } + + var buf bytes.Buffer + if err := app.WritePostExposureCSV(&buf, report, c.AppContext.GetT()); err != nil { + c.Err = model.NewAppError("generatePostExposureReport", "api.data_spillage.exposure.write.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + c.App.NotifyReviewersOfPostExposureReportGeneration(c.AppContext, postId, userId) + + filename := fmt.Sprintf("post-exposure-%s-%d.csv", postId, model.GetMillis()) + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + if _, err := w.Write(buf.Bytes()); err != nil { + c.Logger.Warn("Failed to write post exposure report response", mlog.String("post_id", postId), mlog.Err(err)) + return + } + + auditRec.Success() +} diff --git a/server/channels/api4/content_flagging_report_test.go b/server/channels/api4/content_flagging_report_test.go index 32cfd4e990dd..775f9e4679a4 100644 --- a/server/channels/api4/content_flagging_report_test.go +++ b/server/channels/api4/content_flagging_report_test.go @@ -7,12 +7,14 @@ import ( "archive/zip" "bytes" "context" + "encoding/csv" "io" "net/http" "testing" "github.com/goccy/go-yaml" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/stretchr/testify/require" ) @@ -230,3 +232,209 @@ func TestGenerateFlaggedPostReport(t *testing.T) { require.Empty(t, report) }) } + +// parseExposureCSV skips the "#"-prefixed metadata preamble and returns the remaining records. +func parseExposureCSV(t *testing.T, b []byte) [][]string { + t.Helper() + + r := csv.NewReader(bytes.NewReader(b)) + r.Comment = '#' + records, err := r.ReadAll() + require.NoError(t, err) + return records +} + +func TestGeneratePostExposureReport(t *testing.T) { + th := Setup(t).InitBasic(t) + + client := th.Client + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + t.Run("Should return 501 when feature is disabled", func(t *testing.T) { + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false) + config.ContentFlaggingSettings.SetDefaults() + }) + + post := th.CreatePost(t) + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + require.Empty(t, report) + }) + + t.Run("Should return 400 when post ID is invalid", func(t *testing.T) { + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + report, resp, err := client.GeneratePostExposureReport(context.Background(), "invalid") + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.Empty(t, report) + }) + + t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) { + appErr := setNonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + require.Empty(t, report) + }) + + t.Run("Should return 404 when post is not flagged", func(t *testing.T) { + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.Error(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.Empty(t, report) + }) + + t.Run("Should successfully generate report when the post has already been retained", func(t *testing.T) { + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + resp, err := client.KeepFlaggedPost(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "looks fine"}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotEmpty(t, report) + + require.Contains(t, resp.Header.Get("Content-Type"), "text/csv") + require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id) + }) + + t.Run("Should successfully generate report when the post has already been removed", func(t *testing.T) { + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "confirmed spillage"}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Removal scrubs the post's content but retains a stub row, and it never deletes + // the reporting_time property, so the exposure window is still computable. + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotEmpty(t, report) + + require.Contains(t, resp.Header.Get("Content-Type"), "text/csv") + require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id) + }) + + t.Run("Should successfully generate report for a common reviewer", func(t *testing.T) { + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotEmpty(t, report) + + require.Contains(t, resp.Header.Get("Content-Type"), "text/csv") + require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id) + }) + + t.Run("Should successfully generate report when user is a team reviewer", func(t *testing.T) { + appErr := setBasicTeamReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotEmpty(t, report) + }) + + t.Run("Should generate report for both the assignee and a non-assignee reviewer", func(t *testing.T) { + appErr := setBasicCommonReviewerConfig(th, th.BasicUser2.Id) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser2.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // BasicUser is a reviewer but not the assignee. + report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotEmpty(t, report) + + // BasicUser2 is the assignee, and is therefore also a reviewer. + assigneeClient := th.CreateClient() + th.LoginBasic2WithClient(t, assigneeClient) + + report, resp, err = assigneeClient.GeneratePostExposureReport(context.Background(), post.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotEmpty(t, report) + }) + + t.Run("Should return a parseable CSV listing the channel members", func(t *testing.T) { + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + report, _, err := client.GeneratePostExposureReport(context.Background(), post.Id) + require.NoError(t, err) + + body := string(report) + require.Contains(t, body, "# Post ID: "+post.Id) + require.Contains(t, body, "# Report version: "+model.PostExposureReportVersion) + + records := parseExposureCSV(t, report) + require.NotEmpty(t, records) + require.Equal(t, model.PostExposureReportCSVHeader(i18n.GetUserTranslations("en")), records[0]) + + var found bool + for _, record := range records[1:] { + if record[0] == th.BasicUser.Id { + found = true + require.Equal(t, th.BasicUser.Username, record[1]) + } + } + require.True(t, found, "the post author is a channel member and must appear in the report") + }) + + t.Run("Should return 403 in team reviewer mode for a user not on the team's reviewer list", func(t *testing.T) { + appErr := setBasicTeamReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + otherClient := th.CreateClient() + th.LoginBasic2WithClient(t, otherClient) + + report, resp, err := otherClient.GeneratePostExposureReport(context.Background(), post.Id) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + require.Empty(t, report) + }) +} diff --git a/server/channels/api4/content_flagging_test.go b/server/channels/api4/content_flagging_test.go index 36b94fe87087..41da6e42b22b 100644 --- a/server/channels/api4/content_flagging_test.go +++ b/server/channels/api4/content_flagging_test.go @@ -15,7 +15,9 @@ import ( "github.com/stretchr/testify/require" ) -func setBasicCommonReviewerConfig(th *TestHelper) *model.AppError { +func setBasicCommonReviewerConfig(th *TestHelper, extraReviewerIds ...string) *model.AppError { + ids := []string{th.BasicUser.Id} + ids = append(ids, extraReviewerIds...) config := model.ContentFlaggingSettingsRequest{ ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ EnableContentFlagging: new(true), @@ -25,7 +27,7 @@ func setBasicCommonReviewerConfig(th *TestHelper) *model.AppError { CommonReviewers: new(true), }, ReviewerIDsSettings: model.ReviewerIDsSettings{ - CommonReviewerIds: []string{th.BasicUser.Id}, + CommonReviewerIds: ids, }, }, } diff --git a/server/channels/api4/scheduled_post.go b/server/channels/api4/scheduled_post.go index 0b8e77846e13..35ebf93e4140 100644 --- a/server/channels/api4/scheduled_post.go +++ b/server/channels/api4/scheduled_post.go @@ -209,7 +209,7 @@ func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost) userId := c.AppContext.Session().UserId - existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPost.Id) + existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(c.AppContext, scheduledPost.Id) if err != nil { c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err) return @@ -283,7 +283,7 @@ func deleteScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { userId := c.AppContext.Session().UserId - existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPostId) + existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(c.AppContext, scheduledPostId) if err != nil { c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err) return diff --git a/server/channels/api4/scheduled_post_test.go b/server/channels/api4/scheduled_post_test.go index e88ac6659350..7c0b2870c091 100644 --- a/server/channels/api4/scheduled_post_test.go +++ b/server/channels/api4/scheduled_post_test.go @@ -52,7 +52,7 @@ func TestUpdateScheduledPost(t *testing.T) { // Switch back to original user and verify the post wasn't modified th.LoginBasic(t) - fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.NotNil(t, fetchedPost) require.Equal(t, originalMessage, fetchedPost.Message) @@ -77,7 +77,7 @@ func TestUpdateScheduledPost(t *testing.T) { createdScheduledPost.ErrorCode = model.ScheduledPostErrorUnableToSend createdScheduledPost.ProcessedAt = model.GetMillis() - require.NoError(t, th.App.Srv().Store().ScheduledPost().UpdatedScheduledPost(createdScheduledPost)) + require.NoError(t, th.App.Srv().Store().ScheduledPost().UpdatedScheduledPost(th.Context, createdScheduledPost)) createdScheduledPost.ScheduledAt = model.GetMillis() + 300000 createdScheduledPost.RepeatTimezone = "America/New_York" @@ -90,7 +90,7 @@ func TestUpdateScheduledPost(t *testing.T) { require.Equal(t, model.ScheduledPostRepeatTypeWeekly, updatedScheduledPost.RepeatType) require.Equal(t, "America/New_York", updatedScheduledPost.RepeatTimezone) - fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.Equal(t, model.ScheduledPostRepeatTypeWeekly, fetchedPost.RepeatType) require.Equal(t, "America/New_York", fetchedPost.RepeatTimezone) @@ -134,7 +134,7 @@ func TestUpdateScheduledPost(t *testing.T) { require.Equal(t, model.ScheduledPostRepeatTypeWeekly, updatedScheduledPost.RepeatType) require.Equal(t, "America/New_York", updatedScheduledPost.RepeatTimezone) - fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.Equal(t, "rescheduled by an old client", fetchedPost.Message) require.Equal(t, model.ScheduledPostRepeatTypeWeekly, fetchedPost.RepeatType) @@ -166,7 +166,7 @@ func TestUpdateScheduledPost(t *testing.T) { require.Empty(t, updatedScheduledPost.RepeatType) require.Empty(t, updatedScheduledPost.RepeatTimezone) - fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.Empty(t, fetchedPost.RepeatType) require.Empty(t, fetchedPost.RepeatTimezone) @@ -203,7 +203,7 @@ func TestDeleteScheduledPost(t *testing.T) { // Switch back to original user and verify the post wasn't deleted th.LoginBasic(t) - fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.NotNil(t, fetchedPost) require.Equal(t, createdScheduledPost.Id, fetchedPost.Id) @@ -413,7 +413,7 @@ func TestScheduledPostRecurringFeatureFlag(t *testing.T) { CheckBadRequestStatus(t, resp) CheckErrorID(t, err, "app.scheduled_post.recurring_disabled.app_error") - fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(created.Id) + fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(th.Context, created.Id) require.NoError(t, storeErr) require.Equal(t, model.ScheduledPostRepeatTypeNone, fetched.RepeatType) }) diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 74f7a29d0d0b..d4a7ca80a203 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -3033,7 +3033,7 @@ func (a *App) removeUserFromChannel(rctx request.CTX, userIDToRemove string, rem if appErr := a.removeChannelMembership(rctx, userIDToRemove, channel.Id, "removeUserFromChannel"); appErr != nil { return appErr } - if err := a.Srv().Store().ChannelMemberHistory().LogLeaveEvent(userIDToRemove, channel.Id, model.GetMillis()); err != nil { + if err := a.Srv().Store().ChannelMemberHistory().LogLeaveEvent(rctx, userIDToRemove, channel.Id, model.GetMillis()); err != nil { return model.NewAppError("removeUserFromChannel", "app.channel_member_history.log_leave_event.internal_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/server/channels/app/content_flagging.go b/server/channels/app/content_flagging.go index 1153409dab42..a085e250f230 100644 --- a/server/channels/app/content_flagging.go +++ b/server/channels/app/content_flagging.go @@ -299,6 +299,20 @@ func (a *App) canFlagPost(groupId, postId, userLocal string) *model.AppError { return model.NewAppError("canFlagPost", reason, nil, "", http.StatusBadRequest) } +func (a *App) CheckFlaggedPostActionable(where, postId string) (*model.PropertyValue, *model.AppError) { + status, appErr := a.GetPostContentFlaggingPropertyValue(postId, ContentFlaggingPropertyNameStatus) + if appErr != nil { + return nil, appErr + } + + statusValue := strings.Trim(string(status.Value), `"`) + if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned { + return nil, model.NewAppError(where, "api.data_spillage.error.post_not_in_progress", nil, "", http.StatusBadRequest) + } + + return status, nil +} + func (a *App) GetContentFlaggingMappedFields(groupId string) (map[string]*model.PropertyField, *model.AppError) { fields, appErr := a.SearchPropertyFields(nil, groupId, model.PropertyFieldSearchOpts{PerPage: CONTENT_FLAGGING_MAX_PROPERTY_FIELDS}) if appErr != nil { @@ -579,16 +593,11 @@ func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model. // generating unsafe JSON values commentJsonValue := json.RawMessage(commentBytes) - status, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPost.Id, ContentFlaggingPropertyNameStatus) + status, appErr := a.CheckFlaggedPostActionable("PermanentlyRemoveFlaggedPost", flaggedPost.Id) if appErr != nil { return appErr } - statusValue := strings.Trim(string(status.Value), `"`) - if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned { - return model.NewAppError("PermanentlyRemoveFlaggedPost", "api.data_spillage.error.post_not_in_progress", nil, "", http.StatusBadRequest) - } - groupId, err := a.ContentFlaggingGroupId() if err != nil { return model.NewAppError("PermanentDeleteFlaggedPost", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -868,16 +877,11 @@ func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContent // for keeping a flagged flaggedPost we need to- // 1. Undelete the flaggedPost if it was deleted, that's it - status, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPost.Id, ContentFlaggingPropertyNameStatus) + status, appErr := a.CheckFlaggedPostActionable("KeepFlaggedPost", flaggedPost.Id) if appErr != nil { return appErr } - statusValue := strings.Trim(string(status.Value), `"`) - if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned { - return model.NewAppError("KeepFlaggedPost", "api.data_spillage.error.post_not_in_progress", nil, "", http.StatusBadRequest) - } - groupId, err := a.ContentFlaggingGroupId() if err != nil { return model.NewAppError("KeepFlaggedPost", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err) diff --git a/server/channels/app/content_flagging_exposure_report.go b/server/channels/app/content_flagging_exposure_report.go new file mode 100644 index 000000000000..1ca2cfcd6e19 --- /dev/null +++ b/server/channels/app/content_flagging_exposure_report.go @@ -0,0 +1,258 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "cmp" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "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" +) + +const exposureProfileBatchSize = 1000 + +var exposurePreambleSanitizer = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ", ",", " ", ";", " ", "\t", " ") + +func (a *App) ComputePostExposure(rctx request.CTX, postID string) (*model.PostExposureReport, *model.AppError) { + post, appErr := a.GetSinglePost(rctx, postID, true) + if appErr != nil { + return nil, appErr + } + + if post.OriginalId != "" { + return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.edit_history_post.app_error", nil, "", http.StatusBadRequest) + } + + channel, appErr := a.GetChannel(rctx, post.ChannelId) + if appErr != nil { + return nil, appErr + } + if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup { + return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.unsupported_channel_type.app_error", nil, "", http.StatusBadRequest) + } + + windowEnd, appErr := a.getPostFlagTime(post.Id) + if appErr != nil { + return nil, appErr + } + + report := &model.PostExposureReport{ + Version: model.PostExposureReportVersion, + PostID: post.Id, + ChannelID: channel.Id, + ChannelName: channel.DisplayName, + ChannelType: channel.Type, + TeamID: channel.TeamId, + WindowStart: post.CreateAt, + WindowEnd: windowEnd, + GeneratedAt: model.GetMillis(), + Entries: []*model.PostExposureReportEntry{}, + } + + // Data source 1: who was in the channel while the post was live. + histories, err := a.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(post.CreateAt, windowEnd, []string{channel.Id}) + if err != nil { + return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.get_channel_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // GetUsersInChannelDuring returns one row per membership interval, so a user who left + // and rejoined appears more than once. The report is a list of users, so collapse them. + memberUserIDs := make([]string, 0, len(histories)) + seen := make(map[string]bool, len(histories)) + for _, h := range histories { + if h.IsBot || seen[h.UserId] { + continue + } + seen[h.UserId] = true + memberUserIDs = append(memberUserIDs, h.UserId) + } + + if len(memberUserIDs) == 0 { + return report, nil + } + + // Data source 2: channel read state. + lastViewedByUser, appErr := a.getChannelLastViewedAt(rctx, channel.Id) + if appErr != nil { + return nil, appErr + } + + users, appErr := a.getUsersProfiles(rctx, memberUserIDs) + if appErr != nil { + return nil, appErr + } + + for _, userID := range memberUserIDs { + user, ok := users[userID] + if !ok { + rctx.Logger().Warn("Skipping exposure report entry for a user that no longer exists", mlog.String("user_id", userID), mlog.String("post_id", post.Id)) + continue + } + + entry := &model.PostExposureReportEntry{ + UserID: user.Id, + Username: user.Username, + UserEmail: user.Email, + IsGuest: user.IsGuest(), + IsRemote: user.IsRemote(), + IsDeactivated: user.DeleteAt != 0, + WasChannelMember: true, + } + + if lastViewedAt, isMember := lastViewedByUser[userID]; isMember { + entry.LastViewedAt = model.NewPointer(lastViewedAt) + } + + if !entry.IsDeactivated { + lastActivityAt, appErr := a.getUserLastActivityAt(rctx, userID) + if appErr != nil { + return nil, appErr + } + entry.LastActivityAt = lastActivityAt + } + + report.Entries = append(report.Entries, entry) + } + + slices.SortFunc(report.Entries, func(x, y *model.PostExposureReportEntry) int { + return cmp.Or(cmp.Compare(x.Username, y.Username), cmp.Compare(x.UserID, y.UserID)) + }) + + return report, nil +} + +func (a *App) getPostFlagTime(postID string) (int64, *model.AppError) { + value, appErr := a.GetPostContentFlaggingPropertyValue(postID, contentFlaggingPropertyNameReportingTime) + if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError) + } + return 0, appErr + } + + var reportingTime int64 + if err := json.Unmarshal(value.Value, &reportingTime); err != nil { + return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if reportingTime <= 0 { + return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError) + } + + return reportingTime, nil +} + +func (a *App) getChannelLastViewedAt(rctx request.CTX, channelID string) (map[string]int64, *model.AppError) { + lastViewedByUser := map[string]int64{} + + afterUserID := "" + for { + members, err := a.Srv().Store().Channel().GetMembersWithLastViewedAtSince(rctx, channelID, 0, afterUserID, model.ChannelMemberLastViewedMaxPerPage) + if err != nil { + return nil, model.NewAppError("getChannelLastViewedAt", "app.data_spillage.exposure.get_possible_viewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if len(members) == 0 { + break + } + + for _, m := range members { + lastViewedByUser[m.UserId] = m.LastViewedAt + } + + afterUserID = members[len(members)-1].UserId + + if len(members) < model.ChannelMemberLastViewedMaxPerPage { + break + } + } + + return lastViewedByUser, nil +} + +func (a *App) getUsersProfiles(rctx request.CTX, userIDs []string) (map[string]*model.User, *model.AppError) { + profiles := make(map[string]*model.User, len(userIDs)) + + for batch := range slices.Chunk(userIDs, exposureProfileBatchSize) { + users, appErr := a.GetUsers(rctx, batch) + if appErr != nil { + return nil, appErr + } + for _, u := range users { + profiles[u.Id] = u + } + } + + return profiles, nil +} + +func (a *App) getUserLastActivityAt(rctx request.CTX, userID string) (*int64, *model.AppError) { + sessions, appErr := a.GetSessions(rctx, userID) + if appErr != nil { + return nil, model.NewAppError("getUserLastActivityAt", "app.data_spillage.exposure.get_last_activity.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) + } + + if len(sessions) == 0 { + return nil, nil + } + + var lastActivityAt int64 + for _, session := range sessions { + if session.LastActivityAt > lastActivityAt { + lastActivityAt = session.LastActivityAt + } + } + + return model.NewPointer(lastActivityAt), nil +} + +func WritePostExposureCSV(w io.Writer, report *model.PostExposureReport, T i18n.TranslateFunc) error { + if err := writePostExposurePreamble(w, report, T); err != nil { + return err + } + + cw := csv.NewWriter(w) + if err := cw.Write(model.PostExposureReportCSVHeader(T)); err != nil { + return err + } + for _, entry := range report.Entries { + if err := cw.Write(entry.ToCSVRow(T)); err != nil { + return err + } + } + + cw.Flush() + return cw.Error() +} + +// writePostExposurePreamble writes the report metadata as CSV comment lines above the header +// row, so the artifact is self-describing without a sidecar file. Readers skip these with +// the standard comment option (Go's csv.Reader Comment: '#', pandas comment='#'). +func writePostExposurePreamble(w io.Writer, report *model.PostExposureReport, T i18n.TranslateFunc) error { + lines := [][2]string{ + {T("app.data_spillage.exposure.meta.report_version"), report.Version}, + {T("app.data_spillage.exposure.meta.post_id"), report.PostID}, + {T("app.data_spillage.exposure.meta.channel"), fmt.Sprintf("%s (%s)", report.ChannelName, report.ChannelID)}, + {T("app.data_spillage.exposure.meta.window_start"), model.FormatExposureTime(report.WindowStart)}, + {T("app.data_spillage.exposure.meta.window_end"), model.FormatExposureTime(report.WindowEnd)}, + {T("app.data_spillage.exposure.meta.generated_at"), model.FormatExposureTime(report.GeneratedAt)}, + {T("app.data_spillage.exposure.meta.total_users"), fmt.Sprintf("%d", len(report.Entries))}, + } + + for _, line := range lines { + if _, err := fmt.Fprintf(w, "# %s: %s\n", line[0], exposurePreambleSanitizer.Replace(line[1])); err != nil { + return err + } + } + + return nil +} diff --git a/server/channels/app/content_flagging_exposure_report_test.go b/server/channels/app/content_flagging_exposure_report_test.go new file mode 100644 index 000000000000..8ae88e5b74c2 --- /dev/null +++ b/server/channels/app/content_flagging_exposure_report_test.go @@ -0,0 +1,596 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "bytes" + "encoding/csv" + "net/http" + "slices" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/i18n" +) + +// flagPostInChannel creates a post in the given channel and flags it, returning the post. +// Unlike setupFlaggedPost it does not sleep: the content flagging property values, including +// reporting_time, are written synchronously by FlagPost. +func flagPostInChannel(t *testing.T, th *TestHelper, channel *model.Channel) *model.Post { + t.Helper() + + post := th.CreatePost(t, channel) + + appErr := th.App.FlagPost(th.Context, post, channel.TeamId, th.BasicUser2.Id, model.FlagContentRequest{ + Reason: "spam", + Comment: "This is spam content", + }) + require.Nil(t, appErr) + + return post +} + +// setLastViewedAt writes an exact LastViewedAt for a channel member. The app-level paths all +// clamp with GREATEST or derive the value from Channels.LastPostAt, so none of them can set +// an arbitrary value. +func setLastViewedAt(t *testing.T, th *TestHelper, channelID, userID string, lastViewedAt int64) { + t.Helper() + + _, err := th.SQLStore.GetMaster().Exec( + `UPDATE ChannelMembers SET LastViewedAt = ? WHERE ChannelId = ? AND UserId = ?`, + lastViewedAt, channelID, userID) + require.NoError(t, err) +} + +func seedSessionWithLastActivityAt(t *testing.T, th *TestHelper, userID string, lastActivityAt int64) { + t.Helper() + + session, err := th.App.Srv().Store().Session().Save(th.Context, &model.Session{UserId: userID}) + require.NoError(t, err) + require.NoError(t, th.App.Srv().Store().Session().UpdateLastActivityAt(session.Id, lastActivityAt)) +} + +// seedOldChannelMemberHistory makes GetUsersInChannelDuring use ChannelMemberHistory rather +// than its current-membership fallback. +// +// The fallback triggers when MIN(JoinTime) across the whole ChannelMemberHistory table is +// later than the window start, and it returns only current members with synthesised join and +// leave times. On a production server the table stretches back far enough that this never +// happens, but a test database starts empty and TestHelper.CreatePost backdates posts by ten +// seconds, so every history row is newer than the post. Seeding one very old, already-closed +// row for a synthetic channel pins the accurate code path. +func seedOldChannelMemberHistory(t *testing.T, th *TestHelper) { + t.Helper() + + _, err := th.SQLStore.GetMaster().Exec( + `INSERT INTO ChannelMemberHistory (ChannelId, UserId, JoinTime, LeaveTime) VALUES (?, ?, ?, ?)`, + model.NewId(), model.NewId(), int64(1), int64(2)) + require.NoError(t, err) +} + +// setRemoteID marks a user as originating from a remote server. RemoteId is not a +// user-editable field, so UpdateUser silently drops it. +func setRemoteID(t *testing.T, th *TestHelper, userID, remoteID string) { + t.Helper() + + _, err := th.SQLStore.GetMaster().Exec(`UPDATE Users SET RemoteId = ? WHERE Id = ?`, remoteID, userID) + require.NoError(t, err) + th.App.InvalidateCacheForUser(userID) +} + +func entryFor(report *model.PostExposureReport, userID string) *model.PostExposureReportEntry { + for _, e := range report.Entries { + if e.UserID == userID { + return e + } + } + return nil +} + +func entryUserIDs(report *model.PostExposureReport) []string { + out := make([]string, 0, len(report.Entries)) + for _, e := range report.Entries { + out = append(out, e.UserID) + } + return out +} + +func TestComputePostExposure(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + require.Nil(t, setBaseConfig(th)) + seedOldChannelMemberHistory(t, th) + + t.Run("reports a member who viewed the channel after the post", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + viewer := th.CreateUser(t) + th.LinkUserToTeam(t, viewer, th.BasicTeam) + th.AddUserToChannel(t, viewer, channel) + + post := flagPostInChannel(t, th, channel) + setLastViewedAt(t, th, channel.Id, viewer.Id, post.CreateAt+1000) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, viewer.Id) + require.NotNil(t, entry) + require.True(t, entry.WasChannelMember) + require.NotNil(t, entry.LastViewedAt) + require.Equal(t, post.CreateAt+1000, *entry.LastViewedAt) + require.Equal(t, viewer.Username, entry.Username) + require.Equal(t, viewer.Email, entry.UserEmail) + }) + + t.Run("reports a member whose last channel view predates the post", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + nonViewer := th.CreateUser(t) + th.LinkUserToTeam(t, nonViewer, th.BasicTeam) + th.AddUserToChannel(t, nonViewer, channel) + + post := flagPostInChannel(t, th, channel) + setLastViewedAt(t, th, channel.Id, nonViewer.Id, post.CreateAt-1) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, nonViewer.Id) + require.NotNil(t, entry) + require.True(t, entry.WasChannelMember) + require.NotNil(t, entry.LastViewedAt) + require.Equal(t, post.CreateAt-1, *entry.LastViewedAt) + }) + + t.Run("leaves LastViewedAt unset for a member who has since left the channel", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + leaver := th.CreateUser(t) + th.LinkUserToTeam(t, leaver, th.BasicTeam) + th.AddUserToChannel(t, leaver, channel) + + post := flagPostInChannel(t, th, channel) + require.Nil(t, th.RemoveUserFromChannel(t, leaver, channel)) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, leaver.Id) + require.NotNil(t, entry, "a user who left after the post must still be reported as a member") + require.True(t, entry.WasChannelMember) + require.Nil(t, entry.LastViewedAt, "no read state survives for a former member") + }) + + t.Run("includes a deactivated user, flagged as such", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + user := th.CreateUser(t) + th.LinkUserToTeam(t, user, th.BasicTeam) + th.AddUserToChannel(t, user, channel) + + post := flagPostInChannel(t, th, channel) + + _, appErr := th.App.UpdateActive(th.Context, user, false) + require.Nil(t, appErr) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, user.Id) + require.NotNil(t, entry, "deactivating a user does not undo their exposure") + require.True(t, entry.IsDeactivated) + }) + + t.Run("flags a guest user", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + guest := th.CreateGuest(t) + th.LinkUserToTeam(t, guest, th.BasicTeam) + th.AddUserToChannel(t, guest, channel) + + post := flagPostInChannel(t, th, channel) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, guest.Id) + require.NotNil(t, entry) + require.True(t, entry.IsGuest) + require.False(t, entry.IsRemote) + }) + + t.Run("flags a remote user", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + remote := th.CreateUser(t) + th.LinkUserToTeam(t, remote, th.BasicTeam) + th.AddUserToChannel(t, remote, channel) + + setRemoteID(t, th, remote.Id, model.NewId()) + + post := flagPostInChannel(t, th, channel) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, remote.Id) + require.NotNil(t, entry) + require.True(t, entry.IsRemote, "content on a remote server is beyond the reach of hiding the post") + }) + + t.Run("still reports members of an archived channel", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + user := th.CreateUser(t) + th.LinkUserToTeam(t, user, th.BasicTeam) + th.AddUserToChannel(t, user, channel) + + post := flagPostInChannel(t, th, channel) + setLastViewedAt(t, th, channel.Id, user.Id, post.CreateAt+1) + + require.Nil(t, th.App.DeleteChannel(th.Context, channel, th.BasicUser.Id)) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, user.Id) + require.NotNil(t, entry, "archiving is soft; membership and read state survive it") + require.NotNil(t, entry.LastViewedAt) + require.Equal(t, post.CreateAt+1, *entry.LastViewedAt) + }) + + t.Run("reports the latest activity across all of a user's sessions", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + user := th.CreateUser(t) + th.LinkUserToTeam(t, user, th.BasicTeam) + th.AddUserToChannel(t, user, channel) + + post := flagPostInChannel(t, th, channel) + seedSessionWithLastActivityAt(t, th, user.Id, post.CreateAt-5000) + seedSessionWithLastActivityAt(t, th, user.Id, post.CreateAt+7000) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, user.Id) + require.NotNil(t, entry) + require.NotNil(t, entry.LastActivityAt) + require.Equal(t, post.CreateAt+7000, *entry.LastActivityAt) + }) + + t.Run("leaves LastActivityAt unset for a member with no sessions", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + user := th.CreateUser(t) + th.LinkUserToTeam(t, user, th.BasicTeam) + th.AddUserToChannel(t, user, channel) + + post := flagPostInChannel(t, th, channel) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + entry := entryFor(report, user.Id) + require.NotNil(t, entry) + require.Nil(t, entry.LastActivityAt) + }) + + t.Run("excludes bots", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + bot := th.CreateBot(t) + botUser, appErr := th.App.GetUser(bot.UserId) + require.Nil(t, appErr) + th.LinkUserToTeam(t, botUser, th.BasicTeam) + th.AddUserToChannel(t, botUser, channel) + + human := th.CreateUser(t) + th.LinkUserToTeam(t, human, th.BasicTeam) + th.AddUserToChannel(t, human, channel) + + _, err := th.App.GetChannelMember(th.Context, channel.Id, bot.UserId) + require.Nil(t, err, "the bot must really be a channel member for this to test anything") + + post := flagPostInChannel(t, th, channel) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + require.NotNil(t, entryFor(report, human.Id), "the channel's human members must still be reported") + require.Nil(t, entryFor(report, bot.UserId), "a bot cannot be a recipient of a data spillage") + }) + + t.Run("populates the report window and metadata", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + post := flagPostInChannel(t, th, channel) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + require.Equal(t, model.PostExposureReportVersion, report.Version) + require.Equal(t, post.Id, report.PostID) + require.Equal(t, channel.Id, report.ChannelID) + require.Equal(t, channel.DisplayName, report.ChannelName) + require.Equal(t, th.BasicTeam.Id, report.TeamID) + require.Equal(t, post.CreateAt, report.WindowStart) + + // The window must end at the flag time, not at "now". + values := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameReportingTime) + require.Len(t, values, 1) + require.Equal(t, string(values[0].Value), strconv.FormatInt(report.WindowEnd, 10)) + require.NotZero(t, report.GeneratedAt) + }) + + t.Run("orders entries deterministically by username", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + for range 4 { + u := th.CreateUser(t) + th.LinkUserToTeam(t, u, th.BasicTeam) + th.AddUserToChannel(t, u, channel) + } + + post := flagPostInChannel(t, th, channel) + + first, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + second, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.Nil(t, appErr) + + require.Equal(t, entryUserIDs(first), entryUserIDs(second)) + + usernames := make([]string, 0, len(first.Entries)) + for _, e := range first.Entries { + usernames = append(usernames, e.Username) + } + require.IsIncreasing(t, usernames) + }) + + t.Run("returns an error when the post is not flagged", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + post := th.CreatePost(t, channel) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.NotNil(t, appErr) + require.Nil(t, report) + }) + + t.Run("returns an error when reporting_time is missing", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + post := flagPostInChannel(t, th, channel) + + values := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameReportingTime) + require.Len(t, values, 1) + require.Nil(t, th.App.DeletePropertyValue(th.Context, values[0].GroupID, values[0].ID)) + + report, appErr := th.App.ComputePostExposure(th.Context, post.Id) + require.NotNil(t, appErr) + require.Nil(t, report) + require.Equal(t, "app.data_spillage.exposure.missing_reporting_time.app_error", appErr.Id) + }) + + t.Run("returns an error for a post that does not exist", func(t *testing.T) { + report, appErr := th.App.ComputePostExposure(th.Context, model.NewId()) + require.NotNil(t, appErr) + require.Nil(t, report) + }) + + t.Run("rejects direct and group message channels", func(t *testing.T) { + other := th.CreateUser(t) + + dm := th.CreateDmChannel(t, other) + dmPost := th.CreatePost(t, dm) + appErr := th.App.FlagPost(th.Context, dmPost, "", th.BasicUser2.Id, model.FlagContentRequest{Reason: "spam", Comment: "c"}) + require.Nil(t, appErr) + + report, appErr := th.App.ComputePostExposure(th.Context, dmPost.Id) + require.NotNil(t, appErr) + require.Nil(t, report) + require.Equal(t, "app.data_spillage.exposure.unsupported_channel_type.app_error", appErr.Id) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + + gm := th.CreateGroupChannel(t, th.BasicUser2, other) + gmPost := th.CreatePost(t, gm) + appErr = th.App.FlagPost(th.Context, gmPost, "", th.BasicUser2.Id, model.FlagContentRequest{Reason: "spam", Comment: "c"}) + require.Nil(t, appErr) + + report, appErr = th.App.ComputePostExposure(th.Context, gmPost.Id) + require.NotNil(t, appErr) + require.Nil(t, report) + require.Equal(t, "app.data_spillage.exposure.unsupported_channel_type.app_error", appErr.Id) + }) + + t.Run("rejects an edit history revision", func(t *testing.T) { + channel := th.CreateChannel(t, th.BasicTeam) + post := flagPostInChannel(t, th, channel) + + patched, _, appErr := th.App.PatchPost(th.Context, post.Id, &model.PostPatch{Message: model.NewPointer("edited")}, &model.UpdatePostOptions{}) + require.Nil(t, appErr) + require.NotNil(t, patched) + + history, appErr := th.App.GetEditHistoryForPost(post.Id) + require.Nil(t, appErr) + require.NotEmpty(t, history) + + report, appErr := th.App.ComputePostExposure(th.Context, history[0].Id) + require.NotNil(t, appErr) + require.Nil(t, report) + require.Equal(t, "app.data_spillage.exposure.edit_history_post.app_error", appErr.Id) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) +} + +func TestWritePostExposureCSV(t *testing.T) { + mainHelper.Parallel(t) + + T := i18n.GetUserTranslations("en") + + baseReport := func() *model.PostExposureReport { + return &model.PostExposureReport{ + Version: model.PostExposureReportVersion, + PostID: "post1", + ChannelID: "channel1", + ChannelName: "Town Square", + ChannelType: model.ChannelTypeOpen, + TeamID: "team1", + WindowStart: 1700000000000, + WindowEnd: 1700000600000, + GeneratedAt: 1700001000000, + Entries: []*model.PostExposureReportEntry{}, + } + } + + // parseCSV strips the comment preamble and returns the remaining records. + parseCSV := func(t *testing.T, b []byte) [][]string { + t.Helper() + r := csv.NewReader(bytes.NewReader(b)) + r.Comment = '#' + records, err := r.ReadAll() + require.NoError(t, err) + return records + } + + headerIndex := func(t *testing.T, key string) int { + t.Helper() + idx := slices.Index(model.PostExposureReportCSVHeader(T), T(key)) + require.GreaterOrEqual(t, idx, 0, "no column is headed by %s", key) + return idx + } + + t.Run("writes a preamble and a header for an empty report", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, WritePostExposureCSV(&buf, baseReport(), T)) + + out := buf.String() + require.Contains(t, out, "# Post ID: post1") + require.Contains(t, out, "# Channel: Town Square (channel1)") + require.Contains(t, out, "# Post created at: 2023-11-14T22:13:20Z") + require.Contains(t, out, "# Flagged at: 2023-11-14T22:23:20Z") + require.Contains(t, out, "# Total users: 0") + + records := parseCSV(t, buf.Bytes()) + require.Len(t, records, 1, "an empty report is still a valid CSV with a header") + require.Equal(t, model.PostExposureReportCSVHeader(T), records[0]) + }) + + t.Run("renders every column", func(t *testing.T) { + report := baseReport() + report.Entries = append(report.Entries, &model.PostExposureReportEntry{ + UserID: "user1", + Username: "alice", + UserEmail: "alice@example.com", + IsGuest: true, + IsRemote: false, + IsDeactivated: true, + WasChannelMember: true, + LastViewedAt: model.NewPointer(int64(1700000300000)), + LastActivityAt: model.NewPointer(int64(1700000900000)), + }) + + var buf bytes.Buffer + require.NoError(t, WritePostExposureCSV(&buf, report, T)) + + records := parseCSV(t, buf.Bytes()) + require.Len(t, records, 2) + require.Equal(t, []string{ + "user1", "alice", "alice@example.com", + "Yes", "No", "Yes", "Yes", + "2023-11-14T22:18:20Z", "2023-11-14T22:28:20Z", + }, records[1]) + require.Len(t, records[1], len(model.PostExposureReportCSVHeader(T))) + }) + + t.Run("renders missing read state as markers rather than the epoch", func(t *testing.T) { + report := baseReport() + report.Entries = append(report.Entries, + &model.PostExposureReportEntry{UserID: "u1", Username: "aa", LastViewedAt: nil}, + &model.PostExposureReportEntry{UserID: "u2", Username: "bb", LastViewedAt: model.NewPointer(int64(0))}, + ) + + var buf bytes.Buffer + require.NoError(t, WritePostExposureCSV(&buf, report, T)) + + records := parseCSV(t, buf.Bytes()) + require.Len(t, records, 3) + + lastViewed := headerIndex(t, "app.data_spillage.exposure.column.last_viewed_at") + lastActivity := headerIndex(t, "app.data_spillage.exposure.column.last_activity_at") + + require.Equal(t, "Unknown", records[1][lastViewed]) + require.Equal(t, "No sessions found", records[1][lastActivity]) + require.Equal(t, "N/A", records[2][lastViewed]) + require.NotContains(t, buf.String(), "1970-01-01") + }) + + t.Run("escapes separators and quotes in user data", func(t *testing.T) { + report := baseReport() + report.Entries = append(report.Entries, &model.PostExposureReportEntry{ + UserID: "u1", + Username: `we,ird "name"`, + UserEmail: "line\nbreak@example.com", + }) + + var buf bytes.Buffer + require.NoError(t, WritePostExposureCSV(&buf, report, T)) + + records := parseCSV(t, buf.Bytes()) + require.Len(t, records, 2) + require.Equal(t, `we,ird "name"`, records[1][1]) + require.Equal(t, "line\nbreak@example.com", records[1][2]) + }) + + t.Run("collapses line breaks in preamble values", func(t *testing.T) { + // A channel display name may contain line breaks: Channel.IsValid only bounds its + // length and SanitizeUnicode leaves \n and \r alone. Written raw, the remainder of + // the value would land on a line without a leading "#", which a comment-aware + // reader parses as a data record and rejects for having the wrong field count. + report := baseReport() + report.ChannelName = "Town\nSquare\r\nAnnex\rWing" + + var buf bytes.Buffer + require.NoError(t, WritePostExposureCSV(&buf, report, T)) + + out := buf.String() + require.Contains(t, out, "# Channel: Town Square Annex Wing (channel1)") + + records := parseCSV(t, buf.Bytes()) + require.Len(t, records, 1, "the preamble must stay fully commented out") + require.Equal(t, model.PostExposureReportCSVHeader(T), records[0]) + }) + + t.Run("keeps a preamble value from opening a second cell", func(t *testing.T) { + // A spreadsheet honours no comment convention: it splits every line on the field + // separator and reads each cell on its own. A separator in the value would open a + // second cell that the leading "#" no longer guards, and a cell starting with "=", + // "+", "-" or "@" is a formula. + report := baseReport() + report.ChannelName = "a,=1+1;=2+2\t=3+3" + + var buf bytes.Buffer + require.NoError(t, WritePostExposureCSV(&buf, report, T)) + + out := buf.String() + require.Contains(t, out, "# Channel: a =1+1 =2+2 =3+3 (channel1)") + + for line := range strings.SplitSeq(out, "\n") { + if !strings.HasPrefix(line, "#") { + continue + } + require.False(t, strings.ContainsAny(line, ",;\t"), "preamble line %q is more than one cell", line) + } + + records := parseCSV(t, buf.Bytes()) + require.Len(t, records, 1, "the preamble must stay fully commented out") + require.Equal(t, model.PostExposureReportCSVHeader(T), records[0]) + }) + + t.Run("is byte-for-byte deterministic", func(t *testing.T) { + report := baseReport() + report.Entries = append(report.Entries, + &model.PostExposureReportEntry{UserID: "u1", Username: "aa", WasChannelMember: true}, + &model.PostExposureReportEntry{UserID: "u2", Username: "bb", WasChannelMember: true}, + ) + + var first, second bytes.Buffer + require.NoError(t, WritePostExposureCSV(&first, report, T)) + require.NoError(t, WritePostExposureCSV(&second, report, T)) + require.Equal(t, first.Bytes(), second.Bytes()) + }) +} diff --git a/server/channels/app/content_flagging_report.go b/server/channels/app/content_flagging_report.go index 817a51726de8..a6ad1c5d298e 100644 --- a/server/channels/app/content_flagging_report.go +++ b/server/channels/app/content_flagging_report.go @@ -27,6 +27,7 @@ const ( flaggedPostReportPostYAMLFile = "post.yaml" flaggedPostReportContentReviewFile = "content_review.yaml" flaggedPostReportMetadataFile = "report_metadata.yaml" + flaggedPostReportExposureFile = "exposure_report.csv" flaggedPostReportTempPattern = "mm-flag-report-*.zip" ) @@ -88,6 +89,9 @@ func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, g if appErr := a.writeContentReviewEntry(rctx, zw, rc.Post, generatedByUserID, comment, action); appErr != nil { return appErr } + if appErr := a.writeExposureReportEntry(rctx, zw, rc.Post.Id); appErr != nil { + return appErr + } if appErr := a.writeReportMetadataEntry(zw, generatedByUserID); appErr != nil { return appErr } @@ -95,6 +99,24 @@ func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, g return nil } +func (a *App) writeExposureReportEntry(rctx request.CTX, zw *zip.Writer, postID string) *model.AppError { + report, appErr := a.ComputePostExposure(rctx, postID) + if appErr != nil { + return appErr + } + + w, err := zw.Create(flaggedPostReportExposureFile) + if err != nil { + return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_create.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if csvWriteErr := WritePostExposureCSV(w, report, rctx.GetT()); csvWriteErr != nil { + return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_exposure_csv.app_error", nil, "", http.StatusInternalServerError).Wrap(csvWriteErr) + } + + return nil +} + func (a *App) loadFlaggedPostReportContext(rctx request.CTX, postID string) (*model.FlaggedPostReportContext, *model.AppError) { post, appErr := a.GetSinglePost(rctx, postID, true) if appErr != nil { @@ -426,6 +448,16 @@ func decodePropertyInt64(rctx request.CTX, byName map[string]json.RawMessage, fi // reviewer's content review thread to record that a report was generated. // Best-effort: errors are logged, never returned. func (a *App) NotifyReviewersOfFlaggedPostReportGeneration(rctx request.CTX, flaggedPostID, generatedByUserID string) { + a.notifyReviewersOfReportGeneration(rctx, flaggedPostID, generatedByUserID, "@%s generated a report for the quarantined message.") +} + +// NotifyReviewersOfPostExposureReportGeneration is the exposure report counterpart of +// NotifyReviewersOfFlaggedPostReportGeneration. +func (a *App) NotifyReviewersOfPostExposureReportGeneration(rctx request.CTX, flaggedPostID, generatedByUserID string) { + a.notifyReviewersOfReportGeneration(rctx, flaggedPostID, generatedByUserID, "@%s downloaded an exposure report for the quarantined message.") +} + +func (a *App) notifyReviewersOfReportGeneration(rctx request.CTX, flaggedPostID, generatedByUserID, messageFormat string) { groupID, err := a.ContentFlaggingGroupId() if err != nil { rctx.Logger().Warn("Failed to get content flagging group id for report generation notification", mlog.Err(err)) @@ -438,7 +470,7 @@ func (a *App) NotifyReviewersOfFlaggedPostReportGeneration(rctx request.CTX, fla return } - message := fmt.Sprintf("@%s generated a report for the quarantined message.", generator.Username) + message := fmt.Sprintf(messageFormat, generator.Username) if _, appErr := a.postReviewerMessage(rctx, message, groupID, flaggedPostID, nil, ""); appErr != nil { rctx.Logger().Warn("Failed to post report generation notification to reviewers", mlog.String("flagged_post_id", flaggedPostID), mlog.Err(appErr)) } diff --git a/server/channels/app/content_flagging_report_test.go b/server/channels/app/content_flagging_report_test.go index c501227e255c..b75252a65d5a 100644 --- a/server/channels/app/content_flagging_report_test.go +++ b/server/channels/app/content_flagging_report_test.go @@ -6,6 +6,7 @@ package app import ( "archive/zip" "bytes" + "encoding/csv" "encoding/json" "io" "os" @@ -15,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "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" ) @@ -85,6 +87,39 @@ func TestGenerateFlaggedPostReport(t *testing.T) { require.Contains(t, entries, "post/post.yaml") require.Contains(t, entries, "content_review.yaml") require.Contains(t, entries, "report_metadata.yaml") + require.Contains(t, entries, "exposure_report.csv") + }) + + t.Run("exposure_report.csv is a parseable exposure report", func(t *testing.T) { + appErr := setBaseConfig(th) + require.Nil(t, appErr) + seedOldChannelMemberHistory(t, th) + + post := setupFlaggedPost(t, th) + + path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "") + require.Nil(t, appErr) + + entries := readReportZip(t, path) + require.Contains(t, entries, "exposure_report.csv") + + body := string(entries["exposure_report.csv"]) + require.Contains(t, body, "# Post ID: "+post.Id) + + r := csv.NewReader(bytes.NewReader(entries["exposure_report.csv"])) + r.Comment = '#' + records, err := r.ReadAll() + require.NoError(t, err) + require.NotEmpty(t, records) + require.Equal(t, model.PostExposureReportCSVHeader(i18n.GetUserTranslations("en")), records[0]) + + var found bool + for _, record := range records[1:] { + if record[0] == th.BasicUser.Id { + found = true + } + } + require.True(t, found) }) t.Run("post.yaml contains channel, team, and author details", func(t *testing.T) { @@ -522,3 +557,66 @@ func TestNotifyReviewersOfFlaggedPostReportGeneration(t *testing.T) { }) }) } + +func TestNotifyReviewersOfPostExposureReportGeneration(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + reviewerMessages := func(t *testing.T, postID string) []string { + t.Helper() + + groupID, appErr := th.App.ContentFlaggingGroupId() + require.Nil(t, appErr) + + mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupID) + require.Nil(t, appErr) + + rootPostIDs, appErr := th.App.getReviewerPostsForFlaggedPost(groupID, postID, mappedFields[contentFlaggingPropertyNameFlaggedPostId].ID) + require.Nil(t, appErr) + require.NotEmpty(t, rootPostIDs) + + var messages []string + for _, rootPostID := range rootPostIDs { + thread, appErr := th.App.GetPostThread(th.Context, rootPostID, model.GetPostsOptions{}, "") + require.Nil(t, appErr) + for _, reply := range thread.Posts { + messages = append(messages, reply.Message) + } + } + return messages + } + + t.Run("posts an exposure-specific message distinct from the flagged post report message", func(t *testing.T) { + appErr := setBaseConfig(th) + require.Nil(t, appErr) + + post := setupFlaggedPost(t, th) + + th.App.NotifyReviewersOfPostExposureReportGeneration(th.Context, post.Id, th.BasicUser.Id) + + messages := reviewerMessages(t, post.Id) + require.Contains(t, messages, "@"+th.BasicUser.Username+" downloaded an exposure report for the quarantined message.") + require.NotContains(t, messages, "@"+th.BasicUser.Username+" generated a report for the quarantined message.") + }) + + t.Run("leaves the flagged post report wording unchanged", func(t *testing.T) { + appErr := setBaseConfig(th) + require.Nil(t, appErr) + + post := setupFlaggedPost(t, th) + + th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, post.Id, th.BasicUser.Id) + + messages := reviewerMessages(t, post.Id) + require.Contains(t, messages, "@"+th.BasicUser.Username+" generated a report for the quarantined message.") + }) + + t.Run("does not panic for a non-flagged post", func(t *testing.T) { + appErr := setBaseConfig(th) + require.Nil(t, appErr) + + require.NotPanics(t, func() { + th.App.NotifyReviewersOfPostExposureReportGeneration(th.Context, model.NewId(), th.BasicUser.Id) + }) + }) +} diff --git a/server/channels/app/emoji.go b/server/channels/app/emoji.go index 327aa3f05e6c..5a86c52c0fa7 100644 --- a/server/channels/app/emoji.go +++ b/server/channels/app/emoji.go @@ -373,7 +373,7 @@ func (a *App) deleteEmojiImage(rctx request.CTX, id string) { } func (a *App) deleteReactionsForEmoji(rctx request.CTX, emojiName string) { - if err := a.Srv().Store().Reaction().DeleteAllWithEmojiName(emojiName); err != nil { + if err := a.Srv().Store().Reaction().DeleteAllWithEmojiName(rctx, emojiName); err != nil { rctx.Logger().Warn("Unable to delete reactions when deleting emoji", mlog.String("emoji_name", emojiName), mlog.Err(err)) } } diff --git a/server/channels/app/guarded_hooks_test.go b/server/channels/app/guarded_hooks_test.go index c725b898f4a6..c6230cf448a4 100644 --- a/server/channels/app/guarded_hooks_test.go +++ b/server/channels/app/guarded_hooks_test.go @@ -328,7 +328,7 @@ func TestChannelGuardBlocksScheduledPostWhenPluginInactive(t *testing.T) { assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) // A rejection must not persist any row — same bar as the inactive-guard state. - rows, storeErr := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicTeam.Id) + rows, storeErr := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id) require.NoError(t, storeErr) for _, row := range rows { assert.NotEqual(t, th.BasicChannel.Id, row.ChannelId, "rejected scheduled post must not be in the store") @@ -367,7 +367,7 @@ func TestChannelGuardBlocksScheduledPostWhenPluginInactive(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode) // No row may exist for this channel/user. - rows, storeErr := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicTeam.Id) + rows, storeErr := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id) require.NoError(t, storeErr) for _, row := range rows { assert.NotEqual(t, th.BasicChannel.Id, row.ChannelId, "rejected scheduled post must not be in the store") @@ -393,7 +393,7 @@ func TestChannelGuardBlocksScheduledPostWhenPluginInactive(t *testing.T) { require.NotNil(t, saved) // Confirm the row actually exists in the store with the expected content. - fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id) + fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(th.Context, saved.Id) require.NoError(t, storeErr) assert.Equal(t, "unguarded, should persist", fetched.Message) }) @@ -436,7 +436,7 @@ func TestChannelGuardBlocksScheduledPostWhenPluginInactive(t *testing.T) { assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) // The store row must still have the original message — the rejection must not mutate it. - fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id) + fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(th.Context, saved.Id) require.NoError(t, storeErr) assert.Equal(t, "original", fetched.Message, "rejected update must not be persisted") }) @@ -479,7 +479,7 @@ func TestChannelGuardBlocksScheduledPostWhenPluginInactive(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode) // The store row must still have the original message. - fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id) + fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(th.Context, saved.Id) require.NoError(t, storeErr) assert.Equal(t, "original", fetched.Message, "rejected update must not be persisted") }) @@ -508,7 +508,7 @@ func TestChannelGuardBlocksScheduledPostWhenPluginInactive(t *testing.T) { assert.Equal(t, "updated", updated.Message) // Confirm the updated message is actually in the store. - fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id) + fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(th.Context, saved.Id) require.NoError(t, storeErr) assert.Equal(t, "updated", fetched.Message) }) diff --git a/server/channels/app/plugin_test.go b/server/channels/app/plugin_test.go index 50b8fb13d762..eed823e759c5 100644 --- a/server/channels/app/plugin_test.go +++ b/server/channels/app/plugin_test.go @@ -1266,6 +1266,11 @@ func TestGetPluginStateOverride(t *testing.T) { }) t.Run("with enabled flag set to true", func(t *testing.T) { + // AppsEnabled=true is now rejected by Config.IsValid (MM-69643), so this + // override path can no longer be reached at runtime. Kept skipped rather + // than deleted so it is removed alongside the rest of the Apps code. + t.Skip("AppsEnabled feature flag is retired (MM-69643)") + mainHelper.Parallel(t) th2 := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.AppsEnabled = true diff --git a/server/channels/app/scheduled_post.go b/server/channels/app/scheduled_post.go index 706cf7c25831..cda002c05e63 100644 --- a/server/channels/app/scheduled_post.go +++ b/server/channels/app/scheduled_post.go @@ -61,7 +61,7 @@ func (a *App) SaveScheduledPost(rctx request.CTX, scheduledPost *model.Scheduled return nil, appErr } - savedScheduledPost, err := a.Srv().Store().ScheduledPost().CreateScheduledPost(scheduledPost) + savedScheduledPost, err := a.Srv().Store().ScheduledPost().CreateScheduledPost(rctx, scheduledPost) if err != nil { return nil, model.NewAppError("App.ScheduledPost", "app.save_scheduled_post.save.app_error", map[string]any{"user_id": scheduledPost.UserId, "channel_id": scheduledPost.ChannelId}, "", http.StatusBadRequest).Wrap(err) } @@ -72,7 +72,7 @@ func (a *App) SaveScheduledPost(rctx request.CTX, scheduledPost *model.Scheduled } func (a *App) GetUserTeamScheduledPosts(rctx request.CTX, userId, teamId string) ([]*model.ScheduledPost, *model.AppError) { - scheduledPosts, err := a.Srv().Store().ScheduledPost().GetScheduledPostsForUser(userId, teamId) + scheduledPosts, err := a.Srv().Store().ScheduledPost().GetScheduledPostsForUser(rctx, userId, teamId) if err != nil { return nil, model.NewAppError("App.GetUserTeamScheduledPosts", "app.get_user_team_scheduled_posts.error", map[string]any{"user_id": userId, "team_id": teamId}, "", http.StatusInternalServerError).Wrap(err) } @@ -95,7 +95,7 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost return nil, validationErr } - existingScheduledPost, err := a.Srv().Store().ScheduledPost().Get(scheduledPost.Id) + existingScheduledPost, err := a.Srv().Store().ScheduledPost().Get(rctx, scheduledPost.Id) if err != nil { return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError).Wrap(err) } @@ -127,7 +127,7 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost return nil, appErr } - if err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(scheduledPost); err != nil { + if err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(rctx, scheduledPost); err != nil { return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.update.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError).Wrap(err) } @@ -137,7 +137,7 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost } func (a *App) DeleteScheduledPost(rctx request.CTX, userId, scheduledPostId, connectionId string) (*model.ScheduledPost, *model.AppError) { - scheduledPost, err := a.Srv().Store().ScheduledPost().Get(scheduledPostId) + scheduledPost, err := a.Srv().Store().ScheduledPost().Get(rctx, scheduledPostId) if err != nil { return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.get_scheduled_post.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusInternalServerError).Wrap(err) } diff --git a/server/channels/app/scheduled_post_job.go b/server/channels/app/scheduled_post_job.go index 7d1e93ad60e4..c2a1bf4827be 100644 --- a/server/channels/app/scheduled_post_job.go +++ b/server/channels/app/scheduled_post_job.go @@ -44,7 +44,7 @@ func (a *App) ProcessScheduledPosts(rctx request.CTX) { // we wait some time before processing each batch to avoid hammering the database with too many requests. time.Sleep(scheduledPostBatchWaitTime) - scheduledPostsBatch, err := a.Srv().Store().ScheduledPost().GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, getPendingScheduledPostsPageSize) + scheduledPostsBatch, err := a.Srv().Store().ScheduledPost().GetPendingScheduledPosts(rctx, beforeTime, afterTime, lastScheduledPostId, getPendingScheduledPostsPageSize) if err != nil { rctx.Logger().Error( "App.ProcessScheduledPosts: failed to fetch pending scheduled posts page from database", @@ -95,7 +95,7 @@ func (a *App) ProcessScheduledPosts(rctx request.CTX) { // once all scheduled posts are processed, we need to update and close the old ones // as we don't process pending scheduled posts more than 24 hours old. - if err := a.Srv().Store().ScheduledPost().UpdateOldScheduledPosts(afterTime); err != nil { + if err := a.Srv().Store().ScheduledPost().UpdateOldScheduledPosts(rctx, afterTime); err != nil { rctx.Logger().Error( "App.ProcessScheduledPosts: failed to update old scheduled posts", mlog.Int("cutoff_time", afterTime), @@ -400,7 +400,7 @@ func (a *App) handleSuccessfulScheduledPosts(rctx request.CTX, completedSchedule var errs []error if len(recurringScheduledPosts) > 0 { - if err := a.Srv().Store().ScheduledPost().UpdateRecurringScheduledPosts(recurringScheduledPosts); err != nil { + if err := a.Srv().Store().ScheduledPost().UpdateRecurringScheduledPosts(rctx, recurringScheduledPosts); err != nil { rctx.Logger().Error( "App.handleSuccessfulScheduledPosts: failed to advance recurring scheduled posts", mlog.Int("recurring_scheduled_post_count", len(recurringScheduledPosts)), @@ -442,7 +442,7 @@ func (a *App) handleSuccessfulScheduledPosts(rctx request.CTX, completedSchedule func (a *App) handleFailedScheduledPosts(rctx request.CTX, failedScheduledPosts []*model.ScheduledPost) { for _, failedScheduledPost := range failedScheduledPosts { failedScheduledPost.ProcessedAt = model.GetMillis() - err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(failedScheduledPost) + err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(rctx, failedScheduledPost) if err != nil { // we intentionally don't stop on error as its possible to continue updating other scheduled posts rctx.Logger().Error( diff --git a/server/channels/app/scheduled_post_job_test.go b/server/channels/app/scheduled_post_job_test.go index abae0ce3b1b0..9d2f0b382611 100644 --- a/server/channels/app/scheduled_post_job_test.go +++ b/server/channels/app/scheduled_post_job_test.go @@ -31,7 +31,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost1) assert.NoError(t, err) scheduledPost2 := &model.ScheduledPost{ @@ -43,14 +43,14 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost2) assert.NoError(t, err) time.Sleep(1 * time.Second) th.App.ProcessScheduledPosts(th.Context) - scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) assert.NoError(t, err) assert.Len(t, scheduledPosts, 0) }) @@ -72,13 +72,13 @@ func TestProcessScheduledPosts(t *testing.T) { RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - created, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost) + created, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost) assert.NoError(t, err) require.NotNil(t, created) th.App.ProcessScheduledPosts(th.Context) - updated, err := th.Server.Store().ScheduledPost().Get(created.Id) + updated, err := th.Server.Store().ScheduledPost().Get(th.Context, created.Id) assert.NoError(t, err) require.NotNil(t, updated) assert.Equal(t, model.ScheduledPostRepeatTypeWeekly, updated.RepeatType) @@ -107,7 +107,7 @@ func TestProcessScheduledPosts(t *testing.T) { RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - firstCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(firstScheduledPost) + firstCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, firstScheduledPost) require.NoError(t, err) require.NotNil(t, firstCreated) @@ -122,13 +122,13 @@ func TestProcessScheduledPosts(t *testing.T) { RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - secondCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(secondScheduledPost) + secondCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, secondScheduledPost) require.NoError(t, err) require.NotNil(t, secondCreated) th.App.ProcessScheduledPosts(th.Context) - firstUpdated, err := th.Server.Store().ScheduledPost().Get(firstCreated.Id) + firstUpdated, err := th.Server.Store().ScheduledPost().Get(th.Context, firstCreated.Id) require.NoError(t, err) require.NotNil(t, firstUpdated) assert.Equal(t, model.ScheduledPostRepeatTypeWeekly, firstUpdated.RepeatType) @@ -136,7 +136,7 @@ func TestProcessScheduledPosts(t *testing.T) { assert.Zero(t, firstUpdated.ProcessedAt) assert.Greater(t, firstUpdated.ScheduledAt, scheduledAt) - secondUpdated, err := th.Server.Store().ScheduledPost().Get(secondCreated.Id) + secondUpdated, err := th.Server.Store().ScheduledPost().Get(th.Context, secondCreated.Id) require.NoError(t, err) require.NotNil(t, secondUpdated) assert.Equal(t, model.ScheduledPostRepeatTypeWeekly, secondUpdated.RepeatType) @@ -162,13 +162,13 @@ func TestProcessScheduledPosts(t *testing.T) { RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - created, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost) + created, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost) assert.NoError(t, err) require.NotNil(t, created) th.App.ProcessScheduledPosts(th.Context) - updated, err := th.Server.Store().ScheduledPost().Get(created.Id) + updated, err := th.Server.Store().ScheduledPost().Get(th.Context, created.Id) assert.NoError(t, err) require.NotNil(t, updated) assert.Equal(t, model.ScheduledPostRepeatTypeWeekly, updated.RepeatType) @@ -197,7 +197,7 @@ func TestProcessScheduledPosts(t *testing.T) { RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - recurringCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(recurringScheduledPost) + recurringCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, recurringScheduledPost) require.NoError(t, err) oneShotScheduledPost := &model.ScheduledPost{ @@ -209,16 +209,16 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - oneShotCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(oneShotScheduledPost) + oneShotCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, oneShotScheduledPost) require.NoError(t, err) th.App.ProcessScheduledPosts(th.Context) // Both rows must be permanently deleted: the series ends rather than advancing, // erroring, or being silently reposted on later runs. - _, err = th.Server.Store().ScheduledPost().Get(recurringCreated.Id) + _, err = th.Server.Store().ScheduledPost().Get(th.Context, recurringCreated.Id) require.Error(t, err) - _, err = th.Server.Store().ScheduledPost().Get(oneShotCreated.Id) + _, err = th.Server.Store().ScheduledPost().Get(th.Context, oneShotCreated.Id) require.Error(t, err) }) @@ -242,7 +242,7 @@ func TestProcessScheduledPosts(t *testing.T) { RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - weeklyCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(weeklyScheduledPost) + weeklyCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, weeklyScheduledPost) assert.NoError(t, err) require.NotNil(t, weeklyCreated) @@ -255,13 +255,13 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: oneShotScheduledAt, } - oneShotCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(oneShotScheduledPost) + oneShotCreated, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, oneShotScheduledPost) assert.NoError(t, err) require.NotNil(t, oneShotCreated) th.App.ProcessScheduledPosts(th.Context) - weeklyUpdated, err := th.Server.Store().ScheduledPost().Get(weeklyCreated.Id) + weeklyUpdated, err := th.Server.Store().ScheduledPost().Get(th.Context, weeklyCreated.Id) assert.NoError(t, err) require.NotNil(t, weeklyUpdated) assert.Equal(t, model.ScheduledPostRepeatTypeWeekly, weeklyUpdated.RepeatType) @@ -270,7 +270,7 @@ func TestProcessScheduledPosts(t *testing.T) { assert.Zero(t, weeklyUpdated.ProcessedAt) assert.Greater(t, weeklyUpdated.ScheduledAt, model.GetMillis()) - oneShotUpdated, err := th.Server.Store().ScheduledPost().Get(oneShotCreated.Id) + oneShotUpdated, err := th.Server.Store().ScheduledPost().Get(th.Context, oneShotCreated.Id) assert.NoError(t, err) require.NotNil(t, oneShotUpdated) assert.Equal(t, model.ScheduledPostErrorUnableToSend, oneShotUpdated.ErrorCode) @@ -295,7 +295,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost1) assert.NoError(t, err) scheduledPost2 := &model.ScheduledPost{ @@ -307,7 +307,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost2) assert.NoError(t, err) time.Sleep(1 * time.Second) @@ -316,7 +316,7 @@ func TestProcessScheduledPosts(t *testing.T) { // since the channel ID we set in the above created scheduled posts is of a // non-existing channel, the job should have set the appropriate error code for them in the database - scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) assert.NoError(t, err) assert.Len(t, scheduledPosts, 2) @@ -342,7 +342,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost1) assert.NoError(t, err) scheduledPost2 := &model.ScheduledPost{ @@ -354,7 +354,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost2) assert.NoError(t, err) _, appErr := th.App.UpdateActive(th.Context, th.BasicUser, false) @@ -368,7 +368,7 @@ func TestProcessScheduledPosts(t *testing.T) { th.App.ProcessScheduledPosts(th.Context) - scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) assert.NoError(t, err) assert.Len(t, scheduledPosts, 2) @@ -394,7 +394,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost1) assert.NoError(t, err) scheduledPost2 := &model.ScheduledPost{ @@ -406,7 +406,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost2) assert.NoError(t, err) appErr := th.App.LeaveChannel(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -420,7 +420,7 @@ func TestProcessScheduledPosts(t *testing.T) { th.App.ProcessScheduledPosts(th.Context) - scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) assert.NoError(t, err) assert.Len(t, scheduledPosts, 2) @@ -446,7 +446,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost1) assert.NoError(t, err) scheduledPost2 := &model.ScheduledPost{ @@ -458,7 +458,7 @@ func TestProcessScheduledPosts(t *testing.T) { }, ScheduledAt: scheduledAt, } - _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, scheduledPost2) assert.NoError(t, err) appErr := th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.BasicUser.Id, th.BasicUser.Id) @@ -472,7 +472,7 @@ func TestProcessScheduledPosts(t *testing.T) { th.App.ProcessScheduledPosts(th.Context) - scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) assert.NoError(t, err) assert.Len(t, scheduledPosts, 2) @@ -540,7 +540,7 @@ func TestHandleFailedScheduledPosts(t *testing.T) { // Save the failed scheduled posts in the store for _, sp := range failedScheduledPosts { - _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(sp) + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(th.Context, sp) assert.NoError(t, err) } diff --git a/server/channels/app/scheduled_post_test.go b/server/channels/app/scheduled_post_test.go index 89680621caf6..3e95d486109f 100644 --- a/server/channels/app/scheduled_post_test.go +++ b/server/channels/app/scheduled_post_test.go @@ -718,7 +718,7 @@ func TestUpdateScheduledPost(t *testing.T) { require.Equal(t, model.PostTypeBurnOnRead, createdScheduledPost.Type) - fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id) + fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.NotNil(t, fetchedScheduledPost) require.Equal(t, model.PostTypeBurnOnRead, fetchedScheduledPost.Type) @@ -737,13 +737,13 @@ func TestUpdateScheduledPost(t *testing.T) { require.Equal(t, "Updated burn on read message", updatedScheduledPost.Message) // Fetch again from store to verify the type is still burn on read in the database - reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id) + reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.NotNil(t, reFetchedScheduledPost) require.Equal(t, model.PostTypeBurnOnRead, reFetchedScheduledPost.Type) // Try another update with a different type value - verify the type is still NOT changed - existingPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id) + existingPost, err := th.Server.Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) existingPost.Message = "Another update attempt" existingPost.ScheduledAt = model.GetMillis() + 300000 @@ -758,7 +758,7 @@ func TestUpdateScheduledPost(t *testing.T) { require.Equal(t, model.PostTypeBurnOnRead, updatedScheduledPost2.Type) // Final verification from store - finalFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id) + finalFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(th.Context, createdScheduledPost.Id) require.NoError(t, err) require.NotNil(t, finalFetchedScheduledPost) require.Equal(t, model.PostTypeBurnOnRead, finalFetchedScheduledPost.Type) @@ -786,7 +786,7 @@ func TestDeleteScheduledPost(t *testing.T) { require.Nil(t, appErr) require.NotNil(t, createdScheduledPost) - fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) + fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(th.Context, scheduledPost.Id) require.NoError(t, err) require.NotNil(t, fetchedScheduledPost) require.Equal(t, createdScheduledPost.Id, fetchedScheduledPost.Id) @@ -802,7 +802,7 @@ func TestDeleteScheduledPost(t *testing.T) { require.Equal(t, scheduledPost.Message, deletedScheduledPost.Message) // try to fetch it again - reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) + reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(th.Context, scheduledPost.Id) require.Error(t, err) // This will produce error as the row doesn't exist require.Nil(t, reFetchedScheduledPost) }) diff --git a/server/channels/app/user.go b/server/channels/app/user.go index e693c56dbec0..284e5fe01f05 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -2181,7 +2181,7 @@ func (a *App) PermanentDeleteUser(rctx request.CTX, user *model.User) *model.App return model.NewAppError("PermanentDeleteUser", "app.post.permanent_delete_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if err := a.Srv().Store().Reaction().PermanentDeleteByUser(user.Id); err != nil { + if err := a.Srv().Store().Reaction().PermanentDeleteByUser(rctx, user.Id); err != nil { return model.NewAppError("PermanentDeleteUser", "app.reaction.permanent_delete_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 78dd85c87d34..8d64c13bb3c7 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -1618,11 +1618,11 @@ func TestPermanentDeleteUser(t *testing.T) { require.False(t, exists, "Profile image wasn't deleted. err=%v", err) // verify scheduled posts have been deleted - fetchedScheduledPost, scheduledPostErr := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost1.Id) + fetchedScheduledPost, scheduledPostErr := th.App.Srv().Store().ScheduledPost().Get(th.Context, createdScheduledPost1.Id) require.ErrorIs(t, scheduledPostErr, sql.ErrNoRows) require.Nil(t, fetchedScheduledPost) - fetchedScheduledPost, scheduledPostErr = th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost2.Id) + fetchedScheduledPost, scheduledPostErr = th.App.Srv().Store().ScheduledPost().Get(th.Context, createdScheduledPost2.Id) require.ErrorIs(t, scheduledPostErr, sql.ErrNoRows) require.Nil(t, fetchedScheduledPost) } diff --git a/server/channels/store/localcachelayer/reaction_layer.go b/server/channels/store/localcachelayer/reaction_layer.go index 516547bc8e3a..16c724107d23 100644 --- a/server/channels/store/localcachelayer/reaction_layer.go +++ b/server/channels/store/localcachelayer/reaction_layer.go @@ -7,6 +7,7 @@ import ( "bytes" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" ) @@ -53,9 +54,9 @@ func (s LocalCacheReactionStore) GetForPost(postId string, allowFromCache bool) return reaction, nil } -func (s LocalCacheReactionStore) DeleteAllWithEmojiName(emojiName string) error { +func (s LocalCacheReactionStore) DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error { // This could be improved. Right now we just clear the whole // cache because we don't have a way find what post Ids have this emoji name. defer s.rootStore.doClearCacheCluster(s.rootStore.reactionCache) - return s.ReactionStore.DeleteAllWithEmojiName(emojiName) + return s.ReactionStore.DeleteAllWithEmojiName(rctx, emojiName) } diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index e641373e4be5..2681b397566d 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -2722,6 +2722,27 @@ func (s *RetryLayerChannelStore) GetMembersInfoByChannelIds(channelIDs []string) } +func (s *RetryLayerChannelStore) GetMembersWithLastViewedAtSince(rctx request.CTX, channelID string, since int64, afterUserID string, limit int) ([]*model.ChannelMemberLastViewed, error) { + + tries := 0 + for { + result, err := s.ChannelStore.GetMembersWithLastViewedAtSince(rctx, channelID, since, afterUserID, limit) + 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 *RetryLayerChannelStore) GetMoreChannels(teamID string, userID string, offset int, limit int) (model.ChannelList, error) { tries := 0 @@ -4444,11 +4465,11 @@ func (s *RetryLayerChannelMemberHistoryStore) LogJoinEvent(userID string, channe } -func (s *RetryLayerChannelMemberHistoryStore) LogLeaveEvent(userID string, channelID string, leaveTime int64) error { +func (s *RetryLayerChannelMemberHistoryStore) LogLeaveEvent(rctx request.CTX, userID string, channelID string, leaveTime int64) error { tries := 0 for { - err := s.ChannelMemberHistoryStore.LogLeaveEvent(userID, channelID, leaveTime) + err := s.ChannelMemberHistoryStore.LogLeaveEvent(rctx, userID, channelID, leaveTime) if err == nil { return nil } @@ -11065,11 +11086,11 @@ func (s *RetryLayerReactionStore) Delete(reaction *model.Reaction) (*model.React } -func (s *RetryLayerReactionStore) DeleteAllWithEmojiName(emojiName string) error { +func (s *RetryLayerReactionStore) DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error { tries := 0 for { - err := s.ReactionStore.DeleteAllWithEmojiName(emojiName) + err := s.ReactionStore.DeleteAllWithEmojiName(rctx, emojiName) if err == nil { return nil } @@ -11233,11 +11254,11 @@ func (s *RetryLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int6 } -func (s *RetryLayerReactionStore) PermanentDeleteByUser(userID string) error { +func (s *RetryLayerReactionStore) PermanentDeleteByUser(rctx request.CTX, userID string) error { tries := 0 for { - err := s.ReactionStore.PermanentDeleteByUser(userID) + err := s.ReactionStore.PermanentDeleteByUser(rctx, userID) if err == nil { return nil } @@ -12667,11 +12688,11 @@ func (s *RetryLayerRoleStore) SavePreservingUnknownPermissions(role *model.Role) } -func (s *RetryLayerScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { +func (s *RetryLayerScheduledPostStore) CreateScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { tries := 0 for { - result, err := s.ScheduledPostStore.CreateScheduledPost(scheduledPost) + result, err := s.ScheduledPostStore.CreateScheduledPost(rctx, scheduledPost) if err == nil { return result, nil } @@ -12688,11 +12709,11 @@ func (s *RetryLayerScheduledPostStore) CreateScheduledPost(scheduledPost *model. } -func (s *RetryLayerScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { +func (s *RetryLayerScheduledPostStore) Get(rctx request.CTX, scheduledPostId string) (*model.ScheduledPost, error) { tries := 0 for { - result, err := s.ScheduledPostStore.Get(scheduledPostId) + result, err := s.ScheduledPostStore.Get(rctx, scheduledPostId) if err == nil { return result, nil } @@ -12715,11 +12736,11 @@ func (s *RetryLayerScheduledPostStore) GetMaxMessageSize() int { } -func (s *RetryLayerScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { +func (s *RetryLayerScheduledPostStore) GetPendingScheduledPosts(rctx request.CTX, beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { tries := 0 for { - result, err := s.ScheduledPostStore.GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, perPage) + result, err := s.ScheduledPostStore.GetPendingScheduledPosts(rctx, beforeTime, afterTime, lastScheduledPostId, perPage) if err == nil { return result, nil } @@ -12736,11 +12757,11 @@ func (s *RetryLayerScheduledPostStore) GetPendingScheduledPosts(beforeTime int64 } -func (s *RetryLayerScheduledPostStore) GetScheduledPostsForUser(userId string, teamId string) ([]*model.ScheduledPost, error) { +func (s *RetryLayerScheduledPostStore) GetScheduledPostsForUser(rctx request.CTX, userId string, teamId string) ([]*model.ScheduledPost, error) { tries := 0 for { - result, err := s.ScheduledPostStore.GetScheduledPostsForUser(userId, teamId) + result, err := s.ScheduledPostStore.GetScheduledPostsForUser(rctx, userId, teamId) if err == nil { return result, nil } @@ -12799,11 +12820,11 @@ func (s *RetryLayerScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduled } -func (s *RetryLayerScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { +func (s *RetryLayerScheduledPostStore) UpdateOldScheduledPosts(rctx request.CTX, beforeTime int64) error { tries := 0 for { - err := s.ScheduledPostStore.UpdateOldScheduledPosts(beforeTime) + err := s.ScheduledPostStore.UpdateOldScheduledPosts(rctx, beforeTime) if err == nil { return nil } @@ -12820,11 +12841,11 @@ func (s *RetryLayerScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) } -func (s *RetryLayerScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPosts []*model.ScheduledPost) error { +func (s *RetryLayerScheduledPostStore) UpdateRecurringScheduledPosts(rctx request.CTX, scheduledPosts []*model.ScheduledPost) error { tries := 0 for { - err := s.ScheduledPostStore.UpdateRecurringScheduledPosts(scheduledPosts) + err := s.ScheduledPostStore.UpdateRecurringScheduledPosts(rctx, scheduledPosts) if err == nil { return nil } @@ -12841,11 +12862,11 @@ func (s *RetryLayerScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPo } -func (s *RetryLayerScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { +func (s *RetryLayerScheduledPostStore) UpdatedScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) error { tries := 0 for { - err := s.ScheduledPostStore.UpdatedScheduledPost(scheduledPost) + err := s.ScheduledPostStore.UpdatedScheduledPost(rctx, scheduledPost) if err == nil { return nil } diff --git a/server/channels/store/sqlstore/channel_member_history_store.go b/server/channels/store/sqlstore/channel_member_history_store.go index d101cc709f91..7b8f88bc26b8 100644 --- a/server/channels/store/sqlstore/channel_member_history_store.go +++ b/server/channels/store/sqlstore/channel_member_history_store.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -54,7 +55,7 @@ func (s SqlChannelMemberHistoryStore) LogJoinEvent(userId string, channelId stri return nil } -func (s SqlChannelMemberHistoryStore) LogLeaveEvent(userId string, channelId string, leaveTime int64) error { +func (s SqlChannelMemberHistoryStore) LogLeaveEvent(rctx request.CTX, userId string, channelId string, leaveTime int64) error { query, params, err := s.getQueryBuilder(). Update("ChannelMemberHistory"). Set("LeaveTime", leaveTime). @@ -73,7 +74,7 @@ func (s SqlChannelMemberHistoryStore) LogLeaveEvent(userId string, channelId str if rows, err := sqlResult.RowsAffected(); err == nil && rows != 1 { // there was no join event to update - this is best effort, so no need to raise an error - mlog.Warn("Channel join event for user and channel not found", mlog.String("user", userId), mlog.String("channel", channelId)) + rctx.Logger().Warn("Channel join event for user and channel not found", mlog.String("user", userId), mlog.String("channel", channelId)) } return nil } diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 4d8faaa191c5..a89653005f41 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -2473,6 +2473,36 @@ func (s SqlChannelStore) GetMemberLastViewedAt(rctx request.CTX, channelID strin return lastViewedAt, nil } +func (s SqlChannelStore) GetMembersWithLastViewedAtSince(rctx request.CTX, channelID string, since int64, afterUserID string, limit int) ([]*model.ChannelMemberLastViewed, error) { + if limit <= 0 || limit > model.ChannelMemberLastViewedMaxPerPage { + limit = model.ChannelMemberLastViewedMaxPerPage + } + + query := s.getQueryBuilder(). + Select("ChannelMembers.UserId", "COALESCE(ChannelMembers.LastViewedAt, 0) AS LastViewedAt"). + From("ChannelMembers"). + Where(sq.Eq{"ChannelMembers.ChannelId": channelID}). + Where(sq.GtOrEq{"COALESCE(ChannelMembers.LastViewedAt, 0)": since}). + OrderBy("ChannelMembers.UserId ASC"). + Limit(uint64(limit)) + + if afterUserID != "" { + query = query.Where(sq.Gt{"ChannelMembers.UserId": afterUserID}) + } + + queryString, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "get_members_with_last_viewed_at_since_tosql") + } + + members := []*model.ChannelMemberLastViewed{} + if err := s.DBXFromContext(rctx.Context()).Select(&members, queryString, args...); err != nil { + return nil, errors.Wrapf(err, "failed to find channel members with channelId=%s and lastViewedAt>=%d", channelID, since) + } + + return members, nil +} + func (s SqlChannelStore) InvalidateAllChannelMembersForUser(userId string) { } diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index 8b75167e0f43..82432b136b67 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -307,13 +307,13 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m "count": count, "countroot": countRoot, }); err != nil { - mlog.Warn("Error updating Channel LastPostAt.", mlog.Err(err)) + rctx.Logger().Warn("Error updating Channel LastPostAt.", mlog.Err(err)) } } for rootId := range rootIds { if _, err = s.GetMaster().Exec("UPDATE Posts SET UpdateAt = ? WHERE Id = ?", maxDateRootIds[rootId], rootId); err != nil { - mlog.Warn("Error updating Post UpdateAt.", mlog.Err(err)) + rctx.Logger().Warn("Error updating Post UpdateAt.", mlog.Err(err)) } } @@ -331,7 +331,7 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m if len(unknownRepliesPosts) > 0 { if err := s.populateReplyCount(unknownRepliesPosts); err != nil { - mlog.Warn("Unable to populate the reply count in some posts.", mlog.Err(err)) + rctx.Logger().Warn("Unable to populate the reply count in some posts.", mlog.Err(err)) } } @@ -1005,7 +1005,7 @@ func (s *SqlPostStore) Delete(rctx request.CTX, postID string, time int64, delet Set("UpdateAt", time). Where(sq.Eq{"Id": id.RootId}) if _, err = transaction.ExecBuilder(updatePostQuery); err != nil { - mlog.Warn("Error updating Post UpdateAt.", mlog.Err(err)) + rctx.Logger().Warn("Error updating Post UpdateAt.", mlog.Err(err)) } } @@ -2166,7 +2166,7 @@ func (s *SqlPostStore) buildSearchPostFilterClause(teamID string, fromUsers []st } func (s *SqlPostStore) Search(teamId string, userId string, params *model.SearchParams) (*model.PostList, error) { - return s.search(teamId, userId, params, true, true) + return s.search(s.Logger(), teamId, userId, params, true, true) } // splitCJKSearchTerms splits search terms for LIKE usage. @@ -2232,7 +2232,7 @@ func (s *SqlPostStore) buildCJKSearchClause(baseQuery sq.SelectBuilder, searchTy return baseQuery } -func (s *SqlPostStore) search(teamId string, userId string, params *model.SearchParams, channelsByName bool, userByUsername bool) (*model.PostList, error) { +func (s *SqlPostStore) search(logger mlog.LoggerIFace, teamId string, userId string, params *model.SearchParams, channelsByName bool, userByUsername bool) (*model.PostList, error) { list := model.NewPostList() if params.Terms == "" && params.ExcludedTerms == "" && len(params.InChannels) == 0 && len(params.ExcludedChannels) == 0 && @@ -2363,7 +2363,7 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search var posts []*model.Post if err := s.GetSearchReplicaX().SelectBuilder(&posts, baseQuery); err != nil { - mlog.Warn("Query error searching posts.", mlog.String("error", trimInput(err.Error()))) + logger.Warn("Query error searching posts.", mlog.String("error", trimInput(err.Error()))) // Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results. } else { for _, p := range posts { @@ -2929,7 +2929,7 @@ func (s *SqlPostStore) SearchPostsForUser(rctx request.CTX, paramsList []*model. go func(params *model.SearchParams) { defer wg.Done() - postList, err := s.search(teamId, userId, params, false, false) + postList, err := s.search(rctx.Logger(), teamId, userId, params, false, false) pchan <- store.StoreResult[*model.PostList]{Data: postList, NErr: err} }(params) } diff --git a/server/channels/store/sqlstore/reaction_store.go b/server/channels/store/sqlstore/reaction_store.go index cb6058321527..d2e3e1f3b06b 100644 --- a/server/channels/store/sqlstore/reaction_store.go +++ b/server/channels/store/sqlstore/reaction_store.go @@ -12,6 +12,7 @@ import ( "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" @@ -208,7 +209,7 @@ func (s *SqlReactionStore) GetSingle(userID, postID, remoteID, emojiName string) return reactions[0], nil } -func (s *SqlReactionStore) DeleteAllWithEmojiName(emojiName string) error { +func (s *SqlReactionStore) DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error { var reactions []*model.Reaction now := model.GetMillis() @@ -242,7 +243,7 @@ func (s *SqlReactionStore) DeleteAllWithEmojiName(emojiName string) error { for _, reaction := range reactions { _, err := s.GetMaster().Exec(UpdatePostHasReactionsOnDeleteQuery, now, reaction.PostId, reaction.PostId) if err != nil { - mlog.Warn("Unable to update Post.HasReactions while removing reactions", + rctx.Logger().Warn("Unable to update Post.HasReactions while removing reactions", mlog.String("post_id", reaction.PostId), mlog.Err(err)) } @@ -281,7 +282,7 @@ func (s *SqlReactionStore) permanentDeleteReactions(userId string) ([]string, er return postIds, nil } -func (s SqlReactionStore) PermanentDeleteByUser(userId string) error { +func (s SqlReactionStore) PermanentDeleteByUser(rctx request.CTX, userId string) error { now := model.GetMillis() postIds, err := s.permanentDeleteReactions(userId) @@ -298,7 +299,7 @@ func (s SqlReactionStore) PermanentDeleteByUser(userId string) error { for _, postId := range postIds { _, err = transaction.Exec(UpdatePostHasReactionsOnDeleteQuery, now, postId, postId) if err != nil { - mlog.Warn("Unable to update Post.HasReactions while removing reactions", + rctx.Logger().Warn("Unable to update Post.HasReactions while removing reactions", mlog.String("post_id", postId), mlog.Err(err)) } diff --git a/server/channels/store/sqlstore/scheduled_post_store.go b/server/channels/store/sqlstore/scheduled_post_store.go index 871705b83be7..4c6dc0a78fe7 100644 --- a/server/channels/store/sqlstore/scheduled_post_store.go +++ b/server/channels/store/sqlstore/scheduled_post_store.go @@ -9,6 +9,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" sq "github.com/mattermost/squirrel" "github.com/pkg/errors" ) @@ -85,7 +86,7 @@ func (s *SqlScheduledPostStore) scheduledPostToSlice(scheduledPost *model.Schedu } } -func (s *SqlScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { +func (s *SqlScheduledPostStore) CreateScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { scheduledPost.PreSave() builder := s.getQueryBuilder(). @@ -95,19 +96,19 @@ func (s *SqlScheduledPostStore) CreateScheduledPost(scheduledPost *model.Schedul query, args, err := builder.ToSql() if err != nil { - mlog.Error("SqlScheduledPostStore.CreateScheduledPost failed to generate SQL from query builder", mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.CreateScheduledPost failed to generate SQL from query builder", mlog.Err(err)) return nil, errors.Wrap(err, "SqlScheduledPostStore.CreateScheduledPost failed to generate SQL from query builder") } if _, err := s.GetMaster().Exec(query, args...); err != nil { - mlog.Error("SqlScheduledPostStore.CreateScheduledPost failed to insert scheduled post", mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.CreateScheduledPost failed to insert scheduled post", mlog.Err(err)) return nil, errors.Wrap(err, "SqlScheduledPostStore.CreateScheduledPost failed to insert scheduled post") } return scheduledPost, nil } -func (s *SqlScheduledPostStore) GetScheduledPostsForUser(userId, teamId string) ([]*model.ScheduledPost, error) { +func (s *SqlScheduledPostStore) GetScheduledPostsForUser(rctx request.CTX, userId, teamId string) ([]*model.ScheduledPost, error) { // return scheduled posts for this user for // specified team. // @@ -133,7 +134,7 @@ func (s *SqlScheduledPostStore) GetScheduledPostsForUser(userId, teamId string) var scheduledPosts []*model.ScheduledPost if err := s.GetReplica().SelectBuilder(&scheduledPosts, query); err != nil { - mlog.Error("SqlScheduledPostStore.GetScheduledPostsForUser: failed to fetch scheduled posts for user", mlog.String("user_id", userId), mlog.String("team_id", teamId), mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.GetScheduledPostsForUser: failed to fetch scheduled posts for user", mlog.String("user_id", userId), mlog.String("team_id", teamId), mlog.Err(err)) return nil, errors.Wrapf(err, "SqlScheduledPostStore.GetScheduledPostsForUser: failed to fetch scheduled posts for user, userId: %s, teamID: %s", userId, teamId) } @@ -154,7 +155,7 @@ func (s *SqlScheduledPostStore) GetMaxMessageSize() int { return s.maxMessageSizeCached } -func (s *SqlScheduledPostStore) GetPendingScheduledPosts(beforeTime, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { +func (s *SqlScheduledPostStore) GetPendingScheduledPosts(rctx request.CTX, beforeTime, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { // The ScheduledAt <= beforeTime bound stays outside the keyset tie-break so Postgres can // use it as the boundary of idx_scheduledposts_pending_scheduled_at_id; the equivalent // pure OR form would force scanning the index from the top on every page. @@ -183,7 +184,7 @@ func (s *SqlScheduledPostStore) GetPendingScheduledPosts(beforeTime, afterTime i // replica can return stale data, causing already-processed posts to reappear on later pages. var scheduledPosts []*model.ScheduledPost if err := s.GetMaster().SelectBuilder(&scheduledPosts, query); err != nil { - mlog.Error( + rctx.Logger().Error( "SqlScheduledPostStore.GetPendingScheduledPosts: failed to fetch pending scheduled posts for processing", mlog.Int("before_time", beforeTime), mlog.String("last_scheduled_post_id", lastScheduledPostId), @@ -227,7 +228,7 @@ func (s *SqlScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduledPostIDs // UpdatedScheduledPost persists the scheduled post as given; ProcessedAt and ErrorCode are // caller-owned and stored verbatim. -func (s *SqlScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { +func (s *SqlScheduledPostStore) UpdatedScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) error { scheduledPost.PreUpdate() builder := s.getQueryBuilder(). @@ -237,13 +238,13 @@ func (s *SqlScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.Schedu query, args, err := builder.ToSql() if err != nil { - mlog.Error("SqlScheduledPostStore.UpdatedScheduledPost failed to generate SQL from updating scheduled posts", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.UpdatedScheduledPost failed to generate SQL from updating scheduled posts", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err)) return errors.Wrap(err, "SqlScheduledPostStore.UpdatedScheduledPost failed to generate SQL from bulk updating scheduled posts") } _, err = s.GetMaster().Exec(query, args...) if err != nil { - mlog.Error("SqlScheduledPostStore.UpdatedScheduledPost failed to update scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.UpdatedScheduledPost failed to update scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err)) return errors.Wrap(err, "SqlScheduledPostStore.UpdatedScheduledPost failed to update scheduled post") } @@ -253,7 +254,7 @@ func (s *SqlScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.Schedu // UpdateRecurringScheduledPosts advances recurring scheduled posts to their next occurrence in a // single query, persisting each post's ScheduledAt and resetting ErrorCode/ProcessedAt so the posts // are pending again. It also stamps UpdateAt on the given posts. -func (s *SqlScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPosts []*model.ScheduledPost) error { +func (s *SqlScheduledPostStore) UpdateRecurringScheduledPosts(rctx request.CTX, scheduledPosts []*model.ScheduledPost) error { if len(scheduledPosts) == 0 { return nil } @@ -279,12 +280,12 @@ func (s *SqlScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPosts []* query, args, err := builder.ToSql() if err != nil { - mlog.Error("SqlScheduledPostStore.UpdateRecurringScheduledPosts failed to generate SQL from updating scheduled posts", mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.UpdateRecurringScheduledPosts failed to generate SQL from updating scheduled posts", mlog.Err(err)) return errors.Wrap(err, "SqlScheduledPostStore.UpdateRecurringScheduledPosts failed to generate SQL from updating scheduled posts") } if _, err := s.GetMaster().Exec(query, args...); err != nil { - mlog.Error("SqlScheduledPostStore.UpdateRecurringScheduledPosts failed to update scheduled posts", mlog.Int("scheduled_post_count", len(scheduledPosts)), mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.UpdateRecurringScheduledPosts failed to update scheduled posts", mlog.Int("scheduled_post_count", len(scheduledPosts)), mlog.Err(err)) return errors.Wrap(err, "SqlScheduledPostStore.UpdateRecurringScheduledPosts failed to update scheduled posts") } @@ -307,7 +308,7 @@ func (s *SqlScheduledPostStore) toUpdateMap(scheduledPost *model.ScheduledPost) } } -func (s *SqlScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { +func (s *SqlScheduledPostStore) Get(rctx request.CTX, scheduledPostId string) (*model.ScheduledPost, error) { query := s.getQueryBuilder(). Select(s.columnsForRead("")...). From("ScheduledPosts"). @@ -318,7 +319,7 @@ func (s *SqlScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPos scheduledPost := &model.ScheduledPost{} if err := s.GetReplica().GetBuilder(scheduledPost, query); err != nil { - mlog.Error("SqlScheduledPostStore.Get: failed to get single scheduled post by ID from database", mlog.String("scheduled_post_id", scheduledPostId), mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.Get: failed to get single scheduled post by ID from database", mlog.String("scheduled_post_id", scheduledPostId), mlog.Err(err)) return nil, errors.Wrapf(err, "SqlScheduledPostStore.Get: failed to get single scheduled post by ID from database, scheduledPostId: %s", scheduledPostId) } @@ -326,7 +327,7 @@ func (s *SqlScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPos return scheduledPost, nil } -func (s *SqlScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { +func (s *SqlScheduledPostStore) UpdateOldScheduledPosts(rctx request.CTX, beforeTime int64) error { builder := s.getQueryBuilder(). Update("ScheduledPosts"). Set("ErrorCode", model.ScheduledPostErrorUnableToSend). @@ -339,13 +340,13 @@ func (s *SqlScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error query, args, err := builder.ToSql() if err != nil { - mlog.Error("SqlScheduledPostStore.UpdateOldScheduledPosts failed to generate SQL from updating old scheduled posts", mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.UpdateOldScheduledPosts failed to generate SQL from updating old scheduled posts", mlog.Err(err)) return errors.Wrap(err, "SqlScheduledPostStore.UpdateOldScheduledPosts failed to generate SQL from updating old scheduled posts") } _, err = s.GetMaster().Exec(query, args...) if err != nil { - mlog.Error("SqlScheduledPostStore.UpdateOldScheduledPosts failed to update old scheduled posts", mlog.Err(err)) + rctx.Logger().Error("SqlScheduledPostStore.UpdateOldScheduledPosts failed to update old scheduled posts", mlog.Err(err)) return errors.Wrap(err, "SqlScheduledPostStore.UpdateOldScheduledPosts failed to update old scheduled posts") } diff --git a/server/channels/store/sqlstore/scheduled_post_store_test.go b/server/channels/store/sqlstore/scheduled_post_store_test.go index 50ea5f29cfc0..86ec6a34582b 100644 --- a/server/channels/store/sqlstore/scheduled_post_store_test.go +++ b/server/channels/store/sqlstore/scheduled_post_store_test.go @@ -10,6 +10,7 @@ import ( "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/storetest" ) @@ -28,6 +29,7 @@ func TestGetPendingScheduledPostsReadsFromMaster(t *testing.T) { } logger := mlog.CreateTestLogger(t) + rctx := request.EmptyContext(logger) masterSettings, err := makeSqlSettings(model.DatabaseDriverPostgres) if err != nil { @@ -67,7 +69,7 @@ func TestGetPendingScheduledPostsReadsFromMaster(t *testing.T) { }, ScheduledAt: model.GetMillis(), } - createdScheduledPost, err := store.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := store.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) require.NoError(t, err) require.NotEmpty(t, createdScheduledPost.Id) @@ -78,7 +80,7 @@ func TestGetPendingScheduledPostsReadsFromMaster(t *testing.T) { beforeTime := createdScheduledPost.ScheduledAt + 1000 afterTime := createdScheduledPost.ScheduledAt - (24 * 60 * 60 * 1000) - pending, err := store.ScheduledPost().GetPendingScheduledPosts(beforeTime, afterTime, "", 10) + pending, err := store.ScheduledPost().GetPendingScheduledPosts(rctx, beforeTime, afterTime, "", 10) require.NoError(t, err) require.Len(t, pending, 1, "pending posts must be read from master, not the empty replica") require.Equal(t, createdScheduledPost.Id, pending[0].Id) diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 4d4c8846fa2c..9dae3375727a 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -249,6 +249,7 @@ type ChannelStore interface { GetMembers(opts model.ChannelMembersGetOptions) (model.ChannelMembers, error) GetMember(rctx request.CTX, channelID string, userID string) (*model.ChannelMember, error) GetMemberLastViewedAt(rctx request.CTX, channelID string, userID string) (int64, error) + GetMembersWithLastViewedAtSince(rctx request.CTX, channelID string, since int64, afterUserID string, limit int) ([]*model.ChannelMemberLastViewed, error) GetChannelMembersTimezones(channelID string) ([]model.StringMap, error) GetAllChannelMembersForUser(rctx request.CTX, userID string, allowFromCache bool, includeDeleted bool) (map[string]string, error) GetChannelsMemberCount(channelIDs []string) (map[string]int64, error) @@ -340,7 +341,7 @@ type ChannelStore interface { type ChannelMemberHistoryStore interface { LogJoinEvent(userID string, channelID string, joinTime int64) error - LogLeaveEvent(userID string, channelID string, leaveTime int64) error + LogLeaveEvent(rctx request.CTX, userID string, channelID string, leaveTime int64) error GetEverMembersInChannel(channelID string, userIDs []string) ([]string, error) GetUsersInChannelDuring(startTime int64, endTime int64, channelID []string) ([]*model.ChannelMemberHistoryResult, error) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) @@ -815,12 +816,12 @@ type ReactionStore interface { GetForPostSince(postID string, since int64, excludeRemoteID string, inclDeleted bool) ([]*model.Reaction, error) GetUniqueCountForPost(postID string) (int, error) ExistsOnPost(postID string, emojiName string) (bool, error) - DeleteAllWithEmojiName(emojiName string) error + DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error BulkGetForPosts(postIds []string) ([]*model.Reaction, error) GetSingle(userID, postID, remoteID, emojiName string) (*model.Reaction, error) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) (int64, error) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) - PermanentDeleteByUser(userID string) error + PermanentDeleteByUser(rctx request.CTX, userID string) error } type JobStore interface { @@ -1169,14 +1170,14 @@ type ViewStore interface { type ScheduledPostStore interface { GetMaxMessageSize() int - CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) - GetScheduledPostsForUser(userId, teamId string) ([]*model.ScheduledPost, error) - GetPendingScheduledPosts(beforeTime, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) + CreateScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) + GetScheduledPostsForUser(rctx request.CTX, userId, teamId string) ([]*model.ScheduledPost, error) + GetPendingScheduledPosts(rctx request.CTX, beforeTime, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) PermanentlyDeleteScheduledPosts(scheduledPostIDs []string) error - UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error - UpdateRecurringScheduledPosts(scheduledPosts []*model.ScheduledPost) error - Get(scheduledPostId string) (*model.ScheduledPost, error) - UpdateOldScheduledPosts(beforeTime int64) error + UpdatedScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) error + UpdateRecurringScheduledPosts(rctx request.CTX, scheduledPosts []*model.ScheduledPost) error + Get(rctx request.CTX, scheduledPostId string) (*model.ScheduledPost, error) + UpdateOldScheduledPosts(rctx request.CTX, beforeTime int64) error PermanentDeleteByUser(userId string) error } diff --git a/server/channels/store/storetest/channel_member_history_store.go b/server/channels/store/storetest/channel_member_history_store.go index 192db3afe81f..b71e8b42e096 100644 --- a/server/channels/store/storetest/channel_member_history_store.go +++ b/server/channels/store/storetest/channel_member_history_store.go @@ -72,7 +72,7 @@ func testGetEverMembersInChannel(t *testing.T, rctx request.CTX, ss store.Store) const baseTime int64 = 1700000000000 // user1 has historical rows (joined, left, and rejoined) and should be returned once. require.NoError(t, ss.ChannelMemberHistory().LogJoinEvent(user1, channel.Id, baseTime)) - require.NoError(t, ss.ChannelMemberHistory().LogLeaveEvent(user1, channel.Id, baseTime+100)) + require.NoError(t, ss.ChannelMemberHistory().LogLeaveEvent(rctx, user1, channel.Id, baseTime+100)) require.NoError(t, ss.ChannelMemberHistory().LogJoinEvent(user1, channel.Id, baseTime+200)) // other users are simple joins. require.NoError(t, ss.ChannelMemberHistory().LogJoinEvent(user2, channel.Id, baseTime)) @@ -144,7 +144,7 @@ func testLogLeaveEvent(t *testing.T, rctx request.CTX, ss store.Store) { err = ss.ChannelMemberHistory().LogJoinEvent(user.Id, channel.Id, model.GetMillis()) assert.NoError(t, err) - err = ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, model.GetMillis()) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user.Id, channel.Id, model.GetMillis()) assert.NoError(t, err) } @@ -212,7 +212,7 @@ func testGetChannelsWithActivityDuring(t *testing.T, rctx request.CTX, ss store. assert.Empty(t, channelIds) // case 2: user1 leaves, shows activity - err = ss.ChannelMemberHistory().LogLeaveEvent(user1.Id, channel1.Id, now+1) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user1.Id, channel1.Id, now+1) require.NoError(t, err) channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now, now+1000) @@ -282,9 +282,9 @@ func testGetChannelsWithActivityDuring(t *testing.T, rctx request.CTX, ss store. } _, err = ss.Post().Save(rctx, post2) require.NoError(t, err) - err = ss.ChannelMemberHistory().LogLeaveEvent(user1.Id, channel1.Id, now+12) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user1.Id, channel1.Id, now+12) require.NoError(t, err) - err = ss.ChannelMemberHistory().LogLeaveEvent(user2.Id, channel2.Id, now+13) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user2.Id, channel2.Id, now+13) require.NoError(t, err) channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now+10, now+1000) @@ -330,7 +330,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX, joinTime := leaveTime - 10000 err = ss.ChannelMemberHistory().LogJoinEvent(user.Id, channel.Id, joinTime) require.NoError(t, err) - err = ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, leaveTime) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user.Id, channel.Id, leaveTime) require.NoError(t, err) // log a join event @@ -367,7 +367,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX, assert.Nil(t, channelMembers[0].LeaveTime) // add a leave time for the user - err = ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, leaveTime) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user.Id, channel.Id, leaveTime) require.NoError(t, err) // case 4: user joins the channel before the export period begins, but has not yet left the channel when the export period ends @@ -548,7 +548,7 @@ func testPermanentDeleteBatch(t *testing.T, rctx request.CTX, ss store.Store) { joinTime := leaveTime - 10000 err = ss.ChannelMemberHistory().LogJoinEvent(user.Id, channel.Id, joinTime) require.NoError(t, err) - err = ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, leaveTime) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user.Id, channel.Id, leaveTime) require.NoError(t, err) // user2 joins the channel but never leaves @@ -598,7 +598,7 @@ func testPermanentDeleteBatchForRetentionPolicies(t *testing.T, rctx request.CTX leaveTime := int64(1500) err = ss.ChannelMemberHistory().LogJoinEvent(userID, channel.Id, joinTime) require.NoError(t, err) - err = ss.ChannelMemberHistory().LogLeaveEvent(userID, channel.Id, leaveTime) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, userID, channel.Id, leaveTime) require.NoError(t, err) channelPolicy, err := ss.RetentionPolicy().Save(&model.RetentionPolicyWithTeamAndChannelIDs{ @@ -653,7 +653,7 @@ func testGetChannelsLeftSince(t *testing.T, rctx request.CTX, ss store.Store) { assert.Empty(t, ids) // left - err = ss.ChannelMemberHistory().LogLeaveEvent(userID, channel.Id, joinTime+100) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, userID, channel.Id, joinTime+100) require.NoError(t, err) ids, err = ss.ChannelMemberHistory().GetChannelsLeftSince(userID, joinTime+100) require.NoError(t, err) @@ -665,7 +665,7 @@ func testGetChannelsLeftSince(t *testing.T, rctx request.CTX, ss store.Store) { // joined and left again. err = ss.ChannelMemberHistory().LogJoinEvent(userID, channel.Id, joinTime+200) require.NoError(t, err) - err = ss.ChannelMemberHistory().LogLeaveEvent(userID, channel.Id, joinTime+300) + err = ss.ChannelMemberHistory().LogLeaveEvent(rctx, userID, channel.Id, joinTime+300) require.NoError(t, err) // should be same for both time stamps ids, err = ss.ChannelMemberHistory().GetChannelsLeftSince(userID, joinTime+100) @@ -778,8 +778,8 @@ func testGetMembershipChanges(t *testing.T, rctx request.CTX, ss store.Store) { require.NoError(t, ss.ChannelMemberHistory().LogJoinEvent(user1, channel.Id, 1000)) require.NoError(t, ss.ChannelMemberHistory().LogJoinEvent(user2, channel.Id, 2000)) require.NoError(t, ss.ChannelMemberHistory().LogJoinEvent(user3, channel.Id, 3000)) - require.NoError(t, ss.ChannelMemberHistory().LogLeaveEvent(user1, channel.Id, 4000)) - require.NoError(t, ss.ChannelMemberHistory().LogLeaveEvent(user2, channel.Id, 5000)) + require.NoError(t, ss.ChannelMemberHistory().LogLeaveEvent(rctx, user1, channel.Id, 4000)) + require.NoError(t, ss.ChannelMemberHistory().LogLeaveEvent(rctx, user2, channel.Id, 5000)) t.Run("returns all events since timestamp zero", func(t *testing.T) { results, err := ss.ChannelMemberHistory().GetMembershipChanges(channel.Id, 0, 100) diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go index b10a9ef512f8..2f9cfda85b0a 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -129,6 +129,7 @@ func TestChannelStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore t.Run("UpdateChannelMember", func(t *testing.T) { testUpdateChannelMember(t, rctx, ss) }) t.Run("GetMember", func(t *testing.T) { testGetMember(t, rctx, ss) }) t.Run("GetMemberLastViewedAt", func(t *testing.T) { testGetMemberLastViewedAt(t, rctx, ss) }) + t.Run("GetMembersWithLastViewedAtSince", func(t *testing.T) { testGetMembersWithLastViewedAtSince(t, rctx, ss, s) }) t.Run("GetMemberForPost", func(t *testing.T) { testChannelStoreGetMemberForPost(t, rctx, ss) }) t.Run("GetMemberCount", func(t *testing.T) { testGetMemberCount(t, rctx, ss) }) t.Run("GetMemberCountsByGroup", func(t *testing.T) { testGetMemberCountsByGroup(t, rctx, ss) }) @@ -5332,6 +5333,180 @@ func testGetMemberLastViewedAt(t *testing.T, rctx request.CTX, ss store.Store) { ss.Channel().InvalidateCacheForChannelMembersNotifyProps(c2.Id) } +func testGetMembersWithLastViewedAtSince(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + saveMember := func(t *testing.T, channelID, userID string, lastViewedAt int64) { + t.Helper() + _, err := ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: channelID, + UserId: userID, + NotifyProps: model.GetDefaultChannelNotifyProps(), + LastViewedAt: lastViewedAt, + }) + require.NoError(t, err) + } + + userIDs := func(members []*model.ChannelMemberLastViewed) []string { + out := make([]string, 0, len(members)) + for _, m := range members { + out = append(out, m.UserId) + } + return out + } + + t.Run("filters at or after the threshold", func(t *testing.T) { + c := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr := ss.Channel().Save(rctx, c, -1) + require.NoError(t, nErr) + + before, atBoundary, after := model.NewId(), model.NewId(), model.NewId() + saveMember(t, c.Id, before, 99) + saveMember(t, c.Id, atBoundary, 100) + saveMember(t, c.Id, after, 101) + + members, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 100, "", 0) + require.NoError(t, err) + + // The boundary case is load-bearing: UpdateLastViewedAt sets LastViewedAt to + // Channels.LastPostAt, so a user who reads a channel whose newest post is the one + // being reported on lands on exact equality. It must be included. + require.ElementsMatch(t, []string{atBoundary, after}, userIDs(members)) + }) + + t.Run("returns the coalesced LastViewedAt", func(t *testing.T) { + c := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr := ss.Channel().Save(rctx, c, -1) + require.NoError(t, nErr) + + userID := model.NewId() + saveMember(t, c.Id, userID, 12345) + + members, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 0, "", 0) + require.NoError(t, err) + require.Len(t, members, 1) + require.Equal(t, userID, members[0].UserId) + require.Equal(t, int64(12345), members[0].LastViewedAt) + }) + + //t.Run("treats a NULL LastViewedAt as zero", func(t *testing.T) { + // c := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + // _, nErr := ss.Channel().Save(rctx, c, -1) + // require.NoError(t, nErr) + // + // nullUser, realUser := model.NewId(), model.NewId() + // saveMember(t, c.Id, nullUser, 0) + // saveMember(t, c.Id, realUser, 500) + // + // // SaveMember cannot write a NULL, so go around it. + // _, err := s.GetMaster().Exec(`UPDATE ChannelMembers SET LastViewedAt = NULL WHERE ChannelId = ? AND UserId = ?`, c.Id, nullUser) + // require.NoError(t, err) + // + // members, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 1, "", 0) + // require.NoError(t, err) + // require.Equal(t, []string{realUser}, userIDs(members), "a NULL LastViewedAt must not match a positive threshold") + // + // members, err = ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 0, "", 0) + // require.NoError(t, err) + // require.ElementsMatch(t, []string{nullUser, realUser}, userIDs(members)) + // for _, m := range members { + // if m.UserId == nullUser { + // require.Equal(t, int64(0), m.LastViewedAt, "NULL must scan as 0, not panic") + // } + // } + //}) + + t.Run("is scoped to the channel", func(t *testing.T) { + c1 := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr := ss.Channel().Save(rctx, c1, -1) + require.NoError(t, nErr) + c2 := &model.Channel{TeamId: c1.TeamId, DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr = ss.Channel().Save(rctx, c2, -1) + require.NoError(t, nErr) + + inC1, inC2 := model.NewId(), model.NewId() + saveMember(t, c1.Id, inC1, 500) + saveMember(t, c2.Id, inC2, 500) + + members, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c1.Id, 0, "", 0) + require.NoError(t, err) + require.Equal(t, []string{inC1}, userIDs(members)) + }) + + t.Run("returns members of an archived channel", func(t *testing.T) { + c := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr := ss.Channel().Save(rctx, c, -1) + require.NoError(t, nErr) + + userID := model.NewId() + saveMember(t, c.Id, userID, 500) + + require.NoError(t, ss.Channel().Delete(c.Id, model.GetMillis())) + + members, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 0, "", 0) + require.NoError(t, err) + require.Equal(t, []string{userID}, userIDs(members), "archiving is soft; ChannelMembers rows survive it") + }) + + t.Run("returns an empty result rather than an error", func(t *testing.T) { + c := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr := ss.Channel().Save(rctx, c, -1) + require.NoError(t, nErr) + + members, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 0, "", 0) + require.NoError(t, err) + require.Empty(t, members) + + members, err = ss.Channel().GetMembersWithLastViewedAtSince(rctx, model.NewId(), 0, "", 0) + require.NoError(t, err, "a non-existent channel is an empty result, not an error") + require.Empty(t, members) + }) + + t.Run("pages by keyset without gaps or duplicates", func(t *testing.T) { + c := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr := ss.Channel().Save(rctx, c, -1) + require.NoError(t, nErr) + + const total = 25 + expected := make([]string, 0, total) + for range total { + userID := model.NewId() + expected = append(expected, userID) + saveMember(t, c.Id, userID, 500) + } + + var got []string + after := "" + for { + page, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 0, after, 10) + require.NoError(t, err) + if len(page) == 0 { + break + } + require.LessOrEqual(t, len(page), 10) + got = append(got, userIDs(page)...) + after = page[len(page)-1].UserId + } + + require.ElementsMatch(t, expected, got) + require.True(t, sort.StringsAreSorted(got), "results must be ordered by UserId so the cursor is stable") + }) + + t.Run("clamps the limit", func(t *testing.T) { + c := &model.Channel{TeamId: model.NewId(), DisplayName: model.NewId(), Name: model.NewId(), Type: model.ChannelTypeOpen} + _, nErr := ss.Channel().Save(rctx, c, -1) + require.NoError(t, nErr) + + for range 3 { + saveMember(t, c.Id, model.NewId(), 500) + } + + for _, limit := range []int{0, -1, model.ChannelMemberLastViewedMaxPerPage + 1} { + members, err := ss.Channel().GetMembersWithLastViewedAtSince(rctx, c.Id, 0, "", limit) + require.NoError(t, err) + require.Len(t, members, 3) + } + }) +} + func testChannelStoreGetMemberForPost(t *testing.T, rctx request.CTX, ss store.Store) { ch := &model.Channel{ TeamId: model.NewId(), diff --git a/server/channels/store/storetest/group_store.go b/server/channels/store/storetest/group_store.go index 365b8392e0af..e740e8528cb4 100644 --- a/server/channels/store/storetest/group_store.go +++ b/server/channels/store/storetest/group_store.go @@ -2348,7 +2348,7 @@ func testChannelMembersToAdd(t *testing.T, rctx request.CTX, ss store.Store) { require.Empty(t, channelMembers) // Leaving Channel (ChannelMemberHistory) should still not return result - nErr = ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, model.GetMillis()) + nErr = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user.Id, channel.Id, model.GetMillis()) require.NoError(t, nErr) channelMembers, err = ss.Group().ChannelMembersToAdd(0, nil, false) require.NoError(t, err) @@ -2366,7 +2366,7 @@ func testChannelMembersToAdd(t *testing.T, rctx request.CTX, ss store.Store) { require.Len(t, channelMembers, 1) // If reAddRemovedMembers is set to true, removed members should be added back in - nErr = ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, model.GetMillis()) + nErr = ss.ChannelMemberHistory().LogLeaveEvent(rctx, user.Id, channel.Id, model.GetMillis()) require.NoError(t, nErr) channelMembers, err = ss.Group().ChannelMembersToAdd(0, nil, true) require.NoError(t, err) diff --git a/server/channels/store/storetest/mocks/ChannelMemberHistoryStore.go b/server/channels/store/storetest/mocks/ChannelMemberHistoryStore.go index 8709f4ea6efb..e2cc373bc83c 100644 --- a/server/channels/store/storetest/mocks/ChannelMemberHistoryStore.go +++ b/server/channels/store/storetest/mocks/ChannelMemberHistoryStore.go @@ -6,6 +6,7 @@ package mocks import ( model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" mock "github.com/stretchr/testify/mock" ) @@ -210,17 +211,17 @@ func (_m *ChannelMemberHistoryStore) LogJoinEvent(userID string, channelID strin return r0 } -// LogLeaveEvent provides a mock function with given fields: userID, channelID, leaveTime -func (_m *ChannelMemberHistoryStore) LogLeaveEvent(userID string, channelID string, leaveTime int64) error { - ret := _m.Called(userID, channelID, leaveTime) +// LogLeaveEvent provides a mock function with given fields: rctx, userID, channelID, leaveTime +func (_m *ChannelMemberHistoryStore) LogLeaveEvent(rctx request.CTX, userID string, channelID string, leaveTime int64) error { + ret := _m.Called(rctx, userID, channelID, leaveTime) if len(ret) == 0 { panic("no return value specified for LogLeaveEvent") } var r0 error - if rf, ok := ret.Get(0).(func(string, string, int64) error); ok { - r0 = rf(userID, channelID, leaveTime) + if rf, ok := ret.Get(0).(func(request.CTX, string, string, int64) error); ok { + r0 = rf(rctx, userID, channelID, leaveTime) } else { r0 = ret.Error(0) } diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index 1f4014690803..b2826308a8b5 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -1894,6 +1894,36 @@ func (_m *ChannelStore) GetMembersInfoByChannelIds(channelIDs []string) (map[str return r0, r1 } +// GetMembersWithLastViewedAtSince provides a mock function with given fields: rctx, channelID, since, afterUserID, limit +func (_m *ChannelStore) GetMembersWithLastViewedAtSince(rctx request.CTX, channelID string, since int64, afterUserID string, limit int) ([]*model.ChannelMemberLastViewed, error) { + ret := _m.Called(rctx, channelID, since, afterUserID, limit) + + if len(ret) == 0 { + panic("no return value specified for GetMembersWithLastViewedAtSince") + } + + var r0 []*model.ChannelMemberLastViewed + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX, string, int64, string, int) ([]*model.ChannelMemberLastViewed, error)); ok { + return rf(rctx, channelID, since, afterUserID, limit) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, int64, string, int) []*model.ChannelMemberLastViewed); ok { + r0 = rf(rctx, channelID, since, afterUserID, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ChannelMemberLastViewed) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, int64, string, int) error); ok { + r1 = rf(rctx, channelID, since, afterUserID, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetMoreChannels provides a mock function with given fields: teamID, userID, offset, limit func (_m *ChannelStore) GetMoreChannels(teamID string, userID string, offset int, limit int) (model.ChannelList, error) { ret := _m.Called(teamID, userID, offset, limit) diff --git a/server/channels/store/storetest/mocks/ReactionStore.go b/server/channels/store/storetest/mocks/ReactionStore.go index 009aac66830d..ec99d895a404 100644 --- a/server/channels/store/storetest/mocks/ReactionStore.go +++ b/server/channels/store/storetest/mocks/ReactionStore.go @@ -6,6 +6,7 @@ package mocks import ( model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" mock "github.com/stretchr/testify/mock" ) @@ -74,17 +75,17 @@ func (_m *ReactionStore) Delete(reaction *model.Reaction) (*model.Reaction, erro return r0, r1 } -// DeleteAllWithEmojiName provides a mock function with given fields: emojiName -func (_m *ReactionStore) DeleteAllWithEmojiName(emojiName string) error { - ret := _m.Called(emojiName) +// DeleteAllWithEmojiName provides a mock function with given fields: rctx, emojiName +func (_m *ReactionStore) DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error { + ret := _m.Called(rctx, emojiName) if len(ret) == 0 { panic("no return value specified for DeleteAllWithEmojiName") } var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(emojiName) + if rf, ok := ret.Get(0).(func(request.CTX, string) error); ok { + r0 = rf(rctx, emojiName) } else { r0 = ret.Error(0) } @@ -294,17 +295,17 @@ func (_m *ReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64 return r0, r1 } -// PermanentDeleteByUser provides a mock function with given fields: userID -func (_m *ReactionStore) PermanentDeleteByUser(userID string) error { - ret := _m.Called(userID) +// PermanentDeleteByUser provides a mock function with given fields: rctx, userID +func (_m *ReactionStore) PermanentDeleteByUser(rctx request.CTX, userID string) error { + ret := _m.Called(rctx, userID) if len(ret) == 0 { panic("no return value specified for PermanentDeleteByUser") } var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(userID) + if rf, ok := ret.Get(0).(func(request.CTX, string) error); ok { + r0 = rf(rctx, userID) } else { r0 = ret.Error(0) } diff --git a/server/channels/store/storetest/mocks/ScheduledPostStore.go b/server/channels/store/storetest/mocks/ScheduledPostStore.go index 9851f297ebef..8982fb6e0936 100644 --- a/server/channels/store/storetest/mocks/ScheduledPostStore.go +++ b/server/channels/store/storetest/mocks/ScheduledPostStore.go @@ -6,6 +6,7 @@ package mocks import ( model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" mock "github.com/stretchr/testify/mock" ) @@ -14,9 +15,9 @@ type ScheduledPostStore struct { mock.Mock } -// CreateScheduledPost provides a mock function with given fields: scheduledPost -func (_m *ScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { - ret := _m.Called(scheduledPost) +// CreateScheduledPost provides a mock function with given fields: rctx, scheduledPost +func (_m *ScheduledPostStore) CreateScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { + ret := _m.Called(rctx, scheduledPost) if len(ret) == 0 { panic("no return value specified for CreateScheduledPost") @@ -24,19 +25,19 @@ func (_m *ScheduledPostStore) CreateScheduledPost(scheduledPost *model.Scheduled var r0 *model.ScheduledPost var r1 error - if rf, ok := ret.Get(0).(func(*model.ScheduledPost) (*model.ScheduledPost, error)); ok { - return rf(scheduledPost) + if rf, ok := ret.Get(0).(func(request.CTX, *model.ScheduledPost) (*model.ScheduledPost, error)); ok { + return rf(rctx, scheduledPost) } - if rf, ok := ret.Get(0).(func(*model.ScheduledPost) *model.ScheduledPost); ok { - r0 = rf(scheduledPost) + if rf, ok := ret.Get(0).(func(request.CTX, *model.ScheduledPost) *model.ScheduledPost); ok { + r0 = rf(rctx, scheduledPost) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ScheduledPost) } } - if rf, ok := ret.Get(1).(func(*model.ScheduledPost) error); ok { - r1 = rf(scheduledPost) + if rf, ok := ret.Get(1).(func(request.CTX, *model.ScheduledPost) error); ok { + r1 = rf(rctx, scheduledPost) } else { r1 = ret.Error(1) } @@ -44,9 +45,9 @@ func (_m *ScheduledPostStore) CreateScheduledPost(scheduledPost *model.Scheduled return r0, r1 } -// Get provides a mock function with given fields: scheduledPostId -func (_m *ScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { - ret := _m.Called(scheduledPostId) +// Get provides a mock function with given fields: rctx, scheduledPostId +func (_m *ScheduledPostStore) Get(rctx request.CTX, scheduledPostId string) (*model.ScheduledPost, error) { + ret := _m.Called(rctx, scheduledPostId) if len(ret) == 0 { panic("no return value specified for Get") @@ -54,19 +55,19 @@ func (_m *ScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, var r0 *model.ScheduledPost var r1 error - if rf, ok := ret.Get(0).(func(string) (*model.ScheduledPost, error)); ok { - return rf(scheduledPostId) + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.ScheduledPost, error)); ok { + return rf(rctx, scheduledPostId) } - if rf, ok := ret.Get(0).(func(string) *model.ScheduledPost); ok { - r0 = rf(scheduledPostId) + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.ScheduledPost); ok { + r0 = rf(rctx, scheduledPostId) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ScheduledPost) } } - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(scheduledPostId) + if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok { + r1 = rf(rctx, scheduledPostId) } else { r1 = ret.Error(1) } @@ -92,9 +93,9 @@ func (_m *ScheduledPostStore) GetMaxMessageSize() int { return r0 } -// GetPendingScheduledPosts provides a mock function with given fields: beforeTime, afterTime, lastScheduledPostId, perPage -func (_m *ScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { - ret := _m.Called(beforeTime, afterTime, lastScheduledPostId, perPage) +// GetPendingScheduledPosts provides a mock function with given fields: rctx, beforeTime, afterTime, lastScheduledPostId, perPage +func (_m *ScheduledPostStore) GetPendingScheduledPosts(rctx request.CTX, beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { + ret := _m.Called(rctx, beforeTime, afterTime, lastScheduledPostId, perPage) if len(ret) == 0 { panic("no return value specified for GetPendingScheduledPosts") @@ -102,19 +103,19 @@ func (_m *ScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTi var r0 []*model.ScheduledPost var r1 error - if rf, ok := ret.Get(0).(func(int64, int64, string, uint64) ([]*model.ScheduledPost, error)); ok { - return rf(beforeTime, afterTime, lastScheduledPostId, perPage) + if rf, ok := ret.Get(0).(func(request.CTX, int64, int64, string, uint64) ([]*model.ScheduledPost, error)); ok { + return rf(rctx, beforeTime, afterTime, lastScheduledPostId, perPage) } - if rf, ok := ret.Get(0).(func(int64, int64, string, uint64) []*model.ScheduledPost); ok { - r0 = rf(beforeTime, afterTime, lastScheduledPostId, perPage) + if rf, ok := ret.Get(0).(func(request.CTX, int64, int64, string, uint64) []*model.ScheduledPost); ok { + r0 = rf(rctx, beforeTime, afterTime, lastScheduledPostId, perPage) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.ScheduledPost) } } - if rf, ok := ret.Get(1).(func(int64, int64, string, uint64) error); ok { - r1 = rf(beforeTime, afterTime, lastScheduledPostId, perPage) + if rf, ok := ret.Get(1).(func(request.CTX, int64, int64, string, uint64) error); ok { + r1 = rf(rctx, beforeTime, afterTime, lastScheduledPostId, perPage) } else { r1 = ret.Error(1) } @@ -122,9 +123,9 @@ func (_m *ScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTi return r0, r1 } -// GetScheduledPostsForUser provides a mock function with given fields: userId, teamId -func (_m *ScheduledPostStore) GetScheduledPostsForUser(userId string, teamId string) ([]*model.ScheduledPost, error) { - ret := _m.Called(userId, teamId) +// GetScheduledPostsForUser provides a mock function with given fields: rctx, userId, teamId +func (_m *ScheduledPostStore) GetScheduledPostsForUser(rctx request.CTX, userId string, teamId string) ([]*model.ScheduledPost, error) { + ret := _m.Called(rctx, userId, teamId) if len(ret) == 0 { panic("no return value specified for GetScheduledPostsForUser") @@ -132,19 +133,19 @@ func (_m *ScheduledPostStore) GetScheduledPostsForUser(userId string, teamId str var r0 []*model.ScheduledPost var r1 error - if rf, ok := ret.Get(0).(func(string, string) ([]*model.ScheduledPost, error)); ok { - return rf(userId, teamId) + if rf, ok := ret.Get(0).(func(request.CTX, string, string) ([]*model.ScheduledPost, error)); ok { + return rf(rctx, userId, teamId) } - if rf, ok := ret.Get(0).(func(string, string) []*model.ScheduledPost); ok { - r0 = rf(userId, teamId) + if rf, ok := ret.Get(0).(func(request.CTX, string, string) []*model.ScheduledPost); ok { + r0 = rf(rctx, userId, teamId) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.ScheduledPost) } } - if rf, ok := ret.Get(1).(func(string, string) error); ok { - r1 = rf(userId, teamId) + if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok { + r1 = rf(rctx, userId, teamId) } else { r1 = ret.Error(1) } @@ -188,17 +189,17 @@ func (_m *ScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduledPostIDs [ return r0 } -// UpdateOldScheduledPosts provides a mock function with given fields: beforeTime -func (_m *ScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { - ret := _m.Called(beforeTime) +// UpdateOldScheduledPosts provides a mock function with given fields: rctx, beforeTime +func (_m *ScheduledPostStore) UpdateOldScheduledPosts(rctx request.CTX, beforeTime int64) error { + ret := _m.Called(rctx, beforeTime) if len(ret) == 0 { panic("no return value specified for UpdateOldScheduledPosts") } var r0 error - if rf, ok := ret.Get(0).(func(int64) error); ok { - r0 = rf(beforeTime) + if rf, ok := ret.Get(0).(func(request.CTX, int64) error); ok { + r0 = rf(rctx, beforeTime) } else { r0 = ret.Error(0) } @@ -206,17 +207,17 @@ func (_m *ScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { return r0 } -// UpdateRecurringScheduledPosts provides a mock function with given fields: scheduledPosts -func (_m *ScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPosts []*model.ScheduledPost) error { - ret := _m.Called(scheduledPosts) +// UpdateRecurringScheduledPosts provides a mock function with given fields: rctx, scheduledPosts +func (_m *ScheduledPostStore) UpdateRecurringScheduledPosts(rctx request.CTX, scheduledPosts []*model.ScheduledPost) error { + ret := _m.Called(rctx, scheduledPosts) if len(ret) == 0 { panic("no return value specified for UpdateRecurringScheduledPosts") } var r0 error - if rf, ok := ret.Get(0).(func([]*model.ScheduledPost) error); ok { - r0 = rf(scheduledPosts) + if rf, ok := ret.Get(0).(func(request.CTX, []*model.ScheduledPost) error); ok { + r0 = rf(rctx, scheduledPosts) } else { r0 = ret.Error(0) } @@ -224,17 +225,17 @@ func (_m *ScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPosts []*mo return r0 } -// UpdatedScheduledPost provides a mock function with given fields: scheduledPost -func (_m *ScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { - ret := _m.Called(scheduledPost) +// UpdatedScheduledPost provides a mock function with given fields: rctx, scheduledPost +func (_m *ScheduledPostStore) UpdatedScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) error { + ret := _m.Called(rctx, scheduledPost) if len(ret) == 0 { panic("no return value specified for UpdatedScheduledPost") } var r0 error - if rf, ok := ret.Get(0).(func(*model.ScheduledPost) error); ok { - r0 = rf(scheduledPost) + if rf, ok := ret.Get(0).(func(request.CTX, *model.ScheduledPost) error); ok { + r0 = rf(rctx, scheduledPost) } else { r0 = ret.Error(0) } diff --git a/server/channels/store/storetest/reaction_store.go b/server/channels/store/storetest/reaction_store.go index a520217e4cc3..4c78fb0ae32d 100644 --- a/server/channels/store/storetest/reaction_store.go +++ b/server/channels/store/storetest/reaction_store.go @@ -536,7 +536,7 @@ func testReactionDeleteAllWithEmojiName(t *testing.T, rctx request.CTX, ss store } } - err := ss.Reaction().DeleteAllWithEmojiName(emojiToDelete) + err := ss.Reaction().DeleteAllWithEmojiName(rctx, emojiToDelete) require.NoError(t, err) // check that the reactions were deleted @@ -621,7 +621,7 @@ func testPermanentDeleteByUser(t *testing.T, rctx request.CTX, ss store.Store) { require.NoError(t, err) } - err := ss.Reaction().PermanentDeleteByUser(userId) + err := ss.Reaction().PermanentDeleteByUser(rctx, userId) require.NoError(t, err) // check that the reactions were deleted @@ -875,7 +875,7 @@ func testReactionDeadlock(t *testing.T, rctx request.CTX, ss store.Store) { // 1st tx go func() { defer wg.Done() - err := ss.Reaction().DeleteAllWithEmojiName(reaction1.EmojiName) + err := ss.Reaction().DeleteAllWithEmojiName(rctx, reaction1.EmojiName) require.NoError(t, err) }() diff --git a/server/channels/store/storetest/scheduled_post_store.go b/server/channels/store/storetest/scheduled_post_store.go index 3be523be2c4f..16409ec46779 100644 --- a/server/channels/store/storetest/scheduled_post_store.go +++ b/server/channels/store/storetest/scheduled_post_store.go @@ -52,7 +52,7 @@ func testCreateScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s S ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) @@ -60,7 +60,7 @@ func testCreateScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s S _ = ss.ScheduledPost().PermanentlyDeleteScheduledPosts([]string{createdScheduledPost.Id}) }() - scheduledPostsFromDatabase, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, "team_id_1") + scheduledPostsFromDatabase, err := ss.ScheduledPost().GetScheduledPostsForUser(rctx, userId, "team_id_1") assert.NoError(t, err) require.Equal(t, 1, len(scheduledPostsFromDatabase)) assert.Equal(t, scheduledPost.Id, scheduledPostsFromDatabase[0].Id) @@ -80,7 +80,7 @@ func testCreateScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s S ScheduledAt: model.GetMillis() - 100000, // 100 seconds in the past } - _, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + _, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) defer func() { @@ -102,7 +102,7 @@ func testCreateScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s S RepeatTimezone: "America/New_York", } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) @@ -110,7 +110,7 @@ func testCreateScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s S _ = ss.ScheduledPost().PermanentlyDeleteScheduledPosts([]string{createdScheduledPost.Id}) }() - scheduledPostsFromDatabase, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, "team_id_1") + scheduledPostsFromDatabase, err := ss.ScheduledPost().GetScheduledPostsForUser(rctx, userId, "team_id_1") assert.NoError(t, err) require.Len(t, scheduledPostsFromDatabase, 1) assert.Equal(t, model.ScheduledPostRepeatTypeWeekly, scheduledPostsFromDatabase[0].RepeatType) @@ -122,7 +122,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql t.Run("should handle no scheduled posts exist", func(t *testing.T) { apr2022 := time.Date(2100, time.April, 1, 1, 0, 0, 0, time.UTC) afterTime := time.Date(2100, time.March, 1, 1, 0, 0, 0, time.UTC) - scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(apr2022), model.GetMillisForTime(afterTime), "", 10) + scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(rctx, model.GetMillisForTime(apr2022), model.GetMillisForTime(afterTime), "", 10) assert.NoError(t, err) assert.Equal(t, 0, len(scheduledPosts)) }) @@ -141,7 +141,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql ScheduledAt: model.GetMillisForTime(jan2100), } - createdScheduledPost1, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost1) + createdScheduledPost1, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost1) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost1.Id) @@ -156,7 +156,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql ScheduledAt: model.GetMillisForTime(feb2100), } - createdScheduledPost2, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost2) + createdScheduledPost2, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost2) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost2.Id) @@ -171,7 +171,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql ScheduledAt: model.GetMillisForTime(mar2100), } - createdScheduledPost3, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost3) + createdScheduledPost3, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost3) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost3.Id) @@ -185,19 +185,19 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql apr2022 := time.Date(2100, time.April, 1, 1, 0, 0, 0, time.UTC) afterTime := time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC) - scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(apr2022), model.GetMillisForTime(afterTime), "", 10) + scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(rctx, model.GetMillisForTime(apr2022), model.GetMillisForTime(afterTime), "", 10) assert.NoError(t, err) assert.Equal(t, 3, len(scheduledPosts)) mar2100midnight := time.Date(2100, time.March, 1, 0, 0, 0, 0, time.UTC) afterTime = time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC) - scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(mar2100midnight), model.GetMillisForTime(afterTime), "", 10) + scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(rctx, model.GetMillisForTime(mar2100midnight), model.GetMillisForTime(afterTime), "", 10) assert.NoError(t, err) assert.Equal(t, 2, len(scheduledPosts)) jan2100Midnight := time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC) afterTime = time.Date(2099, time.December, 31, 0, 0, 0, 0, time.UTC) - scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(jan2100Midnight), model.GetMillisForTime(afterTime), "", 10) + scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(rctx, model.GetMillisForTime(jan2100Midnight), model.GetMillisForTime(afterTime), "", 10) assert.NoError(t, err) assert.Equal(t, 0, len(scheduledPosts)) }) @@ -219,7 +219,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql ScheduledAt: scheduledAt, } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) require.NoError(t, err) createdIDs = append(createdIDs, createdScheduledPost.Id) } @@ -237,7 +237,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql var seenIDs []string for { - page, err := ss.ScheduledPost().GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, perPage) + page, err := ss.ScheduledPost().GetPendingScheduledPosts(rctx, beforeTime, afterTime, lastScheduledPostId, perPage) require.NoError(t, err) if len(page) == 0 { break @@ -267,7 +267,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql RepeatTimezone: "UTC", } - createdRecurringScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(recurringScheduledPost) + createdRecurringScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, recurringScheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdRecurringScheduledPost.Id) @@ -277,7 +277,7 @@ func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s Sql beforeTime := model.GetMillisForTime(time.Date(2100, time.March, 1, 1, 0, 0, 0, time.UTC)) afterTime := model.GetMillisForTime(time.Date(2100, time.February, 1, 1, 0, 0, 0, time.UTC)) - scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(beforeTime, afterTime, "", 10) + scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(rctx, beforeTime, afterTime, "", 10) assert.NoError(t, err) require.Len(t, scheduledPosts, 1) assert.Equal(t, createdRecurringScheduledPost.Id, scheduledPosts[0].Id) @@ -297,7 +297,7 @@ func testPermanentlyDeleteScheduledPosts(t *testing.T, rctx request.CTX, ss stor ScheduledAt: model.GetMillis() + 100000, } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) @@ -312,7 +312,7 @@ func testPermanentlyDeleteScheduledPosts(t *testing.T, rctx request.CTX, ss stor ScheduledAt: model.GetMillis() + 100000, } - createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) @@ -327,7 +327,7 @@ func testPermanentlyDeleteScheduledPosts(t *testing.T, rctx request.CTX, ss stor ScheduledAt: model.GetMillis() + 100000, } - createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) @@ -342,13 +342,13 @@ func testPermanentlyDeleteScheduledPosts(t *testing.T, rctx request.CTX, ss stor ScheduledAt: model.GetMillis() + 100000, } - createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) // verify 4 scheduled posts exist - scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillis()+50000000, model.GetMillis()-100000000, "", 10) + scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(rctx, model.GetMillis()+50000000, model.GetMillis()-100000000, "", 10) assert.NoError(t, err) assert.Equal(t, 4, len(scheduledPosts)) @@ -357,7 +357,7 @@ func testPermanentlyDeleteScheduledPosts(t *testing.T, rctx request.CTX, ss stor assert.NoError(t, err) // now there should be no posts - scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillis()+50000000, model.GetMillis()-100000000, "", 10) + scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(rctx, model.GetMillis()+50000000, model.GetMillis()-100000000, "", 10) assert.NoError(t, err) assert.Equal(t, 0, len(scheduledPosts)) } @@ -389,7 +389,7 @@ func testUpdatedScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s ScheduledAt: model.GetMillis(), } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) @@ -422,11 +422,11 @@ func testUpdatedScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s }, } - err = ss.ScheduledPost().UpdatedScheduledPost(updateSchedulePost) + err = ss.ScheduledPost().UpdatedScheduledPost(rctx, updateSchedulePost) assert.NoError(t, err) // now we'll get it and verify that intended fields updated and other fields did not - userScheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, channel.TeamId) + userScheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(rctx, userId, channel.TeamId) assert.NoError(t, err) assert.Equal(t, 1, len(userScheduledPosts)) @@ -458,7 +458,7 @@ func testUpdatedScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s ScheduledAt: model.GetMillis() - (24 * 60 * 60 * 1000), // 1 day in the past } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) @@ -467,10 +467,10 @@ func testUpdatedScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError scheduledPost.ProcessedAt = now - err = ss.ScheduledPost().UpdatedScheduledPost(scheduledPost) + err = ss.ScheduledPost().UpdatedScheduledPost(rctx, scheduledPost) assert.NoError(t, err) - updatedScheduledPost, err := ss.ScheduledPost().Get(scheduledPost.Id) + updatedScheduledPost, err := ss.ScheduledPost().Get(rctx, scheduledPost.Id) assert.NoError(t, err) assert.LessOrEqual(t, now, updatedScheduledPost.ProcessedAt) assert.Equal(t, model.ScheduledPostErrorUnknownError, updatedScheduledPost.ErrorCode) @@ -505,7 +505,7 @@ func testUpdateRecurringScheduledPosts(t *testing.T, rctx request.CTX, ss store. RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - firstCreated, err := ss.ScheduledPost().CreateScheduledPost(firstScheduledPost) + firstCreated, err := ss.ScheduledPost().CreateScheduledPost(rctx, firstScheduledPost) require.NoError(t, err) require.NotEmpty(t, firstCreated.Id) @@ -520,7 +520,7 @@ func testUpdateRecurringScheduledPosts(t *testing.T, rctx request.CTX, ss store. RepeatType: model.ScheduledPostRepeatTypeWeekly, RepeatTimezone: "UTC", } - secondCreated, err := ss.ScheduledPost().CreateScheduledPost(secondScheduledPost) + secondCreated, err := ss.ScheduledPost().CreateScheduledPost(rctx, secondScheduledPost) require.NoError(t, err) require.NotEmpty(t, secondCreated.Id) @@ -537,10 +537,10 @@ func testUpdateRecurringScheduledPosts(t *testing.T, rctx request.CTX, ss store. secondCreated.ErrorCode = "" secondCreated.ProcessedAt = 0 - err = ss.ScheduledPost().UpdateRecurringScheduledPosts([]*model.ScheduledPost{firstCreated, secondCreated}) + err = ss.ScheduledPost().UpdateRecurringScheduledPosts(rctx, []*model.ScheduledPost{firstCreated, secondCreated}) require.NoError(t, err) - updatedFirst, err := ss.ScheduledPost().Get(firstCreated.Id) + updatedFirst, err := ss.ScheduledPost().Get(rctx, firstCreated.Id) require.NoError(t, err) require.NotNil(t, updatedFirst) assert.Equal(t, firstNextAt, updatedFirst.ScheduledAt) @@ -548,7 +548,7 @@ func testUpdateRecurringScheduledPosts(t *testing.T, rctx request.CTX, ss store. assert.Zero(t, updatedFirst.ProcessedAt) assert.GreaterOrEqual(t, updatedFirst.UpdateAt, firstCreated.UpdateAt) - updatedSecond, err := ss.ScheduledPost().Get(secondCreated.Id) + updatedSecond, err := ss.ScheduledPost().Get(rctx, secondCreated.Id) require.NoError(t, err) require.NotNil(t, updatedSecond) assert.Equal(t, secondNextAt, updatedSecond.ScheduledAt) @@ -558,7 +558,7 @@ func testUpdateRecurringScheduledPosts(t *testing.T, rctx request.CTX, ss store. }) t.Run("should not fail for empty input", func(t *testing.T) { - err := ss.ScheduledPost().UpdateRecurringScheduledPosts(nil) + err := ss.ScheduledPost().UpdateRecurringScheduledPosts(rctx, nil) require.NoError(t, err) }) } @@ -585,7 +585,7 @@ func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, ScheduledAt: baseTime + 86400000, // 1 day in the future } - createdScheduledPost1, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost1) + createdScheduledPost1, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost1) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost1.Id) @@ -602,7 +602,7 @@ func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, ScheduledAt: baseTime + (2 * 86400000), // 2 days in the future } - createdScheduledPost2, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost2) + createdScheduledPost2, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost2) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost2.Id) @@ -619,7 +619,7 @@ func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, ScheduledAt: baseTime + (3 * 86400000), // 3 days in the future } - createdScheduledPost3, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost3) + createdScheduledPost3, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost3) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost3.Id) @@ -636,7 +636,7 @@ func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, ScheduledAt: baseTime + (4 * 86400000), // 4 days in the future } - createdScheduledPost4, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost4) + createdScheduledPost4, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost4) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost4.Id) @@ -659,10 +659,10 @@ func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, cleanup := setupScheduledPosts(now, userId, teamId) defer cleanup() - err := ss.ScheduledPost().UpdateOldScheduledPosts(now + 2.5*86400000) // marking all posts older than 2 days from now + err := ss.ScheduledPost().UpdateOldScheduledPosts(rctx, now+2.5*86400000) // marking all posts older than 2 days from now assert.NoError(t, err) - scheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, teamId) + scheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(rctx, userId, teamId) assert.NoError(t, err) assert.Equal(t, 4, len(scheduledPosts)) assert.Equal(t, model.ScheduledPostErrorUnableToSend, scheduledPosts[0].ErrorCode) @@ -694,7 +694,7 @@ func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, RepeatTimezone: "UTC", } - createdRecurringScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(recurringScheduledPost) + createdRecurringScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, recurringScheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdRecurringScheduledPost.Id) @@ -703,10 +703,10 @@ func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, _ = ss.Channel().PermanentDelete(rctx, createdChannel.Id) }() - err = ss.ScheduledPost().UpdateOldScheduledPosts(model.GetMillis()) + err = ss.ScheduledPost().UpdateOldScheduledPosts(rctx, model.GetMillis()) assert.NoError(t, err) - storedRecurringScheduledPost, err := ss.ScheduledPost().Get(createdRecurringScheduledPost.Id) + storedRecurringScheduledPost, err := ss.ScheduledPost().Get(rctx, createdRecurringScheduledPost.Id) assert.NoError(t, err) require.NotNil(t, storedRecurringScheduledPost) assert.Empty(t, storedRecurringScheduledPost.ErrorCode) @@ -730,7 +730,7 @@ func testPermanentDeleteScheduledPostsByUser(t *testing.T, rctx request.CTX, ss ScheduledAt: model.GetMillis() + 100000, } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) @@ -739,7 +739,7 @@ func testPermanentDeleteScheduledPostsByUser(t *testing.T, rctx request.CTX, ss assert.NoError(t, err) // Verify that no scheduled posts exist for the user - scheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, teamId) + scheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(rctx, userId, teamId) assert.NoError(t, err) assert.Empty(t, scheduledPosts) }) @@ -768,7 +768,7 @@ func testPermanentDeleteScheduledPostsByUser(t *testing.T, rctx request.CTX, ss ScheduledAt: model.GetMillis() + 100000, } - createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(rctx, scheduledPost) assert.NoError(t, err) assert.NotEmpty(t, createdScheduledPost.Id) } @@ -778,7 +778,7 @@ func testPermanentDeleteScheduledPostsByUser(t *testing.T, rctx request.CTX, ss assert.NoError(t, err) // Verify that no scheduled posts exist for the user - scheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, teamId) + scheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(rctx, userId, teamId) assert.NoError(t, err) assert.Empty(t, scheduledPosts) }) diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 06315cd20c57..5e301eddbc49 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -2289,6 +2289,22 @@ func (s *TimerLayerChannelStore) GetMembersInfoByChannelIds(channelIDs []string) return result, err } +func (s *TimerLayerChannelStore) GetMembersWithLastViewedAtSince(rctx request.CTX, channelID string, since int64, afterUserID string, limit int) ([]*model.ChannelMemberLastViewed, error) { + start := time.Now() + + result, err := s.ChannelStore.GetMembersWithLastViewedAtSince(rctx, channelID, since, afterUserID, limit) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.GetMembersWithLastViewedAtSince", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelStore) GetMoreChannels(teamID string, userID string, offset int, limit int) (model.ChannelList, error) { start := time.Now() @@ -3674,10 +3690,10 @@ func (s *TimerLayerChannelMemberHistoryStore) LogJoinEvent(userID string, channe return err } -func (s *TimerLayerChannelMemberHistoryStore) LogLeaveEvent(userID string, channelID string, leaveTime int64) error { +func (s *TimerLayerChannelMemberHistoryStore) LogLeaveEvent(rctx request.CTX, userID string, channelID string, leaveTime int64) error { start := time.Now() - err := s.ChannelMemberHistoryStore.LogLeaveEvent(userID, channelID, leaveTime) + err := s.ChannelMemberHistoryStore.LogLeaveEvent(rctx, userID, channelID, leaveTime) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -8804,10 +8820,10 @@ func (s *TimerLayerReactionStore) Delete(reaction *model.Reaction) (*model.React return result, err } -func (s *TimerLayerReactionStore) DeleteAllWithEmojiName(emojiName string) error { +func (s *TimerLayerReactionStore) DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error { start := time.Now() - err := s.ReactionStore.DeleteAllWithEmojiName(emojiName) + err := s.ReactionStore.DeleteAllWithEmojiName(rctx, emojiName) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -8932,10 +8948,10 @@ func (s *TimerLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int6 return result, err } -func (s *TimerLayerReactionStore) PermanentDeleteByUser(userID string) error { +func (s *TimerLayerReactionStore) PermanentDeleteByUser(rctx request.CTX, userID string) error { start := time.Now() - err := s.ReactionStore.PermanentDeleteByUser(userID) + err := s.ReactionStore.PermanentDeleteByUser(rctx, userID) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -10035,10 +10051,10 @@ func (s *TimerLayerRoleStore) SavePreservingUnknownPermissions(role *model.Role) return result, err } -func (s *TimerLayerScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { +func (s *TimerLayerScheduledPostStore) CreateScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { start := time.Now() - result, err := s.ScheduledPostStore.CreateScheduledPost(scheduledPost) + result, err := s.ScheduledPostStore.CreateScheduledPost(rctx, scheduledPost) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -10051,10 +10067,10 @@ func (s *TimerLayerScheduledPostStore) CreateScheduledPost(scheduledPost *model. return result, err } -func (s *TimerLayerScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { +func (s *TimerLayerScheduledPostStore) Get(rctx request.CTX, scheduledPostId string) (*model.ScheduledPost, error) { start := time.Now() - result, err := s.ScheduledPostStore.Get(scheduledPostId) + result, err := s.ScheduledPostStore.Get(rctx, scheduledPostId) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -10083,10 +10099,10 @@ func (s *TimerLayerScheduledPostStore) GetMaxMessageSize() int { return result } -func (s *TimerLayerScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { +func (s *TimerLayerScheduledPostStore) GetPendingScheduledPosts(rctx request.CTX, beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { start := time.Now() - result, err := s.ScheduledPostStore.GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, perPage) + result, err := s.ScheduledPostStore.GetPendingScheduledPosts(rctx, beforeTime, afterTime, lastScheduledPostId, perPage) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -10099,10 +10115,10 @@ func (s *TimerLayerScheduledPostStore) GetPendingScheduledPosts(beforeTime int64 return result, err } -func (s *TimerLayerScheduledPostStore) GetScheduledPostsForUser(userId string, teamId string) ([]*model.ScheduledPost, error) { +func (s *TimerLayerScheduledPostStore) GetScheduledPostsForUser(rctx request.CTX, userId string, teamId string) ([]*model.ScheduledPost, error) { start := time.Now() - result, err := s.ScheduledPostStore.GetScheduledPostsForUser(userId, teamId) + result, err := s.ScheduledPostStore.GetScheduledPostsForUser(rctx, userId, teamId) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -10147,10 +10163,10 @@ func (s *TimerLayerScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduled return err } -func (s *TimerLayerScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { +func (s *TimerLayerScheduledPostStore) UpdateOldScheduledPosts(rctx request.CTX, beforeTime int64) error { start := time.Now() - err := s.ScheduledPostStore.UpdateOldScheduledPosts(beforeTime) + err := s.ScheduledPostStore.UpdateOldScheduledPosts(rctx, beforeTime) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -10163,10 +10179,10 @@ func (s *TimerLayerScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) return err } -func (s *TimerLayerScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPosts []*model.ScheduledPost) error { +func (s *TimerLayerScheduledPostStore) UpdateRecurringScheduledPosts(rctx request.CTX, scheduledPosts []*model.ScheduledPost) error { start := time.Now() - err := s.ScheduledPostStore.UpdateRecurringScheduledPosts(scheduledPosts) + err := s.ScheduledPostStore.UpdateRecurringScheduledPosts(rctx, scheduledPosts) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -10179,10 +10195,10 @@ func (s *TimerLayerScheduledPostStore) UpdateRecurringScheduledPosts(scheduledPo return err } -func (s *TimerLayerScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { +func (s *TimerLayerScheduledPostStore) UpdatedScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) error { start := time.Now() - err := s.ScheduledPostStore.UpdatedScheduledPost(scheduledPost) + err := s.ScheduledPostStore.UpdatedScheduledPost(rctx, scheduledPost) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { diff --git a/server/config/file_test.go b/server/config/file_test.go index fbb4bd2fa2be..786aefa1f754 100644 --- a/server/config/file_test.go +++ b/server/config/file_test.go @@ -497,6 +497,21 @@ func TestFileStoreSet(t *testing.T) { assert.Equal(t, "", *configStore.Get().ServiceSettings.SiteURL) }) + t.Run("AppsEnabled feature flag rejected", func(t *testing.T) { + configStore, tearDown := setupConfigFileStore(t, emptyConfig) + defer tearDown() + + newCfg := &model.Config{} + newCfg.FeatureFlags = &model.FeatureFlags{AppsEnabled: true} + + _, _, err := configStore.Set(newCfg) + if assert.Error(t, err) { + assert.EqualError(t, err, "new configuration is invalid: FeatureFlags.IsValid: model.config.is_valid.feature_flags.apps_enabled.app_error") + } + + assert.False(t, configStore.Get().FeatureFlags.AppsEnabled) + }) + t.Run("read-only", func(t *testing.T) { configStore, tearDown := setupConfigFileStore(t, readOnlyConfig) defer tearDown() diff --git a/server/i18n/en.json b/server/i18n/en.json index 194cb16b7f03..d4c500f462d2 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2197,6 +2197,10 @@ "id": "api.data_spillage.error.user_not_reviewer", "translation": "The user is not a content reviewer." }, + { + "id": "api.data_spillage.exposure.write.app_error", + "translation": "Failed to write the post exposure report." + }, { "id": "api.data_spillage.report.open.app_error", "translation": "Failed to open the generated flagged post report file." @@ -6162,6 +6166,114 @@ "id": "app.data_spillage.create_property_values.app_error", "translation": "Unable to save property values for the flagged post." }, + { + "id": "app.data_spillage.exposure.column.email", + "translation": "Email" + }, + { + "id": "app.data_spillage.exposure.column.is_deactivated", + "translation": "Deactivated" + }, + { + "id": "app.data_spillage.exposure.column.is_guest", + "translation": "Guest" + }, + { + "id": "app.data_spillage.exposure.column.is_remote", + "translation": "Remote user" + }, + { + "id": "app.data_spillage.exposure.column.last_activity_at", + "translation": "User last activity time (YYYY-MM-DDTHH:MM:SSZ)" + }, + { + "id": "app.data_spillage.exposure.column.last_viewed_at", + "translation": "Last viewed channel at (YYYY-MM-DDTHH:MM:SSZ)" + }, + { + "id": "app.data_spillage.exposure.column.user_id", + "translation": "User ID" + }, + { + "id": "app.data_spillage.exposure.column.username", + "translation": "Username" + }, + { + "id": "app.data_spillage.exposure.column.was_channel_member", + "translation": "Was channel member" + }, + { + "id": "app.data_spillage.exposure.edit_history_post.app_error", + "translation": "Cannot generate an exposure report for an edit history revision. Use the current post ID." + }, + { + "id": "app.data_spillage.exposure.get_channel_members.app_error", + "translation": "Failed to retrieve the channel membership history for the flagged post." + }, + { + "id": "app.data_spillage.exposure.get_last_activity.app_error", + "translation": "Failed to retrieve session activity for the flagged post." + }, + { + "id": "app.data_spillage.exposure.get_possible_viewers.app_error", + "translation": "Failed to retrieve channel read state for the flagged post." + }, + { + "id": "app.data_spillage.exposure.meta.channel", + "translation": "Channel" + }, + { + "id": "app.data_spillage.exposure.meta.generated_at", + "translation": "Generated at" + }, + { + "id": "app.data_spillage.exposure.meta.post_id", + "translation": "Post ID" + }, + { + "id": "app.data_spillage.exposure.meta.report_version", + "translation": "Report version" + }, + { + "id": "app.data_spillage.exposure.meta.total_users", + "translation": "Total users" + }, + { + "id": "app.data_spillage.exposure.meta.window_end", + "translation": "Flagged at" + }, + { + "id": "app.data_spillage.exposure.meta.window_start", + "translation": "Post created at" + }, + { + "id": "app.data_spillage.exposure.missing_reporting_time.app_error", + "translation": "The flagged post has no reporting time recorded, so the exposure window cannot be determined." + }, + { + "id": "app.data_spillage.exposure.unsupported_channel_type.app_error", + "translation": "Exposure reports are not supported for direct and group messages." + }, + { + "id": "app.data_spillage.exposure.value.never_viewed", + "translation": "N/A" + }, + { + "id": "app.data_spillage.exposure.value.no", + "translation": "No" + }, + { + "id": "app.data_spillage.exposure.value.no_sessions", + "translation": "No sessions found" + }, + { + "id": "app.data_spillage.exposure.value.unknown", + "translation": "Unknown" + }, + { + "id": "app.data_spillage.exposure.value.yes", + "translation": "Yes" + }, { "id": "app.data_spillage.flag_post.marshal_comment.app_error", "translation": "Failed to marshal flagging user's comment" @@ -6412,6 +6524,10 @@ "id": "app.data_spillage.report.write_edit_yaml.app_error", "translation": "Failed to write edit history entry to the flagged post report." }, + { + "id": "app.data_spillage.report.write_exposure_csv.app_error", + "translation": "Failed to write the exposure report to the flagged post report." + }, { "id": "app.data_spillage.report.write_metadata_yaml.app_error", "translation": "Failed to write metadata to the flagged post report." @@ -11962,6 +12078,10 @@ "id": "model.config.is_valid.extract_content_timeout.app_error", "translation": "Invalid content extraction timeout for file settings. Must be a whole number of seconds greater than or equal to zero." }, + { + "id": "model.config.is_valid.feature_flags.apps_enabled.app_error", + "translation": "The AppsEnabled feature flag is no longer supported and must be disabled." + }, { "id": "model.config.is_valid.feature_flags.move_threads_enabled.app_error", "translation": "The MoveThreadsEnabled feature flag is no longer supported and must be disabled." diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 2e9732eb4ec3..8d047029c58e 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -540,4 +540,5 @@ const ( AuditEventUpdateContentFlaggingConfig = "updateContentFlaggingConfig" // update content flagging configuration AuditEventSetReviewer = "setFlaggedPostReviewer" // assign reviewer for flagged post AuditEventGenerateFlaggedPostReport = "generateFlaggedPostReport" // generate flagged post data report + AuditEventGeneratePostExposureReport = "generatePostExposureReport" // generate flagged post exposure report ) diff --git a/server/public/model/channel_member_last_viewed.go b/server/public/model/channel_member_last_viewed.go new file mode 100644 index 000000000000..ad211e000f08 --- /dev/null +++ b/server/public/model/channel_member_last_viewed.go @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +const ChannelMemberLastViewedMaxPerPage = 1000 + +type ChannelMemberLastViewed struct { + UserId string + LastViewedAt int64 +} diff --git a/server/public/model/client4.go b/server/public/model/client4.go index e3192b297675..bac06c5164b2 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -4022,6 +4022,15 @@ func (c *Client4) GenerateFlaggedPostReport(ctx context.Context, postId string, return ReadBytesFromResponse(r) } +func (c *Client4) GeneratePostExposureReport(ctx context.Context, postId string) ([]byte, *Response, error) { + r, err := c.doAPIPost(ctx, c.contentFlaggingRoute().Join("post", postId, "exposure_report"), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return ReadBytesFromResponse(r) +} + // SearchFiles returns any posts with matching terms string. func (c *Client4) SearchFiles(ctx context.Context, teamId string, terms string, isOrSearch bool) (*FileInfoList, *Response, error) { params := SearchParameter{ diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 20c47ef673ee..793a5790a72b 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -123,6 +123,16 @@ func TestFeatureFlagsIsValid(t *testing.T) { require.Nil(t, f.isValid()) }) + t.Run("AppsEnabled is rejected", func(t *testing.T) { + f := &FeatureFlags{} + f.SetDefaults() + f.AppsEnabled = true + + appErr := f.isValid() + require.NotNil(t, appErr) + require.Equal(t, "model.config.is_valid.feature_flags.apps_enabled.app_error", appErr.Id) + }) + t.Run("MoveThreadsEnabled is rejected", func(t *testing.T) { f := &FeatureFlags{} f.SetDefaults() @@ -134,6 +144,21 @@ func TestFeatureFlagsIsValid(t *testing.T) { }) } +func TestConfigIsValidAppsEnabled(t *testing.T) { + c := Config{} + c.SetDefaults() + require.Nil(t, c.IsValid()) + + c.FeatureFlags.AppsEnabled = true + appErr := c.IsValid() + require.NotNil(t, appErr) + require.Equal(t, "model.config.is_valid.feature_flags.apps_enabled.app_error", appErr.Id) + + // A nil FeatureFlags must not panic the validation chain. + c.FeatureFlags = nil + require.Nil(t, c.IsValid()) +} + func TestConfigIsValidMoveThreadsEnabled(t *testing.T) { c := Config{} c.SetDefaults() diff --git a/server/public/model/content_flagging_exposure_report.go b/server/public/model/content_flagging_exposure_report.go new file mode 100644 index 000000000000..3e9a405392e1 --- /dev/null +++ b/server/public/model/content_flagging_exposure_report.go @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "time" + + "github.com/mattermost/mattermost/server/public/shared/i18n" +) + +const PostExposureReportVersion = "1.0" + +type PostExposureReportEntry struct { + UserID string `json:"user_id"` + Username string `json:"username"` + UserEmail string `json:"user_email"` + + IsGuest bool `json:"is_guest"` + IsRemote bool `json:"is_remote"` + IsDeactivated bool `json:"is_deactivated"` + + // WasChannelMember records that the user was a member of the channel between the post's + // creation time and it being flagged, per ChannelMemberHistory. + WasChannelMember bool `json:"was_channel_member"` + + // LastViewedAt is nil when the user is no longer a member of the channel and so has no + // read state at all. A non-nil zero means they are a member who never viewed the channel. + LastViewedAt *int64 `json:"last_viewed_at,omitempty"` + + LastActivityAt *int64 `json:"last_activity_at,omitempty"` +} + +type PostExposureReport struct { + Version string `json:"version"` + PostID string `json:"post_id"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelType ChannelType `json:"channel_type"` + TeamID string `json:"team_id,omitempty"` + WindowStart int64 `json:"window_start"` + WindowEnd int64 `json:"window_end"` + GeneratedAt int64 `json:"generated_at"` + Entries []*PostExposureReportEntry `json:"entries"` +} + +func PostExposureReportCSVHeader(T i18n.TranslateFunc) []string { + return []string{ + T("app.data_spillage.exposure.column.user_id"), + T("app.data_spillage.exposure.column.username"), + T("app.data_spillage.exposure.column.email"), + T("app.data_spillage.exposure.column.is_guest"), + T("app.data_spillage.exposure.column.is_remote"), + T("app.data_spillage.exposure.column.is_deactivated"), + T("app.data_spillage.exposure.column.was_channel_member"), + T("app.data_spillage.exposure.column.last_viewed_at"), + T("app.data_spillage.exposure.column.last_activity_at"), + } +} + +func (e *PostExposureReportEntry) ToCSVRow(T i18n.TranslateFunc) []string { + return []string{ + e.UserID, + e.Username, + e.UserEmail, + exposureBool(T, e.IsGuest), + exposureBool(T, e.IsRemote), + exposureBool(T, e.IsDeactivated), + exposureBool(T, e.WasChannelMember), + e.lastViewedAtCell(T), + e.lastActivityAtCell(T), + } +} + +func (e *PostExposureReportEntry) lastViewedAtCell(T i18n.TranslateFunc) string { + switch { + case e.LastViewedAt == nil: + return T("app.data_spillage.exposure.value.unknown") + case *e.LastViewedAt <= 0: + return T("app.data_spillage.exposure.value.never_viewed") + default: + return FormatExposureTime(*e.LastViewedAt) + } +} + +func (e *PostExposureReportEntry) lastActivityAtCell(T i18n.TranslateFunc) string { + if e.LastActivityAt == nil { + return T("app.data_spillage.exposure.value.no_sessions") + } + + return FormatExposureTime(*e.LastActivityAt) +} + +func exposureBool(T i18n.TranslateFunc, v bool) string { + if v { + return T("app.data_spillage.exposure.value.yes") + } + return T("app.data_spillage.exposure.value.no") +} + +func FormatExposureTime(millis int64) string { + if millis <= 0 { + return "" + } + return time.UnixMilli(millis).UTC().Format(time.RFC3339) +} diff --git a/server/public/model/content_flagging_report.go b/server/public/model/content_flagging_report.go index aaae6e650d22..ef4bdbda5ef9 100644 --- a/server/public/model/content_flagging_report.go +++ b/server/public/model/content_flagging_report.go @@ -3,7 +3,7 @@ package model -const FlaggedPostReportVersion = "1.0" +const FlaggedPostReportVersion = "1.1" type FlaggedPostReportContext struct { Post *Post diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 94e849ef6759..c074eac42756 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -222,6 +222,12 @@ func (f *FeatureFlags) SetDefaults() { // isValid rejects feature flag combinations that are no longer supported. func (f *FeatureFlags) isValid() *AppError { + // The Apps framework is being retired, so the server refuses to start + // while the AppsEnabled feature flag is enabled. + if f.AppsEnabled { + return NewAppError("FeatureFlags.IsValid", "model.config.is_valid.feature_flags.apps_enabled.app_error", nil, "", http.StatusBadRequest) + } + // MoveThreadsEnabled is being retired in favor of Wrangler, so the server // refuses to start while it is enabled. if f.MoveThreadsEnabled { diff --git a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx index 3d47f807feed..0ba41e634bcb 100644 --- a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx +++ b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx @@ -7,6 +7,7 @@ import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import {Link} from 'react-router-dom'; +import {extractFilenameFromContentDisposition} from '@mattermost/client'; import {Button} from '@mattermost/shared/components/button'; import type {SupportPacketContent} from '@mattermost/types/admin'; import type {UserProfile} from '@mattermost/types/users'; @@ -97,14 +98,7 @@ export default class CommercialSupportModal extends React.PureComponent { diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.scss b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.scss new file mode 100644 index 000000000000..41d21c7af9dd --- /dev/null +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.scss @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.DataSpillageExposureReport { + display: flex; + + .btn { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .LoadingSpinner { + line-height: 0; + } +} diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.test.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.test.tsx new file mode 100644 index 000000000000..54196d6e9980 --- /dev/null +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.test.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import {Client4} from 'mattermost-redux/client'; + +import DataSpillageExposureReport from 'components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +describe('DataSpillageExposureReport', () => { + const flaggedPostId = 'flagged_post_id'; + const filename = 'post-exposure-flagged_post_id-1700000000000.csv'; + + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(Client4, 'generatePostExposureReport').mockResolvedValue({ + blob: new Blob(['user_id,username\n'], {type: 'text/csv'}), + filename, + }); + + // jsdom does not implement the object URL APIs, so these cannot be spied on and are + // saved and restored by hand instead. + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + URL.createObjectURL = jest.fn().mockReturnValue('blob:mock-url'); + URL.revokeObjectURL = jest.fn(); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restores every jest.spyOn above, including the ones installed inside individual + // tests, so cleanup still happens when an assertion fails partway through. + jest.restoreAllMocks(); + + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + test('renders idle download button', () => { + renderWithContext( + , + ); + + const button = screen.getByTestId('data-spillage-action-download-exposure-report'); + expect(button).toBeVisible(); + expect(button).toHaveTextContent('Download exposure report'); + expect(button).not.toBeDisabled(); + }); + + test('click triggers download and returns to idle on success', async () => { + renderWithContext( + , + ); + + await userEvent.click(screen.getByTestId('data-spillage-action-download-exposure-report')); + + await waitFor(() => { + expect(Client4.generatePostExposureReport).toHaveBeenCalledWith( + flaggedPostId, + expect.any(AbortSignal), + ); + }); + await waitFor(() => { + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + // Returns to idle state + await waitFor(() => { + expect(screen.getByTestId('data-spillage-action-download-exposure-report')).toHaveTextContent('Download exposure report'); + }); + }); + + test('uses the filename returned by the server', async () => { + jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const anchors: HTMLAnchorElement[] = []; + const originalCreateElement = document.createElement.bind(document); + jest.spyOn(document, 'createElement').mockImplementation((tagName: string, options?: ElementCreationOptions) => { + const element = originalCreateElement(tagName, options); + if (tagName === 'a') { + anchors.push(element as HTMLAnchorElement); + } + return element; + }); + + renderWithContext( + , + ); + + await userEvent.click(screen.getByTestId('data-spillage-action-download-exposure-report')); + + await waitFor(() => { + expect(anchors).toHaveLength(1); + }); + expect(anchors[0].download).toBe(filename); + expect(anchors[0].href).toContain('blob:mock-url'); + }); + + test('shows error state when request rejects', async () => { + jest.spyOn(Client4, 'generatePostExposureReport').mockRejectedValue(new Error('boom')); + + renderWithContext( + , + ); + + await userEvent.click(screen.getByTestId('data-spillage-action-download-exposure-report')); + + await waitFor(() => { + expect(screen.getByTestId('data-spillage-action-download-exposure-report')).toHaveTextContent('Generation failed. Try again.'); + }); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); + + test('is disabled and shows generating label while in flight, and aborts on unmount', async () => { + // Hold the request promise open until we unmount + let resolveRequest: (value: {blob: Blob; filename: string}) => void = () => {}; + const requestPromise = new Promise<{blob: Blob; filename: string}>((resolve) => { + resolveRequest = resolve; + }); + jest.spyOn(Client4, 'generatePostExposureReport').mockReturnValue(requestPromise); + + const {unmount} = renderWithContext( + , + ); + + await userEvent.click(screen.getByTestId('data-spillage-action-download-exposure-report')); + + await waitFor(() => { + expect(screen.getByTestId('data-spillage-action-download-exposure-report')).toHaveTextContent('Generating…'); + }); + expect(screen.getByTestId('data-spillage-action-download-exposure-report')).toBeDisabled(); + + unmount(); + + // Resolving after unmount should not trigger a download + resolveRequest({blob: new Blob(['csv']), filename}); + await Promise.resolve(); + await Promise.resolve(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.tsx new file mode 100644 index 000000000000..9d8f243ae06b --- /dev/null +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {Button, type ButtonEmphasis, type ButtonVariant} from '@mattermost/shared/components/button'; + +import {Client4} from 'mattermost-redux/client'; + +import LoadingSpinner from 'components/widgets/loading/loading_spinner'; + +import './data_spillage_exposure_report.scss'; + +type Status = 'idle' | 'generating' | 'error'; + +type Props = { + flaggedPostId: string; +}; + +export default function DataSpillageExposureReport({flaggedPostId}: Props) { + const [status, setStatus] = useState('idle'); + const abortControllerRef = useRef(null); + + useEffect(() => { + // Cleanup function to cancel in-progress API calls + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const handleClick = useCallback(async () => { + if (status === 'generating') { + return; + } + + const controller = new AbortController(); + abortControllerRef.current?.abort(); + abortControllerRef.current = controller; + + setStatus('generating'); + + let report: {blob: Blob; filename: string} | undefined; + + try { + report = await Client4.generatePostExposureReport(flaggedPostId, controller.signal); + if (controller.signal.aborted) { + return; + } + } catch (err) { + if (controller.signal.aborted) { + return; + } + + // eslint-disable-next-line no-console + console.error(err); + setStatus('error'); + return; + } + + if (controller.signal.aborted || !report) { + return; + } + + const downloadUrl = URL.createObjectURL(report.blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = report.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(downloadUrl); + + setStatus('idle'); + }, [flaggedPostId, status]); + + const {icon, label, emphasis, variant} = useMemo(() => { + let icon; + let label; + let emphasis: ButtonEmphasis = 'tertiary'; + let variant: ButtonVariant = ''; + + switch (status) { + case 'generating': + icon = ; + label = ( + + ); + break; + case 'error': + icon = ; + label = ( + + ); + + // Primary emphasis is suppressed by the destructive variant, leaving just btn-danger + emphasis = 'primary'; + variant = 'destructive'; + break; + case 'idle': + default: + icon = null; + label = ( + + ); + break; + } + + return {icon, label, emphasis, variant}; + }, [status]); + + 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 04378eda5138..5d6dd45a54a8 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 @@ -456,13 +456,38 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => { // action rows are gated on mode === 'full', so they must not render here expect(screen.queryByTestId('data-spillage-action')).not.toBeInTheDocument(); expect(screen.queryByTestId('data-spillage-action-download-report')).not.toBeInTheDocument(); + expect(screen.queryByTestId('data-spillage-exposure-report')).not.toBeInTheDocument(); + }); + + it('renders the exposure report row above the report row in RHS mode', async () => { + renderWithContext( + , + baseState, + ); + + await act(async () => {}); + + const exposureRow = screen.getByTestId('data-spillage-exposure-report-row'); + expect(exposureRow).toBeVisible(); + expect(exposureRow).toHaveTextContent('Exposure report'); + + const exposureButton = screen.getByTestId('data-spillage-action-download-exposure-report'); + expect(exposureButton).toHaveTextContent('Download exposure report'); + + // eslint-disable-next-line no-bitwise + const reportFollowsExposure = exposureButton.compareDocumentPosition(screen.getByTestId('data-spillage-action-download-report')) & Node.DOCUMENT_POSITION_FOLLOWING; + expect(reportFollowsExposure).toBeTruthy(); }); describe.each([ ['Pending', true], + ['Assigned', true], ['Retained', false], ['Removed', false], - ])('Download Report button when status is %s', (status, expectActions) => { + ])('Download Report button when status is %s', (status, reviewIsOpen) => { it('is rendered in action rows in RHS mode', async () => { usePostContentFlaggingValues.mockReturnValue( postContentFlaggingValues.map((v) => @@ -483,7 +508,13 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => { expect(screen.getByTestId('data-spillage-action-download-report')).toBeVisible(); expect(screen.getByTestId('data-spillage-action-download-report')).toHaveTextContent('Download Report'); - if (expectActions) { + // the exposure report is downloadable in every status, including once the review is closed + expect(screen.getByTestId('data-spillage-exposure-report-row')).toBeVisible(); + expect(screen.getByTestId('data-spillage-action-download-exposure-report')).toBeVisible(); + expect(screen.getByTestId('data-spillage-action-download-exposure-report')).toHaveTextContent('Download exposure report'); + + // the Remove/Keep actions, in contrast, are only offered while the review is open + if (reviewIsOpen) { expect(screen.queryByTestId('data-spillage-action')).toBeVisible(); } else { expect(screen.queryByTestId('data-spillage-action')).not.toBeInTheDocument(); 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 4c6f3155e686..37ebc3e39457 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 @@ -18,6 +18,7 @@ import {useContentFlaggingFields, usePostContentFlaggingValues} from 'components import {useUser} from 'components/common/hooks/useUser'; import DataSpillageAction from 'components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions'; import DataSpillageDownloadReport from 'components/post_view/data_spillage_report/data_spillage_download_report/data_spillage_download_report'; +import DataSpillageExposureReport from 'components/post_view/data_spillage_report/data_spillage_exposure_report/data_spillage_exposure_report'; import type {ActionRow, PropertiesCardViewMetadata} from 'components/properties_card_view/properties_card_view'; import PropertiesCardView from 'components/properties_card_view/properties_card_view'; @@ -169,6 +170,23 @@ export function DataSpillageReport({post, isRHS}: Props) { const rows: ActionRow[] = []; + const statusFieldId = propertyFields.status?.id; + const status = statusFieldId ? (propertyValues.find((value) => value.field_id === statusFieldId)?.value as string | undefined) : undefined; + const reviewIsOpen = status === ContentFlaggingStatus.Pending || status === ContentFlaggingStatus.Assigned; + + rows.push({ + label: ( + + ), + content: ( + + ), + testId: 'data-spillage-exposure-report-row', + }); + rows.push({ label: ( , }); - const statusFieldId = propertyFields.status?.id; - const status = statusFieldId ? (propertyValues.find((value) => value.field_id === statusFieldId)?.value as string | undefined) : undefined; - - if (reportingUser && (status === ContentFlaggingStatus.Pending || status === ContentFlaggingStatus.Assigned)) { + if (reportingUser && reviewIsOpen) { rows.push({ label: ( { expect(receivedBody).toEqual({comment: ''}); }); + + test('getPostExposureReportUrl should build correct URL', () => { + expect(client.getPostExposureReportUrl('post123')).toBe( + 'http://mattermost.example.com/api/v4/content_flagging/post/post123/exposure_report', + ); + }); + + test('generatePostExposureReport should return the CSV blob and the filename from Content-Disposition', async () => { + nock(client.getBaseRoute()). + post('/content_flagging/post/post123/exposure_report'). + reply(200, '#Post ID,post123\nUser ID,Username\nuid1,alice\n', { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="post-exposure-post123-1700000000000.csv"', + }); + + const result = await client.generatePostExposureReport('post123'); + + expect(result.filename).toEqual('post-exposure-post123-1700000000000.csv'); + expect(typeof result.blob.text).toBe('function'); + expect(await result.blob.text()).toContain('uid1,alice'); + }); + + test('generatePostExposureReport should fall back to a generated filename when the header is absent', async () => { + nock(client.getBaseRoute()). + post('/content_flagging/post/post123/exposure_report'). + reply(200, 'User ID,Username\n', {'Content-Type': 'text/csv; charset=utf-8'}); + + const result = await client.generatePostExposureReport('post123'); + + expect(result.filename).toMatch(/^post-exposure-post123-\d+\.csv$/); + }); + + test('generatePostExposureReport should surface server errors as ClientError', async () => { + nock(client.getBaseRoute()). + post('/content_flagging/post/post123/exposure_report'). + reply(400, { + id: 'api.data_spillage.error.post_not_in_progress', + message: 'The review of this post is already closed.', + status_code: 400, + }, {'Content-Type': 'application/json'}); + + await expect(client.generatePostExposureReport('post123')).rejects.toMatchObject({ + server_error_id: 'api.data_spillage.error.post_not_in_progress', + status_code: 400, + }); + }); }); describe('team access control routes', () => { diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index aad9668d2704..b2245ebfc676 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -159,7 +159,7 @@ import type { import type {DeepPartial, PartialExcept, RelationOneToOne} from '@mattermost/types/utilities'; import {cleanUrlForLogging} from './errors'; -import {buildQueryString} from './helpers'; +import {buildQueryString, extractFilenameFromContentDisposition} from './helpers'; export enum LdapDiagnosticTestType { FILTERS = 'filters', @@ -4883,7 +4883,7 @@ export default class Client4 { const text = await response.text(); const objects = text.trim().split('\n'); data = objects.map((obj) => JSON.parse(obj)); - } else if (contentType === 'application/zip') { + } else if (contentType === 'application/zip' || contentType?.startsWith('text/csv')) { data = await response.blob(); } else { data = await response.text(); @@ -5425,6 +5425,28 @@ export default class Client4 { }, ); }; + + getPostExposureReportUrl = (postId: string) => { + return `${this.getContentFlaggingRoute()}/post/${postId}/exposure_report`; + }; + + generatePostExposureReport = async (postId: string, signal?: AbortSignal): Promise<{blob: Blob; filename: string}> => { + const {data, headers} = await this.doFetchWithResponse( + this.getPostExposureReportUrl(postId), + { + method: 'post', + signal, + }, + ); + + return { + blob: data, + filename: extractFilenameFromContentDisposition( + headers.get('Content-Disposition'), + `post-exposure-${postId}-${Date.now()}.csv`, + ), + }; + }; } export function parseAndMergeNestedHeaders(originalHeaders: any) { diff --git a/webapp/platform/client/src/helpers.test.ts b/webapp/platform/client/src/helpers.test.ts index fb3633f7dd91..6affe25b8b5f 100644 --- a/webapp/platform/client/src/helpers.test.ts +++ b/webapp/platform/client/src/helpers.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {buildQueryString} from './helpers'; +import {buildQueryString, extractFilenameFromContentDisposition} from './helpers'; describe('Helpers', () => { test.each([ @@ -15,4 +15,59 @@ describe('Helpers', () => { ])('buildQueryString with %o should return %s', (params, expected) => { expect(buildQueryString(params)).toEqual(expected); }); + + describe('extractFilenameFromContentDisposition', () => { + const fallback = 'fallback.csv'; + + test.each([ + ['attachment; filename="report.csv"', 'report.csv'], + ['attachment; filename=report.csv', 'report.csv'], + ["attachment; filename='report.csv'", 'report.csv'], + ['attachment; filename="post-exposure-abc123-1700000000000.csv"', 'post-exposure-abc123-1700000000000.csv'], + + // An unquoted value ends at the parameter separator, with or without surrounding space. + ['attachment; filename=report.csv; size=1', 'report.csv'], + ['attachment; filename=report.csv;size=1', 'report.csv'], + ['attachment; filename = report.csv ; size=1', 'report.csv'], + + // A quoted value may contain the separator and escaped quotes. + ['attachment; filename="report; final.csv"', 'report; final.csv'], + ['attachment; filename="say \\"hi\\".csv"', 'say "hi".csv'], + ["attachment; filename='say \\'hi\\'.csv'", "say 'hi'.csv"], + ['attachment; filename="back\\\\slash.csv"', 'back\\slash.csv'], + ["attachment; filename='back\\\\slash.csv'", 'back\\slash.csv'], + + // RFC 5987 extended values are percent-decoded, and preferred over plain filename. + ["attachment; filename*=UTF-8''report.csv", 'report.csv'], + ["attachment; filename*=UTF-8''report%20name.csv", 'report name.csv'], + ["attachment; filename=ascii.csv; filename*=UTF-8''unicode%E2%9C%93.csv", 'unicode✓.csv'], + ["attachment; filename*=UTF-8''report.csv; size=1", 'report.csv'], + + // An undecodable or malformed extended value falls through to plain filename, then fallback. + ["attachment; filename=ascii.csv; filename*=UTF-8''%E0%A4%A.csv", 'ascii.csv'], + ['attachment; filename*=no-charset-delimiters', fallback], + ["attachment; filename*=UTF-8''%E0%A4%A.csv", fallback], + + ['attachment; filename=', fallback], + ['attachment; filename=""', fallback], + ['inline', fallback], + ['', fallback], + [null, fallback], + [undefined, fallback], + ])('with header %p should return %p', (header, expected) => { + expect(extractFilenameFromContentDisposition(header, fallback)).toEqual(expected); + }); + + // Only the "\\." alternative may consume a backslash. If the fallback character class + // could consume it too, every escaped character would have two ways to match and an + // unterminated value would backtrack exponentially, blocking the calling thread. + test.each([ + ['"', '\\!'], + ["'", '\\&'], + ])('with an unterminated %p quoted value repeating %p should not backtrack', (quote, escaped) => { + const value = escaped.repeat(80); + + expect(extractFilenameFromContentDisposition(`attachment; filename=${quote}${value}`, fallback)).toEqual(`${quote}${value}`); + }, 1000); + }); }); diff --git a/webapp/platform/client/src/helpers.ts b/webapp/platform/client/src/helpers.ts index 6564b3444273..4acda1cd0322 100644 --- a/webapp/platform/client/src/helpers.ts +++ b/webapp/platform/client/src/helpers.ts @@ -15,3 +15,52 @@ export function buildQueryString(parameters: Record): string { return queryParams.length > 0 ? `?${queryParams}` : ''; } + +// extractFilenameFromContentDisposition returns the filename advertised by a Content-Disposition +// response header, falling back to the given name when the header is missing or unparsable. +export function extractFilenameFromContentDisposition(header: string | null | undefined, fallback: string): string { + if (!header) { + return fallback; + } + + // RFC 6266 prefers the RFC 5987 extended parameter when a header carries both. + const extended = (/filename\*\s*=\s*([^;]*)/i).exec(header); + if (extended) { + const decoded = decodeExtendedFilename(extended[1].trim()); + if (decoded) { + return decoded; + } + } + + // A quoted value may legitimately contain the ";" parameter separator, so consume it as a + // unit. An unquoted value ends at the next ";" or at the end of the header; without that + // bound a trailing parameter such as "; size=1" would be read as part of the filename. + const quoted = (/filename\s*=\s*"((?:\\.|[^"\\])*)"/i).exec(header) ?? + (/filename\s*=\s*'((?:\\.|[^'\\])*)'/i).exec(header); + if (quoted) { + return quoted[1].replace(/\\(.)/g, '$1').trim() || fallback; + } + + const unquoted = (/filename\s*=\s*([^;]*)/i).exec(header); + if (unquoted) { + return unquoted[1].trim() || fallback; + } + + return fallback; +} + +// decodeExtendedFilename decodes an RFC 5987 "charset'language'percent-encoded-value" parameter. +// It returns an empty string when the value is malformed or percent-decoding fails, so callers +// fall through to the plain "filename" parameter rather than surfacing a partial value. +function decodeExtendedFilename(value: string): string { + const parts = (/^[\w-]+'[\w-]*'(.*)$/).exec(value); + if (!parts) { + return ''; + } + + try { + return decodeURIComponent(parts[1]).trim(); + } catch { + return ''; + } +} diff --git a/webapp/platform/client/src/index.ts b/webapp/platform/client/src/index.ts index 95f53cbc5731..329389b52221 100644 --- a/webapp/platform/client/src/index.ts +++ b/webapp/platform/client/src/index.ts @@ -10,6 +10,8 @@ export { DEFAULT_LIMIT_BEFORE, } from './client4'; +export {extractFilenameFromContentDisposition} from './helpers'; + export {default as WebSocketClient} from './websocket'; export {WebSocketEvents} from './websocket_events'; export type {BaseWebSocketMessage, JsonEncodedValue, WebSocketBroadcast, WebSocketMessage} from './websocket_message';