From 75bd409ba015421019ae82f60826fe57f9f06444 Mon Sep 17 00:00:00 2001 From: Doug Lauder Date: Fri, 10 Dec 2021 10:46:37 -0500 Subject: [PATCH] Notifications phase 1 (#1851) Backend support for subscribing/unsubscribing to blocks, typically cards and boards. Notifies subscribers when changes are made to cards they are subscribed to. --- mattermost-plugin/go.mod | 1 - mattermost-plugin/go.sum | 1 + mattermost-plugin/server/notifications.go | 53 ++- mattermost-plugin/server/plugin.go | 78 +++- mattermost-plugin/webapp/src/index.tsx | 5 +- server/api/api.go | 255 ++++++++++++++ server/app/blocks.go | 68 +--- server/app/subscriptions.go | 42 +++ server/client/client.go | 48 +++ server/integrationtests/clienttestlib.go | 54 ++- server/integrationtests/subscriptions_test.go | 146 ++++++++ server/model/block.go | 15 + server/model/notification.go | 92 +++++ server/model/properties.go | 172 +++++++++ server/model/properties_test.go | 138 ++++++++ server/model/subscription.go | 110 ++++++ server/model/util.go | 25 ++ server/server/server.go | 2 +- server/services/config/config.go | 14 +- .../notify/notifymentions/delivery.go | 9 +- .../notify/notifymentions/mentions_backend.go | 63 +++- .../notify/notifysubscriptions/delivery.go | 17 + .../notify/notifysubscriptions/diff.go | 317 +++++++++++++++++ .../diff2slackattachments.go | 240 +++++++++++++ .../notify/notifysubscriptions/notifier.go | 251 +++++++++++++ .../notify/notifysubscriptions/store.go | 30 ++ .../subscriptions_backend.go | 221 ++++++++++++ .../notify/notifysubscriptions/util.go | 32 ++ .../notify/plugindelivery/mention_deliver.go | 60 ++++ .../services/notify/plugindelivery/message.go | 4 - .../notify/plugindelivery/plugin_delivery.go | 64 +--- .../plugindelivery/subscription_deliver.go | 64 ++++ server/services/notify/service.go | 26 +- server/services/store/generators/main.go | 3 +- .../generators/transactional_store.go.tmpl | 1 + server/services/store/mockstore/mockstore.go | 240 ++++++++++++- server/services/store/sqlstore/blocks.go | 149 +++++++- .../store/sqlstore/migrations/bindata.go | 48 ++- .../000016_subscriptions_table.down.sql | 2 + .../000016_subscriptions_table.up.sql | 22 ++ .../store/sqlstore/notificationhints.go | 203 +++++++++++ .../services/store/sqlstore/public_methods.go | 79 ++++- .../services/store/sqlstore/sqlstore_test.go | 5 + .../services/store/sqlstore/subscriptions.go | 273 ++++++++++++++ server/services/store/sqlstore/util.go | 6 + server/services/store/store.go | 52 ++- server/services/store/storetests/blocks.go | 12 +- .../store/storetests/notificationhints.go | 270 ++++++++++++++ .../store/storetests/subscriptions.go | 333 ++++++++++++++++++ server/services/store/storetests/util.go | 54 +++ server/utils/links.go | 11 + server/ws/adapter.go | 2 + server/ws/common.go | 6 + server/ws/plugin_adapter.go | 26 +- server/ws/plugin_adapter_cluster.go | 17 +- server/ws/server.go | 4 + webapp/i18n/en.json | 4 +- .../__snapshots__/cardDialog.test.tsx.snap | 322 +++++++++++++++++ webapp/src/components/cardDialog.scss | 14 + webapp/src/components/cardDialog.test.tsx | 44 +++ webapp/src/components/cardDialog.tsx | 85 +++-- webapp/src/components/centerPanel.test.tsx | 8 + webapp/src/components/dialog.scss | 5 + webapp/src/components/dialog.tsx | 3 + webapp/src/components/workspace.test.tsx | 1 + webapp/src/mutator.ts | 26 ++ webapp/src/octoClient.ts | 34 ++ webapp/src/pages/boardPage.tsx | 27 +- webapp/src/store/initialLoad.ts | 17 +- webapp/src/store/users.ts | 32 +- webapp/src/wsclient.ts | 40 +++ 71 files changed, 4986 insertions(+), 211 deletions(-) create mode 100644 server/app/subscriptions.go create mode 100644 server/integrationtests/subscriptions_test.go create mode 100644 server/model/notification.go create mode 100644 server/model/properties.go create mode 100644 server/model/properties_test.go create mode 100644 server/model/subscription.go create mode 100644 server/model/util.go create mode 100644 server/services/notify/notifysubscriptions/delivery.go create mode 100644 server/services/notify/notifysubscriptions/diff.go create mode 100644 server/services/notify/notifysubscriptions/diff2slackattachments.go create mode 100644 server/services/notify/notifysubscriptions/notifier.go create mode 100644 server/services/notify/notifysubscriptions/store.go create mode 100644 server/services/notify/notifysubscriptions/subscriptions_backend.go create mode 100644 server/services/notify/notifysubscriptions/util.go create mode 100644 server/services/notify/plugindelivery/mention_deliver.go create mode 100644 server/services/notify/plugindelivery/subscription_deliver.go create mode 100644 server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.down.sql create mode 100644 server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.up.sql create mode 100644 server/services/store/sqlstore/notificationhints.go create mode 100644 server/services/store/sqlstore/subscriptions.go create mode 100644 server/services/store/storetests/notificationhints.go create mode 100644 server/services/store/storetests/subscriptions.go create mode 100644 server/services/store/storetests/util.go create mode 100644 server/utils/links.go create mode 100644 webapp/src/components/cardDialog.scss diff --git a/mattermost-plugin/go.mod b/mattermost-plugin/go.mod index ab60e9637..15130c64f 100644 --- a/mattermost-plugin/go.mod +++ b/mattermost-plugin/go.mod @@ -8,6 +8,5 @@ require ( github.com/mattermost/focalboard/server v0.0.0-20210525112228-f43e4028dbdc github.com/mattermost/mattermost-plugin-api v0.0.21 github.com/mattermost/mattermost-server/v6 v6.0.0-20211022142730-a6cca93ba3c3 - github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.7.0 ) diff --git a/mattermost-plugin/go.sum b/mattermost-plugin/go.sum index 4504bd4ee..7c477d985 100644 --- a/mattermost-plugin/go.sum +++ b/mattermost-plugin/go.sum @@ -1026,6 +1026,7 @@ github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 h1:ZuhckGJ10ulaKkdvJtiAqsLTiPrLaXSdnVgXJKJkTxE= github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= diff --git a/mattermost-plugin/server/notifications.go b/mattermost-plugin/server/notifications.go index 18ef2a6d6..f5b4c9d91 100644 --- a/mattermost-plugin/server/notifications.go +++ b/mattermost-plugin/server/notifications.go @@ -4,9 +4,12 @@ import ( "errors" "fmt" - "github.com/mattermost/focalboard/server/services/notify" + "github.com/mattermost/focalboard/server/services/config" "github.com/mattermost/focalboard/server/services/notify/notifymentions" + "github.com/mattermost/focalboard/server/services/notify/notifysubscriptions" "github.com/mattermost/focalboard/server/services/notify/plugindelivery" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/ws" pluginapi "github.com/mattermost/mattermost-plugin-api" @@ -21,7 +24,47 @@ const ( botDescription = "Created by Boards plugin." ) -func createMentionsNotifyBackend(client *pluginapi.Client, serverRoot string, logger *mlog.Logger) (notify.Backend, error) { +type notifyBackendParams struct { + cfg *config.Configuration + client *pluginapi.Client + serverRoot string + logger *mlog.Logger +} + +func createMentionsNotifyBackend(params notifyBackendParams) (*notifymentions.Backend, error) { + delivery, err := createDelivery(params.client, params.serverRoot) + if err != nil { + return nil, err + } + + backend := notifymentions.New(delivery, params.logger) + + return backend, nil +} + +func createSubscriptionsNotifyBackend(params notifyBackendParams, store store.Store, + wsPluginAdapter ws.PluginAdapterInterface) (*notifysubscriptions.Backend, error) { + // + delivery, err := createDelivery(params.client, params.serverRoot) + if err != nil { + return nil, err + } + + backendParams := notifysubscriptions.BackendParams{ + ServerRoot: params.serverRoot, + Store: store, + Delivery: delivery, + WSAdapter: wsPluginAdapter, + Logger: params.logger, + NotifyFreqCardSeconds: params.cfg.NotifyFreqCardSeconds, + NotifyFreqBoardSeconds: params.cfg.NotifyFreqBoardSeconds, + } + backend := notifysubscriptions.New(backendParams) + + return backend, nil +} + +func createDelivery(client *pluginapi.Client, serverRoot string) (*plugindelivery.PluginDelivery, error) { bot := &model.Bot{ Username: botUsername, DisplayName: botDisplayname, @@ -34,11 +77,7 @@ func createMentionsNotifyBackend(client *pluginapi.Client, serverRoot string, lo pluginAPI := &pluginAPIAdapter{client: client} - delivery := plugindelivery.New(botID, serverRoot, pluginAPI) - - backend := notifymentions.New(delivery, logger) - - return backend, nil + return plugindelivery.New(botID, serverRoot, pluginAPI), nil } type pluginAPIAdapter struct { diff --git a/mattermost-plugin/server/plugin.go b/mattermost-plugin/server/plugin.go index cbfb15ee5..2fb440835 100644 --- a/mattermost-plugin/server/plugin.go +++ b/mattermost-plugin/server/plugin.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "math" "net/http" "net/url" "path" @@ -28,9 +29,11 @@ import ( ) const ( - boardsFeatureFlagName = "BoardsFeatureFlags" - pluginName = "focalboard" - sharedBoardsName = "enablepublicsharedboards" + boardsFeatureFlagName = "BoardsFeatureFlags" + pluginName = "focalboard" + sharedBoardsName = "enablepublicsharedboards" + notifyFreqCardSecondsKey = "notify_freq_card_seconds" + notifyFreqBoardSecondsKey = "notify_freq_board_seconds" ) type BoardsEmbed struct { @@ -111,9 +114,28 @@ func (p *Plugin) OnActivate() error { p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db)) - mentionsBackend, err := createMentionsNotifyBackend(client, baseURL+"/boards", logger) + backendParams := notifyBackendParams{ + cfg: cfg, + client: client, + serverRoot: baseURL + "/boards", + logger: logger, + } + + var notifyBackends []notify.Backend + + mentionsBackend, err := createMentionsNotifyBackend(backendParams) if err != nil { - return fmt.Errorf("error creating mentions notifications backend: %w", err) + return fmt.Errorf("error creating mention notifications backend: %w", err) + } + notifyBackends = append(notifyBackends, mentionsBackend) + + if cfg.IsSubscriptionsEnabled() { + subscriptionsBackend, err2 := createSubscriptionsNotifyBackend(backendParams, db, p.wsPluginAdapter) + if err2 != nil { + return fmt.Errorf("error creating subscription notifications backend: %w", err2) + } + notifyBackends = append(notifyBackends, subscriptionsBackend) + mentionsBackend.AddListener(subscriptionsBackend) } params := server.Params{ @@ -123,7 +145,7 @@ func (p *Plugin) OnActivate() error { Logger: logger, ServerID: serverID, WSAdapter: p.wsPluginAdapter, - NotifyBackends: []notify.Backend{mentionsBackend}, + NotifyBackends: notifyBackends, } server, err := server.New(params) @@ -204,9 +226,36 @@ func (p *Plugin) createBoardsConfig(mmconfig mmModel.Config, baseURL string, ser AuthMode: "mattermost", EnablePublicSharedBoards: enablePublicSharedBoards, FeatureFlags: featureFlags, + NotifyFreqCardSeconds: getPluginSettingInt(mmconfig, notifyFreqCardSecondsKey, 120), + NotifyFreqBoardSeconds: getPluginSettingInt(mmconfig, notifyFreqBoardSecondsKey, 86400), } } +func getPluginSetting(mmConfig mmModel.Config, key string) (interface{}, bool) { + plugin, ok := mmConfig.PluginSettings.Plugins[pluginName] + if !ok { + return nil, false + } + + val, ok := plugin[key] + if !ok { + return nil, false + } + return val, true +} + +func getPluginSettingInt(mmConfig mmModel.Config, key string, def int) int { + val, ok := getPluginSetting(mmConfig, key) + if !ok { + return def + } + valFloat, ok := val.(float64) + if !ok { + return def + } + return int(math.Round(valFloat)) +} + func parseFeatureFlags(configFeatureFlags map[string]string) map[string]string { featureFlags := make(map[string]string) for key, value := range configFeatureFlags { @@ -270,7 +319,22 @@ func defaultLoggingConfig() string { {"id": 1, "name": "fatal", "stacktrace": true}, {"id": 0, "name": "panic", "stacktrace": true} ] - } + }, + "errors_file": { + "Type": "file", + "Format": "plain", + "Levels": [ + {"ID": 2, "Name": "error", "Stacktrace": true} + ], + "Options": { + "Compress": true, + "Filename": "focalboard_errors.log", + "MaxAgeDays": 0, + "MaxBackups": 5, + "MaxSizeMB": 10 + }, + "MaxQueueSize": 1000 + } }` } diff --git a/mattermost-plugin/webapp/src/index.tsx b/mattermost-plugin/webapp/src/index.tsx index 083b5b9cf..2364ffcfc 100644 --- a/mattermost-plugin/webapp/src/index.tsx +++ b/mattermost-plugin/webapp/src/index.tsx @@ -29,7 +29,7 @@ import '../../../webapp/src/styles/labels.scss' import octoClient from '../../../webapp/src/octoClient' import BoardsUnfurl from './components/boardsUnfurl/boardsUnfurl' -import wsClient, {MMWebSocketClient, ACTION_UPDATE_BLOCK, ACTION_UPDATE_CLIENT_CONFIG} from './../../../webapp/src/wsclient' +import wsClient, {MMWebSocketClient, ACTION_UPDATE_BLOCK, ACTION_UPDATE_CLIENT_CONFIG, ACTION_UPDATE_SUBSCRIPTION} from './../../../webapp/src/wsclient' import manifest from './manifest' import ErrorBoundary from './error_boundary' @@ -182,7 +182,8 @@ export default class Plugin { // register websocket handlers this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BLOCK}`, (e: any) => wsClient.updateBlockHandler(e.data)) this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data)) - this.registry?.registerWebSocketEventHandler(`plugin_statuses_changed`, (e: any) => wsClient.pluginStatusesChangedHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data)) + this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data)) this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => { let preferences try { diff --git a/server/api/api.go b/server/api/api.go index 609d77a2f..999c92281 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -102,6 +102,11 @@ func (a *API) RegisterRoutes(r *mux.Router) { files := r.PathPrefix("/files").Subrouter() files.HandleFunc("/workspaces/{workspaceID}/{rootID}/{filename}", a.attachSession(a.handleServeFile, false)).Methods("GET") + + // Subscriptions + apiv1.HandleFunc("/workspaces/{workspaceID}/subscriptions", a.sessionRequired(a.handleCreateSubscription)).Methods("POST") + apiv1.HandleFunc("/workspaces/{workspaceID}/subscriptions/{blockID}/{subscriberID}", a.sessionRequired(a.handleDeleteSubscription)).Methods("DELETE") + apiv1.HandleFunc("/workspaces/{workspaceID}/subscriptions/{subscriberID}", a.sessionRequired(a.handleGetSubscriptions)).Methods("GET") } func (a *API) RegisterAdminRoutes(r *mux.Router) { @@ -1516,6 +1521,256 @@ func (a *API) getWorkspaceUsers(w http.ResponseWriter, r *http.Request) { auditRec.Success() } +// subscriptions + +func (a *API) handleCreateSubscription(w http.ResponseWriter, r *http.Request) { + // swagger:operation POST /api/v1/workspaces/{workspaceID}/subscriptions createSubscription + // + // Creates a subscription to a block for a user. The user will receive change notifications for the block. + // + // --- + // produces: + // - application/json + // parameters: + // - name: workspaceID + // in: path + // description: Workspace ID + // required: true + // type: string + // - name: Body + // in: body + // description: subscription definition + // required: true + // schema: + // "$ref": "#/definitions/Subscription" + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // "$ref": "#/definitions/User" + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + container, err := a.getContainer(r) + if err != nil { + a.noContainerErrorResponse(w, r.URL.Path, err) + return + } + + requestBody, err := ioutil.ReadAll(r.Body) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + var sub model.Subscription + + err = json.Unmarshal(requestBody, &sub) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + if err = sub.IsValid(); err != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "", err) + } + + ctx := r.Context() + session := ctx.Value(sessionContextKey).(*model.Session) + + auditRec := a.makeAuditRecord(r, "createSubscription", audit.Fail) + defer a.audit.LogRecord(audit.LevelModify, auditRec) + auditRec.AddMeta("subscriber_id", sub.SubscriberID) + auditRec.AddMeta("block_id", sub.BlockID) + + // User can only create subscriptions for themselves (for now) + if session.UserID != sub.SubscriberID { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "userID and subscriberID mismatch", nil) + return + } + + // check for valid block + _, err = a.app.GetBlockWithID(*container, sub.BlockID) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "invalid blockID", err) + return + } + + subNew, err := a.app.CreateSubscription(*container, &sub) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + a.logger.Debug("CREATE subscription", + mlog.String("subscriber_id", subNew.SubscriberID), + mlog.String("block_id", subNew.BlockID), + ) + + json, err := json.Marshal(subNew) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + jsonBytesResponse(w, http.StatusOK, json) + auditRec.Success() +} + +func (a *API) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) { + // swagger:operation DELETE /api/v1/workspaces/{workspaceID}/subscriptions/{blockID}/{subscriberID} deleteSubscription + // + // Deletes a subscription a user has for a a block. The user will no longer receive change notifications for the block. + // + // --- + // produces: + // - application/json + // parameters: + // - name: workspaceID + // in: path + // description: Workspace ID + // required: true + // type: string + // - name: BlockID + // in: path + // description: Block ID + // required: true + // type: string + // - name: subscriberID + // in: path + // description: Subscriber ID + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + ctx := r.Context() + session := ctx.Value(sessionContextKey).(*model.Session) + + vars := mux.Vars(r) + blockID := vars["blockID"] + subscriberID := vars["subscriberID"] + + container, err := a.getContainer(r) + if err != nil { + a.noContainerErrorResponse(w, r.URL.Path, err) + return + } + + auditRec := a.makeAuditRecord(r, "deleteSubscription", audit.Fail) + defer a.audit.LogRecord(audit.LevelModify, auditRec) + auditRec.AddMeta("block_id", blockID) + auditRec.AddMeta("subscriber_id", subscriberID) + + // User can only delete subscriptions for themselves + if session.UserID != subscriberID { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "userID and subscriberID mismatch", nil) + return + } + + _, err = a.app.DeleteSubscription(*container, blockID, subscriberID) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + a.logger.Debug("DELETE subscription", + mlog.String("blockID", blockID), + mlog.String("subscriberID", subscriberID), + ) + jsonStringResponse(w, http.StatusOK, "{}") + + auditRec.Success() +} + +func (a *API) handleGetSubscriptions(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /api/v1/workspaces/{workspaceID}/subscriptions/{subscriberID} getSubscriptions + // + // Gets subscriptions for a user. + // + // --- + // produces: + // - application/json + // parameters: + // - name: workspaceID + // in: path + // description: Workspace ID + // required: true + // type: string + // - name: subscriberID + // in: path + // description: Subscriber ID + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // type: array + // items: + // "$ref": "#/definitions/User" + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + ctx := r.Context() + session := ctx.Value(sessionContextKey).(*model.Session) + + vars := mux.Vars(r) + subscriberID := vars["subscriberID"] + + container, err := a.getContainer(r) + if err != nil { + a.noContainerErrorResponse(w, r.URL.Path, err) + return + } + + auditRec := a.makeAuditRecord(r, "getSubscriptions", audit.Fail) + defer a.audit.LogRecord(audit.LevelRead, auditRec) + auditRec.AddMeta("subscriber_id", subscriberID) + + // User can only get subscriptions for themselves (for now) + if session.UserID != subscriberID { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "userID and subscriberID mismatch", nil) + return + } + + subs, err := a.app.GetSubscriptions(*container, subscriberID) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + a.logger.Debug("GET subscriptions", + mlog.String("subscriberID", subscriberID), + mlog.Int("count", len(subs)), + ) + + json, err := json.Marshal(subs) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + jsonBytesResponse(w, http.StatusOK, json) + + auditRec.AddMeta("subscription_count", len(subs)) + auditRec.Success() +} + // Response helpers func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message string, sourceError error) { diff --git a/server/app/blocks.go b/server/app/blocks.go index 9dcf4be57..4b9e17a41 100644 --- a/server/app/blocks.go +++ b/server/app/blocks.go @@ -38,13 +38,13 @@ func (a *App) GetParentID(c store.Container, blockID string) (string, error) { return a.store.GetParentID(c, blockID) } -func (a *App) PatchBlock(c store.Container, blockID string, blockPatch *model.BlockPatch, userID string) error { +func (a *App) PatchBlock(c store.Container, blockID string, blockPatch *model.BlockPatch, modifiedByID string) error { oldBlock, err := a.store.GetBlock(c, blockID) if err != nil { return nil } - err = a.store.PatchBlock(c, blockID, blockPatch, userID) + err = a.store.PatchBlock(c, blockID, blockPatch, modifiedByID) if err != nil { return err } @@ -57,12 +57,12 @@ func (a *App) PatchBlock(c store.Container, blockID string, blockPatch *model.Bl a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, *block) go func() { a.webhook.NotifyUpdate(*block) - a.notifyBlockChanged(notify.Update, c, block, oldBlock, userID) + a.notifyBlockChanged(notify.Update, c, block, oldBlock, modifiedByID) }() return nil } -func (a *App) PatchBlocks(c store.Container, blockPatches *model.BlockPatchBatch, userID string) error { +func (a *App) PatchBlocks(c store.Container, blockPatches *model.BlockPatchBatch, modifiedByID string) error { oldBlocks := make([]model.Block, 0, len(blockPatches.BlockIDs)) for _, blockID := range blockPatches.BlockIDs { oldBlock, err := a.store.GetBlock(c, blockID) @@ -72,7 +72,7 @@ func (a *App) PatchBlocks(c store.Container, blockPatches *model.BlockPatchBatch oldBlocks = append(oldBlocks, *oldBlock) } - err := a.store.PatchBlocks(c, blockPatches, userID) + err := a.store.PatchBlocks(c, blockPatches, modifiedByID) if err != nil { return err } @@ -86,30 +86,30 @@ func (a *App) PatchBlocks(c store.Container, blockPatches *model.BlockPatchBatch a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, *newBlock) go func(currentIndex int) { a.webhook.NotifyUpdate(*newBlock) - a.notifyBlockChanged(notify.Update, c, newBlock, &oldBlocks[currentIndex], userID) + a.notifyBlockChanged(notify.Update, c, newBlock, &oldBlocks[currentIndex], modifiedByID) }(i) } return nil } -func (a *App) InsertBlock(c store.Container, block model.Block, userID string) error { - err := a.store.InsertBlock(c, &block, userID) +func (a *App) InsertBlock(c store.Container, block model.Block, modifiedByID string) error { + err := a.store.InsertBlock(c, &block, modifiedByID) if err == nil { a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, block) a.metrics.IncrementBlocksInserted(1) go func() { a.webhook.NotifyUpdate(block) - a.notifyBlockChanged(notify.Add, c, &block, nil, userID) + a.notifyBlockChanged(notify.Add, c, &block, nil, modifiedByID) }() } return err } -func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string, allowNotifications bool) ([]model.Block, error) { +func (a *App) InsertBlocks(c store.Container, blocks []model.Block, modifiedByID string, allowNotifications bool) ([]model.Block, error) { needsNotify := make([]model.Block, 0, len(blocks)) for i := range blocks { - err := a.store.InsertBlock(c, &blocks[i], userID) + err := a.store.InsertBlock(c, &blocks[i], modifiedByID) if err != nil { return nil, err } @@ -125,7 +125,7 @@ func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID strin block := b a.webhook.NotifyUpdate(block) if allowNotifications { - a.notifyBlockChanged(notify.Add, c, &block, nil, userID) + a.notifyBlockChanged(notify.Add, c, &block, nil, modifiedByID) } } }() @@ -136,10 +136,9 @@ func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID strin func (a *App) GetSubTree(c store.Container, blockID string, levels int) ([]model.Block, error) { // Only 2 or 3 levels are supported for now if levels >= 3 { - return a.store.GetSubTree3(c, blockID) + return a.store.GetSubTree3(c, blockID, model.QuerySubtreeOptions{}) } - - return a.store.GetSubTree2(c, blockID) + return a.store.GetSubTree2(c, blockID, model.QuerySubtreeOptions{}) } func (a *App) GetAllBlocks(c store.Container) ([]model.Block, error) { @@ -174,7 +173,7 @@ func (a *App) DeleteBlock(c store.Container, blockID string, modifiedBy string) a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, block.ParentID) a.metrics.IncrementBlocksDeleted(1) go func() { - a.notifyBlockChanged(notify.Update, c, block, block, modifiedBy) + a.notifyBlockChanged(notify.Delete, c, block, block, modifiedBy) }() return nil } @@ -183,13 +182,13 @@ func (a *App) GetBlockCountsByType() (map[string]int64, error) { return a.store.GetBlockCountsByType() } -func (a *App) notifyBlockChanged(action notify.Action, c store.Container, block *model.Block, oldBlock *model.Block, userID string) { +func (a *App) notifyBlockChanged(action notify.Action, c store.Container, block *model.Block, oldBlock *model.Block, modifiedByID string) { if a.notifications == nil { return } // find card and board for the changed block. - board, card, err := a.getBoardAndCard(c, block) + board, card, err := a.store.GetBoardAndCard(c, block) if err != nil { a.logger.Error("Error notifying for block change; cannot determine board or card", mlog.Err(err)) return @@ -202,38 +201,7 @@ func (a *App) notifyBlockChanged(action notify.Action, c store.Container, block Card: card, BlockChanged: block, BlockOld: oldBlock, - UserID: userID, + ModifiedByID: modifiedByID, } a.notifications.BlockChanged(evt) } - -const ( - maxSearchDepth = 50 -) - -// getBoardAndCard returns the first parent of type `card` and first parent of type `board` for the specified block. -// `board` and/or `card` may return nil without error if the block does not belong to a board or card. -func (a *App) getBoardAndCard(c store.Container, block *model.Block) (board *model.Block, card *model.Block, err error) { - var count int // don't let invalid blocks hierarchy cause infinite loop. - iter := block - for { - count++ - if board == nil && iter.Type == model.TypeBoard { - board = iter - } - - if card == nil && iter.Type == model.TypeCard { - card = iter - } - - if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth { - break - } - - iter, err = a.store.GetBlock(c, iter.ParentID) - if err != nil { - return board, card, err - } - } - return board, card, nil -} diff --git a/server/app/subscriptions.go b/server/app/subscriptions.go new file mode 100644 index 000000000..12cf939d1 --- /dev/null +++ b/server/app/subscriptions.go @@ -0,0 +1,42 @@ +package app + +import ( + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/utils" +) + +func (a *App) CreateSubscription(c store.Container, sub *model.Subscription) (*model.Subscription, error) { + sub, err := a.store.CreateSubscription(c, sub) + if err != nil { + return nil, err + } + a.notifySubscriptionChanged(c, sub) + + return sub, nil +} + +func (a *App) DeleteSubscription(c store.Container, blockID string, subscriberID string) (*model.Subscription, error) { + sub, err := a.store.GetSubscription(c, blockID, subscriberID) + if err != nil { + return nil, err + } + if err := a.store.DeleteSubscription(c, blockID, subscriberID); err != nil { + return nil, err + } + sub.DeleteAt = utils.GetMillis() + a.notifySubscriptionChanged(c, sub) + + return sub, nil +} + +func (a *App) GetSubscriptions(c store.Container, subscriberID string) ([]*model.Subscription, error) { + return a.store.GetSubscriptions(c, subscriberID) +} + +func (a *App) notifySubscriptionChanged(c store.Container, subscription *model.Subscription) { + if a.notifications == nil { + return + } + a.notifications.BroadcastSubscriptionChange(c.WorkspaceID, subscription) +} diff --git a/server/client/client.go b/server/client/client.go index 9413fbb88..cc560a4b1 100644 --- a/server/client/client.go +++ b/server/client/client.go @@ -361,3 +361,51 @@ func (c *Client) WorkspaceUploadFile(workspaceID, rootID string, data io.Reader) return fileUploadResponse, BuildResponse(r) } + +func (c *Client) GetSubscriptionsRoute(workspaceID string) string { + return fmt.Sprintf("/workspaces/%s/subscriptions", workspaceID) +} + +func (c *Client) CreateSubscription(workspaceID string, sub *model.Subscription) (*model.Subscription, *Response) { + r, err := c.DoAPIPost(c.GetSubscriptionsRoute(workspaceID), toJSON(&sub)) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + subNew, err := model.SubscriptionFromJSON(r.Body) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + return subNew, BuildResponse(r) +} + +func (c *Client) DeleteSubscription(workspaceID string, blockID string, subscriberID string) *Response { + url := fmt.Sprintf("%s/%s/%s", c.GetSubscriptionsRoute(workspaceID), blockID, subscriberID) + + r, err := c.DoAPIDelete(url) + if err != nil { + return BuildErrorResponse(r, err) + } + defer closeBody(r) + + return BuildResponse(r) +} + +func (c *Client) GetSubscriptions(workspaceID string, subscriberID string) ([]*model.Subscription, *Response) { + url := fmt.Sprintf("%s/%s", c.GetSubscriptionsRoute(workspaceID), subscriberID) + + r, err := c.DoAPIGet(url, "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + var subs []*model.Subscription + err = json.NewDecoder(r.Body).Decode(&subs) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + + return subs, BuildResponse(r) +} diff --git a/server/integrationtests/clienttestlib.go b/server/integrationtests/clienttestlib.go index 6d0a1d8b5..e9fb29d46 100644 --- a/server/integrationtests/clienttestlib.go +++ b/server/integrationtests/clienttestlib.go @@ -1,20 +1,24 @@ package integrationtests import ( + "errors" "net/http" "os" "time" + "github.com/mattermost/focalboard/server/api" "github.com/mattermost/focalboard/server/client" "github.com/mattermost/focalboard/server/server" "github.com/mattermost/focalboard/server/services/config" + "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) type TestHelper struct { - Server *server.Server - Client *client.Client + Server *server.Server + Client *client.Client + Client2 *client.Client } func getTestConfig() *config.Configuration { @@ -103,6 +107,7 @@ func SetupTestHelperWithoutToken() *TestHelper { th := &TestHelper{} th.Server = newTestServer("") th.Client = client.NewClient(th.Server.Config().ServerRoot, "") + th.Client2 = client.NewClient(th.Server.Config().ServerRoot, "") return th } @@ -139,6 +144,51 @@ func (th *TestHelper) InitBasic() *TestHelper { return th } +var ErrRegisterFail = errors.New("register failed") + +func (th *TestHelper) InitUsers(username1 string, username2 string) error { + workspace, err := th.Server.App().GetRootWorkspace() + if err != nil { + return err + } + + clients := []*client.Client{th.Client, th.Client2} + usernames := []string{username1, username2} + + for i, client := range clients { + // register a new user + password := utils.NewID(utils.IDTypeNone) + registerRequest := &api.RegisterRequest{ + Username: usernames[i], + Email: usernames[i] + "@example.com", + Password: password, + Token: workspace.SignupToken, + } + success, resp := client.Register(registerRequest) + if resp.Error != nil { + return resp.Error + } + if !success { + return ErrRegisterFail + } + + // login + loginRequest := &api.LoginRequest{ + Type: "normal", + Username: registerRequest.Username, + Email: registerRequest.Email, + Password: registerRequest.Password, + } + data, resp := client.Login(loginRequest) + if resp.Error != nil { + return resp.Error + } + + client.Token = data.Token + } + return nil +} + func (th *TestHelper) TearDown() { defer func() { _ = th.Server.Logger().Shutdown() }() diff --git a/server/integrationtests/subscriptions_test.go b/server/integrationtests/subscriptions_test.go new file mode 100644 index 000000000..6db76d100 --- /dev/null +++ b/server/integrationtests/subscriptions_test.go @@ -0,0 +1,146 @@ +package integrationtests + +import ( + "fmt" + "testing" + + "github.com/mattermost/focalboard/server/client" + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createTestSubscriptions(client *client.Client, num int, workspaceID string) ([]*model.Subscription, string, error) { + newSubs := make([]*model.Subscription, 0, num) + + user, resp := client.GetMe() + if resp.Error != nil { + return nil, "", fmt.Errorf("cannot get current user: %w", resp.Error) + } + + for n := 0; n < num; n++ { + sub := &model.Subscription{ + BlockType: model.TypeCard, + BlockID: utils.NewID(utils.IDTypeCard), + WorkspaceID: workspaceID, + SubscriberType: model.SubTypeUser, + SubscriberID: user.ID, + } + + subNew, resp := client.CreateSubscription(workspaceID, sub) + if resp.Error != nil { + return nil, "", resp.Error + } + newSubs = append(newSubs, subNew) + } + return newSubs, user.ID, nil +} + +func TestCreateSubscription(t *testing.T) { + th := SetupTestHelper().InitBasic() + defer th.TearDown() + + container := store.Container{ + WorkspaceID: utils.NewID(utils.IDTypeWorkspace), + } + + t.Run("Create valid subscription", func(t *testing.T) { + subs, userID, err := createTestSubscriptions(th.Client, 5, container.WorkspaceID) + require.NoError(t, err) + require.Len(t, subs, 5) + + // fetch the newly created subscriptions and compare + subsFound, resp := th.Client.GetSubscriptions(container.WorkspaceID, userID) + require.NoError(t, resp.Error) + require.Len(t, subsFound, 5) + assert.ElementsMatch(t, subs, subsFound) + }) + + t.Run("Create invalid subscription", func(t *testing.T) { + user, resp := th.Client.GetMe() + require.NoError(t, resp.Error) + + sub := &model.Subscription{ + WorkspaceID: container.WorkspaceID, + SubscriberID: user.ID, + } + _, resp = th.Client.CreateSubscription(container.WorkspaceID, sub) + require.Error(t, resp.Error) + }) + + t.Run("Create subscription for another user", func(t *testing.T) { + sub := &model.Subscription{ + WorkspaceID: container.WorkspaceID, + SubscriberID: utils.NewID(utils.IDTypeUser), + } + _, resp := th.Client.CreateSubscription(container.WorkspaceID, sub) + require.Error(t, resp.Error) + }) +} + +func TestGetSubscriptions(t *testing.T) { + th := SetupTestHelperWithoutToken().InitBasic() + defer th.TearDown() + + err := th.InitUsers("user1", "user2") + require.NoError(t, err, "failed to init users") + + container := store.Container{ + WorkspaceID: utils.NewID(utils.IDTypeWorkspace), + } + + t.Run("Get subscriptions for user", func(t *testing.T) { + mySubs, user1ID, err := createTestSubscriptions(th.Client, 5, container.WorkspaceID) + require.NoError(t, err) + require.Len(t, mySubs, 5) + + // create more subscriptions with different user + otherSubs, _, err := createTestSubscriptions(th.Client2, 10, container.WorkspaceID) + require.NoError(t, err) + require.Len(t, otherSubs, 10) + + // fetch the newly created subscriptions for current user, making sure only + // the ones created for the current user are returned. + subsFound, resp := th.Client.GetSubscriptions(container.WorkspaceID, user1ID) + require.NoError(t, resp.Error) + require.Len(t, subsFound, 5) + assert.ElementsMatch(t, mySubs, subsFound) + }) +} + +func TestDeleteSubscription(t *testing.T) { + th := SetupTestHelper().InitBasic() + defer th.TearDown() + + container := store.Container{ + WorkspaceID: utils.NewID(utils.IDTypeWorkspace), + } + + t.Run("Delete valid subscription", func(t *testing.T) { + subs, userID, err := createTestSubscriptions(th.Client, 3, container.WorkspaceID) + require.NoError(t, err) + require.Len(t, subs, 3) + + resp := th.Client.DeleteSubscription(container.WorkspaceID, subs[1].BlockID, userID) + require.NoError(t, resp.Error) + + // fetch the subscriptions and ensure the list is correct + subsFound, resp := th.Client.GetSubscriptions(container.WorkspaceID, userID) + require.NoError(t, resp.Error) + require.Len(t, subsFound, 2) + + assert.Contains(t, subsFound, subs[0]) + assert.Contains(t, subsFound, subs[2]) + assert.NotContains(t, subsFound, subs[1]) + }) + + t.Run("Delete invalid subscription", func(t *testing.T) { + user, resp := th.Client.GetMe() + require.NoError(t, resp.Error) + + resp = th.Client.DeleteSubscription(container.WorkspaceID, "bogus", user.ID) + require.Error(t, resp.Error) + }) +} diff --git a/server/model/block.go b/server/model/block.go index 8bfdedeec..f3421b5b6 100644 --- a/server/model/block.go +++ b/server/model/block.go @@ -166,6 +166,21 @@ func (p *BlockPatch) Patch(block *Block) *Block { return block } +// QuerySubtreeOptions are query options that can be passed to GetSubTree methods. +type QuerySubtreeOptions struct { + BeforeUpdateAt int64 // if non-zero then filter for records with update_at less than BeforeUpdateAt + AfterUpdateAt int64 // if non-zero then filter for records with update_at greater than AfterUpdateAt + Limit uint64 // if non-zero then limit the number of returned records +} + +// QueryBlockHistoryOptions are query options that can be passed to GetBlockHistory. +type QueryBlockHistoryOptions struct { + BeforeUpdateAt int64 // if non-zero then filter for records with update_at less than BeforeUpdateAt + AfterUpdateAt int64 // if non-zero then filter for records with update_at greater than AfterUpdateAt + Limit uint64 // if non-zero then limit the number of returned records + Descending bool // if true then the records are sorted by insert_at in descending order +} + // GenerateBlockIDs generates new IDs for all the blocks of the list, // keeping consistent any references that other blocks would made to // the original IDs, so a tree of blocks can get new IDs and maintain diff --git a/server/model/notification.go b/server/model/notification.go new file mode 100644 index 000000000..3426cb09d --- /dev/null +++ b/server/model/notification.go @@ -0,0 +1,92 @@ +package model + +import ( + "time" + + "github.com/mattermost/mattermost-server/v6/utils" +) + +// NotificationHint provides a hint that a block has been modified and has subscribers that +// should be notified. +// swagger:model +type NotificationHint struct { + // BlockType is the block type of the entity (e.g. board, card) that was updated + // required: true + BlockType BlockType `json:"block_type"` + + // BlockID is id of the entity that was updated + // required: true + BlockID string `json:"block_id"` + + // WorkspaceID is id of workspace the block belongs to + // required: true + WorkspaceID string `json:"workspace_id"` + + // ModifiedByID is the id of the user who made the block change + ModifiedByID string `json:"modified_by_id"` + Username string `json:"-"` + + // CreatedAt is the timestamp this notification hint was created + // required: true + CreateAt int64 `json:"create_at"` + + // NotifyAt is the timestamp this notification should be scheduled + // required: true + NotifyAt int64 `json:"notify_at"` +} + +func (s *NotificationHint) IsValid() error { + if s == nil { + return ErrInvalidNotificationHint{"cannot be nil"} + } + if s.BlockID == "" { + return ErrInvalidNotificationHint{"missing block id"} + } + if s.WorkspaceID == "" { + return ErrInvalidNotificationHint{"missing workspace id"} + } + if s.BlockType == "" { + return ErrInvalidNotificationHint{"missing block type"} + } + if s.ModifiedByID == "" { + return ErrInvalidNotificationHint{"missing modified_by id"} + } + return nil +} + +func (s *NotificationHint) Copy() *NotificationHint { + return &NotificationHint{ + BlockType: s.BlockType, + BlockID: s.BlockID, + WorkspaceID: s.WorkspaceID, + ModifiedByID: s.ModifiedByID, + CreateAt: s.CreateAt, + NotifyAt: s.NotifyAt, + } +} + +func (s *NotificationHint) LogClone() interface{} { + return struct { + BlockType BlockType `json:"block_type"` + BlockID string `json:"block_id"` + WorkspaceID string `json:"workspace_id"` + ModifiedByID string `json:"modified_by_id"` + CreateAt string `json:"create_at"` + NotifyAt string `json:"notify_at"` + }{ + BlockType: s.BlockType, + BlockID: s.BlockID, + WorkspaceID: s.WorkspaceID, + ModifiedByID: s.ModifiedByID, + CreateAt: utils.TimeFromMillis(s.CreateAt).Format(time.StampMilli), + NotifyAt: utils.TimeFromMillis(s.NotifyAt).Format(time.StampMilli), + } +} + +type ErrInvalidNotificationHint struct { + msg string +} + +func (e ErrInvalidNotificationHint) Error() string { + return e.msg +} diff --git a/server/model/properties.go b/server/model/properties.go new file mode 100644 index 000000000..41b51ed27 --- /dev/null +++ b/server/model/properties.go @@ -0,0 +1,172 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "errors" + "fmt" +) + +var ErrInvalidBoardBlock = errors.New("invalid board block") +var ErrInvalidPropSchema = errors.New("invalid property schema") +var ErrInvalidProperty = errors.New("invalid property") + +// BlockProperties is a map of Prop's keyed by property id. +type BlockProperties map[string]BlockProp + +// BlockProp represent a property attached to a block (typically a card). +type BlockProp struct { + ID string `json:"id"` + Index int `json:"index"` + Name string `json:"name"` + Value string `json:"value"` +} + +// PropSchema is a map of PropDef's keyed by property id. +type PropSchema map[string]PropDef + +// PropDefOption represents an option within a property definition. +type PropDefOption struct { + ID string `json:"id"` + Index int `json:"index"` + Color string `json:"color"` + Value string `json:"value"` +} + +// PropDef represents a property definition as defined in a board's Fields member. +type PropDef struct { + ID string `json:"id"` + Index int `json:"index"` + Name string `json:"name"` + Type string `json:"type"` + Options map[string]PropDefOption `json:"options"` +} + +// GetValue resolves the value of a property if the passed value is an ID for an option, +// otherwise returns the original value. +func (pd PropDef) GetValue(v string) string { + // v may be an id to an option. + opt, ok := pd.Options[v] + if ok { + return opt.Value + } + return v +} + +// ParsePropertySchema parses a board block's `Fields` to extract the properties +// schema for all cards within the board. +// The result is provided as a map for quick lookup, and the original order is +// preserved via the `Index` field. +func ParsePropertySchema(board *Block) (PropSchema, error) { + if board == nil || board.Type != TypeBoard { + return nil, ErrInvalidBoardBlock + } + + schema := make(map[string]PropDef) + + // cardProperties contains a slice of maps (untyped at this point). + cardPropsIface, ok := board.Fields["cardProperties"] + if !ok { + return schema, nil + } + + cardProps, ok := cardPropsIface.([]interface{}) + if !ok || len(cardProps) == 0 { + return schema, nil + } + + for i, cp := range cardProps { + prop, ok := cp.(map[string]interface{}) + if !ok { + return nil, ErrInvalidPropSchema + } + + pd := PropDef{ + ID: getMapString("id", prop), + Index: i, + Name: getMapString("name", prop), + Type: getMapString("type", prop), + Options: make(map[string]PropDefOption), + } + optsIface, ok := prop["options"] + if ok { + opts, ok := optsIface.([]interface{}) + if !ok { + return nil, ErrInvalidPropSchema + } + for j, propOptIface := range opts { + propOpt, ok := propOptIface.(map[string]interface{}) + if !ok { + return nil, ErrInvalidPropSchema + } + po := PropDefOption{ + ID: getMapString("id", propOpt), + Index: j, + Value: getMapString("value", propOpt), + Color: getMapString("color", propOpt), + } + pd.Options[po.ID] = po + } + } + schema[pd.ID] = pd + } + return schema, nil +} + +func getMapString(key string, m map[string]interface{}) string { + iface, ok := m[key] + if !ok { + return "" + } + + s, ok := iface.(string) + if !ok { + return "" + } + return s +} + +// ParseProperties parses a block's `Fields` to extract the properties. Properties typically exist on +// card blocks. +func ParseProperties(block *Block, schema PropSchema) (BlockProperties, error) { + props := make(map[string]BlockProp) + + if block == nil { + return props, nil + } + + // `properties` contains a map (untyped at this point). + propsIface, ok := block.Fields["properties"] + if !ok { + return props, nil // this is expected for blocks that don't have any properties. + } + + blockProps, ok := propsIface.(map[string]interface{}) + if !ok { + return props, fmt.Errorf("`properties` field wrong type: %w", ErrInvalidProperty) + } + + if len(blockProps) == 0 { + return props, nil + } + + for k, v := range blockProps { + s := fmt.Sprintf("%v", v) + + prop := BlockProp{ + ID: k, + Name: k, + Value: s, + } + + def, ok := schema[k] + if ok { + prop.Name = def.Name + prop.Value = def.GetValue(s) + prop.Index = def.Index + } + props[k] = prop + } + return props, nil +} diff --git a/server/model/properties_test.go b/server/model/properties_test.go new file mode 100644 index 000000000..8b9eed88c --- /dev/null +++ b/server/model/properties_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "testing" + + "github.com/mattermost/focalboard/server/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parsePropertySchema(t *testing.T) { + board := &Block{ + ID: utils.NewID(utils.IDTypeBoard), + Type: TypeBoard, + Title: "Test Board", + WorkspaceID: utils.NewID(utils.IDTypeWorkspace), + } + + err := json.Unmarshal([]byte(fieldsExample), &board.Fields) + require.NoError(t, err) + + t.Run("parse schema", func(t *testing.T) { + schema, err := ParsePropertySchema(board) + require.NoError(t, err) + + assert.Len(t, schema, 6) + + prop, ok := schema["7c212e78-9345-4c60-81b5-0b0e37ce463f"] + require.True(t, ok) + + assert.Equal(t, "select", prop.Type) + assert.Equal(t, "Type", prop.Name) + assert.Len(t, prop.Options, 3) + + prop, ok = schema["a8spou7if43eo1rqzb9qeq488so"] + require.True(t, ok) + + assert.Equal(t, "date", prop.Type) + assert.Equal(t, "MyDate", prop.Name) + assert.Empty(t, prop.Options) + }) +} + +const ( + fieldsExample = ` + { + "cardProperties":[ + { + "id":"7c212e78-9345-4c60-81b5-0b0e37ce463f", + "name":"Type", + "options":[ + { + "color":"propColorYellow", + "id":"31da50ca-f1a9-4d21-8636-17dc387c1a23", + "value":"Ad Hoc" + }, + { + "color":"propColorBlue", + "id":"def6317c-ec11-410d-8a6b-ea461320f392", + "value":"Standup" + }, + { + "color":"propColorPurple", + "id":"700f83f8-6a41-46cd-87e2-53e0d0b12cc7", + "value":"Weekly Sync" + } + ], + "type":"select" + }, + { + "id":"13d2394a-eb5e-4f22-8c22-6515ec41c4a4", + "name":"Summary", + "options":[ + + ], + "type":"text" + }, + { + "id":"566cd860-bbae-4bcd-86a8-7df4db2ba15c", + "name":"Color", + "options":[ + { + "color":"propColorDefault", + "id":"efb0c783-f9ea-4938-8b86-9cf425296cd1", + "value":"RED" + }, + { + "color":"propColorDefault", + "id":"2f100e13-e7c4-4ab6-81c9-a17baf98b311", + "value":"GREEN" + }, + { + "color":"propColorDefault", + "id":"a05bdc80-bd90-45b0-8805-a7e77a4884be", + "value":"BLUE" + } + ], + "type":"select" + }, + { + "id":"aawg1s8rxq8o1bbksxmsmpsdd3r", + "name":"MyTextProp", + "options":[ + + ], + "type":"text" + }, + { + "id":"awdwfigo4kse63bdfp56mzhip6w", + "name":"MyCheckBox", + "options":[ + + ], + "type":"checkbox" + }, + { + "id":"a8spou7if43eo1rqzb9qeq488so", + "name":"MyDate", + "options":[ + + ], + "type":"date" + } + ], + "columnCalculations":[ + + ], + "description":"", + "icon":"🗒️", + "isTemplate":false, + "showDescription":false + } + ` +) diff --git a/server/model/subscription.go b/server/model/subscription.go new file mode 100644 index 000000000..77337d057 --- /dev/null +++ b/server/model/subscription.go @@ -0,0 +1,110 @@ +package model + +import ( + "encoding/json" + "io" +) + +const ( + SubTypeUser = "user" + SubTypeChannel = "channel" +) + +type SubscriberType string + +func (st SubscriberType) IsValid() bool { + switch st { + case SubTypeUser, SubTypeChannel: + return true + } + return false +} + +// Subscription is a subscription to a board, card, etc, for a user or channel. +// swagger:model +type Subscription struct { + // BlockType is the block type of the entity (e.g. board, card) subscribed to + // required: true + BlockType BlockType `json:"blockType"` + + // BlockID is id of the entity being subscribed to + // required: true + BlockID string `json:"blockId"` + + // WorkspaceID is id of the workspace the block belongs to + // required: true + WorkspaceID string `json:"workspaceId"` + + // SubscriberType is the type of the entity (e.g. user, channel) that is subscribing + // required: true + SubscriberType SubscriberType `json:"subscriberType"` + + // SubscriberID is the id of the entity that is subscribing + // required: true + SubscriberID string `json:"subscriberId"` + + // NotifiedAt is the timestamp of the last notification sent for this subscription + // required: true + NotifiedAt int64 `json:"notifiedAt,omitempty"` + + // CreatedAt is the timestamp this subscription was created + // required: true + CreateAt int64 `json:"createAt"` + + // DeleteAt is the timestamp this subscription was deleted, or zero if not deleted + // required: true + DeleteAt int64 `json:"deleteAt"` +} + +func (s *Subscription) IsValid() error { + if s == nil { + return ErrInvalidSubscription{"cannot be nil"} + } + if s.BlockID == "" { + return ErrInvalidSubscription{"missing block id"} + } + if s.WorkspaceID == "" { + return ErrInvalidSubscription{"missing workspace id"} + } + if s.BlockType == "" { + return ErrInvalidSubscription{"missing block type"} + } + if s.SubscriberID == "" { + return ErrInvalidSubscription{"missing subscriber id"} + } + if !s.SubscriberType.IsValid() { + return ErrInvalidSubscription{"invalid subscriber type"} + } + return nil +} + +func SubscriptionFromJSON(data io.Reader) (*Subscription, error) { + var subscription Subscription + if err := json.NewDecoder(data).Decode(&subscription); err != nil { + return nil, err + } + return &subscription, nil +} + +type ErrInvalidSubscription struct { + msg string +} + +func (e ErrInvalidSubscription) Error() string { + return e.msg +} + +// Subscriber is an entity (e.g. user, channel) that can subscribe to events from boards, cards, etc +// swagger:model +type Subscriber struct { + // SubscriberType is the type of the entity (e.g. user, channel) that is subscribing + // required: true + SubscriberType SubscriberType `json:"subscriber_type"` + + // SubscriberID is the id of the entity that is subscribing + // required: true + SubscriberID string `json:"subscriber_id"` + + // NotifiedAt is the timestamp this subscriber was last notified + NotifiedAt int64 `json:"notified_at"` +} diff --git a/server/model/util.go b/server/model/util.go new file mode 100644 index 000000000..dcb2f9667 --- /dev/null +++ b/server/model/util.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "time" + + mm_model "github.com/mattermost/mattermost-server/v6/model" +) + +// GetMillis is a convenience method to get milliseconds since epoch. +func GetMillis() int64 { + return mm_model.GetMillis() +} + +// GetMillisForTime is a convenience method to get milliseconds since epoch for provided Time. +func GetMillisForTime(thisTime time.Time) int64 { + return mm_model.GetMillisForTime(thisTime) +} + +// GetTimeForMillis is a convenience method to get time.Time for milliseconds since epoch. +func GetTimeForMillis(millis int64) time.Time { + return mm_model.GetTimeForMillis(millis) +} diff --git a/server/server/server.go b/server/server/server.go index fd6ebf421..584836de6 100644 --- a/server/server/server.go +++ b/server/server/server.go @@ -127,7 +127,7 @@ func New(params Params) (*Server, error) { // Init notification services notificationService, errNotify := initNotificationService(params.NotifyBackends, params.Logger) if errNotify != nil { - return nil, fmt.Errorf("cannot initialize notification service: %w", errNotify) + return nil, fmt.Errorf("cannot initialize notification service(s): %w", errNotify) } appServices := app.Services{ diff --git a/server/services/config/config.go b/server/services/config/config.go index 2aea14d4c..c17089628 100644 --- a/server/services/config/config.go +++ b/server/services/config/config.go @@ -57,6 +57,17 @@ type Configuration struct { AuditCfgFile string `json:"audit_cfg_file" mapstructure:"audit_cfg_file"` AuditCfgJSON string `json:"audit_cfg_json" mapstructure:"audit_cfg_json"` + + NotifyFreqCardSeconds int `json:"notify_freq_card_seconds" mapstructure:"notify_freq_card_seconds"` + NotifyFreqBoardSeconds int `json:"notify_freq_board_seconds" mapstructure:"notify_freq_board_seconds"` +} + +// IsSubscriptionsEnabled returns true if the block change notification subscription service should be enabled. +func (c *Configuration) IsSubscriptionsEnabled() bool { + if enabled, ok := c.FeatureFlags["subscriptions"]; ok && enabled == "true" { + return true + } + return false } // ReadConfigFile read the configuration from the filesystem. @@ -88,8 +99,9 @@ func ReadConfigFile(configFilePath string) (*Configuration, error) { viper.SetDefault("LocalModeSocketLocation", "/var/tmp/focalboard_local.socket") viper.SetDefault("EnablePublicSharedBoards", false) viper.SetDefault("FeatureFlags", map[string]string{}) - viper.SetDefault("AuthMode", "native") + viper.SetDefault("NotifyFreqCardSeconds", 120) // 2 minutes after last card edit + viper.SetDefault("NotifyFreqBoardSeconds", 86400) // 1 day after last card edit err := viper.ReadInConfig() // Find and read the config file if err != nil { // Handle errors reading the config file diff --git a/server/services/notify/notifymentions/delivery.go b/server/services/notify/notifymentions/delivery.go index 677e82c52..271f37334 100644 --- a/server/services/notify/notifymentions/delivery.go +++ b/server/services/notify/notifymentions/delivery.go @@ -5,8 +5,9 @@ package notifymentions import "github.com/mattermost/focalboard/server/services/notify" -// Delivery provides an interface for delivering notifications to other systems, such as -// MM server or email. -type Delivery interface { - Deliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) error +// MentionDelivery provides an interface for delivering @mention notifications to other systems, such as +// channels server via plugin API. +// On success the user id of the user mentioned is returned. +type MentionDelivery interface { + MentionDeliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) (string, error) } diff --git a/server/services/notify/notifymentions/mentions_backend.go b/server/services/notify/notifymentions/mentions_backend.go index 877b66684..196acd6d3 100644 --- a/server/services/notify/notifymentions/mentions_backend.go +++ b/server/services/notify/notifymentions/mentions_backend.go @@ -5,6 +5,7 @@ package notifymentions import ( "fmt" + "sync" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/notify" @@ -17,12 +18,20 @@ const ( backendName = "notifyMentions" ) -type Backend struct { - delivery Delivery - logger *mlog.Logger +type MentionListener interface { + OnMention(userID string, evt notify.BlockChangeEvent) } -func New(delivery Delivery, logger *mlog.Logger) *Backend { +// Backend provides the notification backend for @mentions. +type Backend struct { + delivery MentionDelivery + logger *mlog.Logger + + mux sync.RWMutex + listeners []MentionListener +} + +func New(delivery MentionDelivery, logger *mlog.Logger) *Backend { return &Backend{ delivery: delivery, logger: logger, @@ -42,6 +51,26 @@ func (b *Backend) Name() string { return backendName } +func (b *Backend) AddListener(l MentionListener) { + b.mux.Lock() + defer b.mux.Unlock() + b.listeners = append(b.listeners, l) + b.logger.Debug("Mention listener added.", mlog.Int("listener_count", len(b.listeners))) +} + +func (b *Backend) RemoveListener(l MentionListener) { + b.mux.Lock() + defer b.mux.Unlock() + list := make([]MentionListener, 0, len(b.listeners)) + for _, listener := range b.listeners { + if listener != l { + list = append(list, listener) + } + } + b.listeners = list + b.logger.Debug("Mention listener removed.", mlog.Int("listener_count", len(b.listeners))) +} + func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { if evt.Board == nil || evt.Card == nil { return nil @@ -63,6 +92,11 @@ func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { oldMentions := extractMentions(evt.BlockOld) merr := merror.New() + b.mux.RLock() + listeners := make([]MentionListener, len(b.listeners)) + copy(listeners, b.listeners) + b.mux.RUnlock() + for username := range mentions { if _, exists := oldMentions[username]; exists { // the mention already existed; no need to notify again @@ -71,10 +105,29 @@ func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { extract := extractText(evt.BlockChanged.Title, username, newLimits()) - err := b.delivery.Deliver(username, extract, evt) + userID, err := b.delivery.MentionDeliver(username, extract, evt) if err != nil { merr.Append(fmt.Errorf("cannot deliver notification for @%s: %w", username, err)) } + + b.logger.Debug("Mention notification delivered", + mlog.String("user", username), + mlog.Int("listener_count", len(listeners)), + ) + + for _, listener := range listeners { + safeCallListener(listener, userID, evt, b.logger) + } } return merr.ErrorOrNil() } + +func safeCallListener(listener MentionListener, userID string, evt notify.BlockChangeEvent, logger *mlog.Logger) { + // don't let panicky listeners stop notifications + defer func() { + if r := recover(); r != nil { + logger.Error("panic calling @mention notification listener", mlog.Any("err", r)) + } + }() + listener.OnMention(userID, evt) +} diff --git a/server/services/notify/notifysubscriptions/delivery.go b/server/services/notify/notifysubscriptions/delivery.go new file mode 100644 index 000000000..9e60d0ec9 --- /dev/null +++ b/server/services/notify/notifysubscriptions/delivery.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notifysubscriptions + +import ( + "github.com/mattermost/focalboard/server/model" + + mm_model "github.com/mattermost/mattermost-server/v6/model" +) + +// SubscriptionDelivery provides an interface for delivering subscription notifications to other systems, such as +// channels server via plugin API. +type SubscriptionDelivery interface { + SubscriptionDeliverSlackAttachments(workspaceID string, subscriberID string, subscriberType model.SubscriberType, + attachments []*mm_model.SlackAttachment) error +} diff --git a/server/services/notify/notifysubscriptions/diff.go b/server/services/notify/notifysubscriptions/diff.go new file mode 100644 index 000000000..f4f9d7adf --- /dev/null +++ b/server/services/notify/notifysubscriptions/diff.go @@ -0,0 +1,317 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notifysubscriptions + +import ( + "fmt" + "sort" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +// Diff represents a difference between two versions of a block. +type Diff struct { + Board *model.Block + Card *model.Block + Username string + + BlockType model.BlockType + OldBlock *model.Block + NewBlock *model.Block + + UpdateAt int64 // the UpdateAt of the latest version of the block + + schemaDiffs []SchemaDiff + PropDiffs []PropDiff + + Diffs []*Diff // Diffs for child blocks +} + +type PropDiff struct { + ID string // property id + Index int + Name string + OldValue string + NewValue string +} + +type SchemaDiff struct { + Board *model.Block + + OldPropDef *model.PropDef + NewPropDef *model.PropDef +} + +type diffGenerator struct { + container store.Container + board *model.Block + card *model.Block + + store Store + hint *model.NotificationHint + lastNotifyAt int64 + logger *mlog.Logger +} + +func (dg *diffGenerator) generateDiffs() ([]*Diff, error) { + // use block_history to fetch blocks in case they were deleted and no longer exist in blocks table. + opts := model.QueryBlockHistoryOptions{ + Limit: 1, + Descending: true, + } + blocks, err := dg.store.GetBlockHistory(dg.container, dg.hint.BlockID, opts) + if err != nil { + return nil, fmt.Errorf("could not get block for notification: %w", err) + } + if len(blocks) == 0 { + return nil, fmt.Errorf("block not found for notification: %w", err) + } + block := &blocks[0] + + if dg.board == nil || dg.card == nil { + return nil, fmt.Errorf("cannot generate diff for block %s; must have a valid board and card: %w", dg.hint.BlockID, err) + } + + user, err := dg.store.GetUserByID(dg.hint.ModifiedByID) + if err != nil { + return nil, fmt.Errorf("could not lookup user %s: %w", dg.hint.ModifiedByID, err) + } + if user != nil { + dg.hint.Username = user.Username + } else { + dg.hint.Username = "unknown user" // TODO: localize this when server gets i18n + } + + // parse board's property schema here so it only happens once. + schema, err := model.ParsePropertySchema(dg.board) + if err != nil { + return nil, fmt.Errorf("could not parse property schema for board %s: %w", dg.board.ID, err) + } + + switch block.Type { + case model.TypeBoard: + return dg.generateDiffsForBoard(block, schema) + case model.TypeCard: + diff, err := dg.generateDiffsForCard(block, schema) + if err != nil || diff == nil { + return nil, err + } + return []*Diff{diff}, nil + default: + diff, err := dg.generateDiffForBlock(block, schema) + if err != nil || diff == nil { + return nil, err + } + return []*Diff{diff}, nil + } +} + +func (dg *diffGenerator) generateDiffsForBoard(board *model.Block, schema model.PropSchema) ([]*Diff, error) { + opts := model.QuerySubtreeOptions{ + AfterUpdateAt: dg.lastNotifyAt, + } + + // find all child blocks of the board that updated since last notify. + blocks, err := dg.store.GetSubTree2(dg.container, board.ID, opts) + if err != nil { + return nil, fmt.Errorf("could not get subtree for board %s: %w", board.ID, err) + } + + var diffs []*Diff + + // generate diff for board title change or description + boardDiff, err := dg.generateDiffForBlock(board, schema) + if err != nil { + return nil, fmt.Errorf("could not generate diff for board %s: %w", board.ID, err) + } + + if boardDiff != nil { + // TODO: phase 2 feature (generate schema diffs and add to board diff) goes here. + diffs = append(diffs, boardDiff) + } + + for _, b := range blocks { + block := b + if block.Type == model.TypeCard { + cardDiffs, err := dg.generateDiffsForCard(&block, schema) + if err != nil { + return nil, err + } + diffs = append(diffs, cardDiffs) + } + } + return diffs, nil +} + +func (dg *diffGenerator) generateDiffsForCard(card *model.Block, schema model.PropSchema) (*Diff, error) { + // generate diff for card title change and properties. + cardDiff, err := dg.generateDiffForBlock(card, schema) + if err != nil { + return nil, fmt.Errorf("could not generate diff for card %s: %w", card.ID, err) + } + + // fetch all card content blocks that were updated after last notify + opts := model.QuerySubtreeOptions{ + AfterUpdateAt: dg.lastNotifyAt, + } + blocks, err := dg.store.GetSubTree2(dg.container, card.ID, opts) + if err != nil { + return nil, fmt.Errorf("could not get subtree for card %s: %w", card.ID, err) + } + + // walk child blocks + var childDiffs []*Diff + for i := range blocks { + if blocks[i].ID == card.ID { + continue + } + + blockDiff, err := dg.generateDiffForBlock(&blocks[i], schema) + if err != nil { + return nil, fmt.Errorf("could not generate diff for block %s: %w", blocks[i].ID, err) + } + if blockDiff != nil { + childDiffs = append(childDiffs, blockDiff) + } + } + + dg.logger.Debug("generateDiffsForCard", + mlog.Int("subtree", len(blocks)), + mlog.Int("child_diffs", len(childDiffs)), + ) + + if len(childDiffs) != 0 { + if cardDiff == nil { // will be nil if the card has no other changes besides child diffs + cardDiff = &Diff{ + Board: dg.board, + Card: card, + Username: dg.hint.Username, + BlockType: card.Type, + OldBlock: card, + NewBlock: card, + UpdateAt: card.UpdateAt, + PropDiffs: nil, + schemaDiffs: nil, + } + } + cardDiff.Diffs = childDiffs + } + return cardDiff, nil +} + +func (dg *diffGenerator) generateDiffForBlock(newBlock *model.Block, schema model.PropSchema) (*Diff, error) { + // find the version of the block as it was at the time of last notify. + opts := model.QueryBlockHistoryOptions{ + BeforeUpdateAt: dg.lastNotifyAt, + Limit: 1, + Descending: true, + } + history, err := dg.store.GetBlockHistory(dg.container, newBlock.ID, opts) + if err != nil { + return nil, fmt.Errorf("could not get block history for block %s: %w", newBlock.ID, err) + } + + var oldBlock *model.Block + if len(history) != 0 { + oldBlock = &history[0] + } + + propDiffs := dg.generatePropDiffs(oldBlock, newBlock, schema) + + dg.logger.Debug("generateDiffForBlock - results", + mlog.String("block_id", newBlock.ID), + mlog.Int64("before_update_at", opts.BeforeUpdateAt), + mlog.Int("history_count", len(history)), + mlog.Int("prop_diff_count", len(propDiffs)), + ) + + diff := &Diff{ + Board: dg.board, + Card: dg.card, + Username: dg.hint.Username, + BlockType: newBlock.Type, + OldBlock: oldBlock, + NewBlock: newBlock, + UpdateAt: newBlock.UpdateAt, + PropDiffs: propDiffs, + schemaDiffs: nil, + } + return diff, nil +} + +func (dg *diffGenerator) generatePropDiffs(oldBlock, newBlock *model.Block, schema model.PropSchema) []PropDiff { + var propDiffs []PropDiff + + oldProps, err := model.ParseProperties(oldBlock, schema) + if err != nil { + dg.logger.Error("Cannot parse properties for old block", + mlog.String("block_id", oldBlock.ID), + mlog.Err(err), + ) + } + + newProps, err := model.ParseProperties(newBlock, schema) + if err != nil { + dg.logger.Error("Cannot parse properties for new block", + mlog.String("block_id", oldBlock.ID), + mlog.Err(err), + ) + } + + // look for new or changed properties. + for k, prop := range newProps { + oldP, ok := oldProps[k] + if ok { + // prop changed + if prop.Value != oldP.Value { + propDiffs = append(propDiffs, PropDiff{ + ID: prop.ID, + Index: prop.Index, + Name: prop.Name, + NewValue: prop.Value, + OldValue: oldP.Value, + }) + } + } else { + // prop added + propDiffs = append(propDiffs, PropDiff{ + ID: prop.ID, + Index: prop.Index, + Name: prop.Name, + NewValue: prop.Value, + OldValue: "", + }) + } + } + + // look for deleted properties + for k, prop := range oldProps { + _, ok := newProps[k] + if !ok { + // prop deleted + propDiffs = append(propDiffs, PropDiff{ + ID: prop.ID, + Index: prop.Index, + Name: prop.Name, + NewValue: "", + OldValue: prop.Value, + }) + } + } + return sortPropDiffs(propDiffs) +} + +func sortPropDiffs(propDiffs []PropDiff) []PropDiff { + if len(propDiffs) == 0 { + return propDiffs + } + + sort.Slice(propDiffs, func(i, j int) bool { + return propDiffs[i].Index < propDiffs[j].Index + }) + return propDiffs +} diff --git a/server/services/notify/notifysubscriptions/diff2slackattachments.go b/server/services/notify/notifysubscriptions/diff2slackattachments.go new file mode 100644 index 000000000..c233834ce --- /dev/null +++ b/server/services/notify/notifysubscriptions/diff2slackattachments.go @@ -0,0 +1,240 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notifysubscriptions + +import ( + "bytes" + "fmt" + "io" + "strings" + "sync" + "text/template" + + "github.com/mattermost/focalboard/server/model" + "github.com/wiggin77/merror" + + mm_model "github.com/mattermost/mattermost-server/v6/model" +) + +const ( + // card change notifications. + defAddCardNotify = "@{{.Username}} has added the card {{.NewBlock | makeLink}}\n" + defModifyCardNotify = "###### @{{.Username}} has modified the card {{.Card | makeLink}}\n" + defDeleteCardNotify = "@{{.Username}} has deleted the card {{.Card | makeLink}}\n" +) + +var ( + // templateCache is a map of text templateCache keyed by languange code. + templateCache = make(map[string]*template.Template) + templateCacheMux sync.Mutex +) + +// DiffConvOpts provides options when converting diffs to slack attachments. +type DiffConvOpts struct { + Language string + MakeCardLink func(block *model.Block) string +} + +// getTemplate returns a new or cached named template based on the language specified. +func getTemplate(name string, opts DiffConvOpts, def string) (*template.Template, error) { + templateCacheMux.Lock() + defer templateCacheMux.Unlock() + + key := name + "&" + opts.Language + t, ok := templateCache[key] + if !ok { + t = template.New(key) + + if opts.MakeCardLink == nil { + opts.MakeCardLink = func(block *model.Block) string { return fmt.Sprintf("`%s`", block.Title) } + } + myFuncs := template.FuncMap{ + "getBoardDescription": getBoardDescription, + "makeLink": opts.MakeCardLink, + "stripNewlines": func(s string) string { + return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ ")) + }, + } + t.Funcs(myFuncs) + + s := def // TODO: lookup i18n string when supported on server + t2, err := t.Parse(s) + if err != nil { + return nil, fmt.Errorf("cannot parse markdown template '%s' for notifications: %w", key, err) + } + templateCache[key] = t2 + } + return t, nil +} + +// execTemplate executes the named template corresponding to the template name and language specified. +func execTemplate(w io.Writer, name string, opts DiffConvOpts, def string, data interface{}) error { + t, err := getTemplate(name, opts, def) + if err != nil { + return err + } + return t.Execute(w, data) +} + +// Diffs2SlackAttachments converts a slice of `Diff` to slack attachments to be used in a post. +func Diffs2SlackAttachments(diffs []*Diff, opts DiffConvOpts) ([]*mm_model.SlackAttachment, error) { + var attachments []*mm_model.SlackAttachment + merr := merror.New() + + for _, d := range diffs { + // only handle cards for now. + if d.BlockType == model.TypeCard { + a, err := cardDiff2SlackAttachment(d, opts) + if err != nil { + merr.Append(err) + continue + } + attachments = append(attachments, a) + } + } + return attachments, merr.ErrorOrNil() +} + +func cardDiff2SlackAttachment(cardDiff *Diff, opts DiffConvOpts) (*mm_model.SlackAttachment, error) { + // sanity check + if cardDiff.NewBlock == nil && cardDiff.OldBlock == nil { + return nil, nil + } + + attachment := &mm_model.SlackAttachment{} + buf := &bytes.Buffer{} + + // card added + if cardDiff.NewBlock != nil && cardDiff.OldBlock == nil { + if err := execTemplate(buf, "AddCardNotify", opts, defAddCardNotify, cardDiff); err != nil { + return nil, err + } + attachment.Pretext = buf.String() + attachment.Fallback = attachment.Pretext + return attachment, nil + } + + // card deleted + if (cardDiff.NewBlock == nil || cardDiff.NewBlock.DeleteAt != 0) && cardDiff.OldBlock != nil { + buf.Reset() + if err := execTemplate(buf, "DeleteCardNotify", opts, defDeleteCardNotify, cardDiff); err != nil { + return nil, err + } + attachment.Pretext = buf.String() + attachment.Fallback = attachment.Pretext + return attachment, nil + } + + // at this point new and old block are non-nil + + buf.Reset() + if err := execTemplate(buf, "ModifyCardNotify", opts, defModifyCardNotify, cardDiff); err != nil { + return nil, fmt.Errorf("cannot write notification for card %s: %w", cardDiff.NewBlock.ID, err) + } + attachment.Pretext = buf.String() + attachment.Fallback = attachment.Pretext + + // title changes + if cardDiff.NewBlock.Title != cardDiff.OldBlock.Title { + attachment.Fields = append(attachment.Fields, &mm_model.SlackAttachmentField{ + Short: false, + Title: "Title", + Value: fmt.Sprintf("%s ~~`%s`~~", cardDiff.NewBlock.Title, cardDiff.OldBlock.Title), + }) + } + + // property changes + if len(cardDiff.PropDiffs) > 0 { + for _, propDiff := range cardDiff.PropDiffs { + if propDiff.NewValue == propDiff.OldValue { + continue + } + + var val string + if propDiff.OldValue != "" { + val = fmt.Sprintf("%s ~~`%s`~~", propDiff.NewValue, propDiff.OldValue) + } else { + val = propDiff.NewValue + } + + attachment.Fields = append(attachment.Fields, &mm_model.SlackAttachmentField{ + Short: false, + Title: propDiff.Name, + Value: val, + }) + } + } + + // comment add/delete + for _, child := range cardDiff.Diffs { + if child.BlockType == model.TypeComment { + var format string + var block *model.Block + if child.NewBlock != nil && child.OldBlock == nil { + // added comment + format = "%s" + block = child.NewBlock + } + + if child.NewBlock == nil && child.OldBlock != nil { + // deleted comment + format = "~~`%s`~~" + block = child.OldBlock + } + + if format != "" { + attachment.Fields = append(attachment.Fields, &mm_model.SlackAttachmentField{ + Short: false, + Title: "Comment", + Value: fmt.Sprintf(format, stripNewlines(block.Title)), + }) + } + } + } + + // content/description changes + for _, child := range cardDiff.Diffs { + if child.BlockType != model.TypeComment { + var newTitle, oldTitle string + if child.NewBlock != nil { + newTitle = stripNewlines(child.NewBlock.Title) + } + if child.OldBlock != nil { + oldTitle = stripNewlines(child.OldBlock.Title) + } + + if newTitle == oldTitle { + continue + } + + /* + TODO: use diff lib for content changes which can be many paragraphs. + Unfortunately `github.com/sergi/go-diff` is not suitable for + markdown display. An alternate markdown friendly lib is being + worked on at github.com/wiggin77/go-difflib and will be substituted + here when ready. + + newTxt := cleanBlockTitle(child.NewBlock) + oldTxt := cleanBlockTitle(child.OldBlock) + + dmp := diffmatchpatch.New() + txtDiffs := dmp.DiffMain(oldTxt, newTxt, true) + + _, _ = w.Write([]byte(dmp.DiffPrettyText(txtDiffs))) + + */ + + if oldTitle != "" { + oldTitle = fmt.Sprintf("\n~~`%s`~~", oldTitle) + } + + attachment.Fields = append(attachment.Fields, &mm_model.SlackAttachmentField{ + Short: false, + Title: "Description", + Value: newTitle + oldTitle, + }) + } + } + return attachment, nil +} diff --git a/server/services/notify/notifysubscriptions/notifier.go b/server/services/notify/notifysubscriptions/notifier.go new file mode 100644 index 000000000..8476af29b --- /dev/null +++ b/server/services/notify/notifysubscriptions/notifier.go @@ -0,0 +1,251 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notifysubscriptions + +import ( + "errors" + "fmt" + "sync" + "time" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/utils" + "github.com/wiggin77/merror" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +const ( + defBlockNotificationFreq = time.Minute * 2 + enqueueNotifyHintTimeout = time.Second * 10 + hintQueueSize = 20 +) + +var ( + errEnqueueNotifyHintTimeout = errors.New("enqueue notify hint timed out") +) + +// notifier provides block change notifications for subscribers. Block change events are batched +// via notifications hints written to the database so that fewer notifications are sent for active +// blocks. +type notifier struct { + serverRoot string + store Store + delivery SubscriptionDelivery + logger *mlog.Logger + + hints chan *model.NotificationHint + + mux sync.Mutex + done chan struct{} +} + +func newNotifier(params BackendParams) *notifier { + return ¬ifier{ + serverRoot: params.ServerRoot, + store: params.Store, + delivery: params.Delivery, + logger: params.Logger, + done: nil, + hints: make(chan *model.NotificationHint, hintQueueSize), + } +} + +func (n *notifier) start() { + n.mux.Lock() + defer n.mux.Unlock() + + if n.done == nil { + n.done = make(chan struct{}) + go n.loop() + } +} + +func (n *notifier) stop() { + n.mux.Lock() + defer n.mux.Unlock() + + if n.done != nil { + close(n.done) + n.done = nil + } +} + +func (n *notifier) loop() { + done := n.done + var nextNotify time.Time + + for { + hint, err := n.store.GetNextNotificationHint(false) + switch { + case n.store.IsErrNotFound(err): + // no hints in table; wait up to an hour or when `onNotifyHint` is called again + nextNotify = time.Now().Add(time.Hour * 1) + n.logger.Debug("notify loop - no hints in queue", mlog.Time("next_check", nextNotify)) + case err != nil: + // try again in a minute + nextNotify = time.Now().Add(time.Minute * 1) + n.logger.Error("notify loop - error fetching next notification", mlog.Err(err)) + case hint.NotifyAt > utils.GetMillis(): + // next hint is not ready yet; sleep until hint.NotifyAt + nextNotify = utils.GetTimeForMillis(hint.NotifyAt) + default: + // it's time to notify + n.notify() + continue + } + + n.logger.Debug("subscription notifier loop", + mlog.Time("next_notify", nextNotify), + ) + + select { + case <-n.hints: + // A new hint was added. Wake up and check if next hint is ready to go. + case <-time.After(time.Until(nextNotify)): + // Next scheduled hint should be ready now. + case <-done: + return + } + } +} + +func (n *notifier) onNotifyHint(hint *model.NotificationHint) error { + n.logger.Debug("onNotifyHint - enqueing hint", mlog.Any("hint", hint)) + + select { + case n.hints <- hint: + case <-time.After(enqueueNotifyHintTimeout): + return errEnqueueNotifyHintTimeout + } + return nil +} + +func (n *notifier) notify() { + var hint *model.NotificationHint + var err error + + hint, err = n.store.GetNextNotificationHint(true) + if err != nil { + // try again later + n.logger.Error("notify - error fetching next notification", mlog.Err(err)) + return + } + + if err = n.notifySubscribers(hint); err != nil { + n.logger.Error("Error notifying subscribers", mlog.Err(err)) + } +} + +func (n *notifier) notifySubscribers(hint *model.NotificationHint) error { + c := store.Container{ + WorkspaceID: hint.WorkspaceID, + } + + // get the subscriber list + subs, err := n.store.GetSubscribersForBlock(c, hint.BlockID) + if err != nil { + return err + } + if len(subs) == 0 { + n.logger.Debug("notifySubscribers - no subscribers", mlog.Any("hint", hint)) + return nil + } + + n.logger.Debug("notifySubscribers - subscribers", + mlog.Any("hint", hint), + mlog.Int("sub_count", len(subs)), + ) + + // subs slice is sorted by `NotifiedAt`, therefore subs[0] contains the oldest NotifiedAt needed + oldestNotifiedAt := subs[0].NotifiedAt + + // need the block's board and card. + board, card, err := n.store.GetBoardAndCardByID(c, hint.BlockID) + if err != nil || board == nil || card == nil { + return fmt.Errorf("could not get board & card for block %s: %w", hint.BlockID, err) + } + + dg := &diffGenerator{ + container: c, + board: board, + card: card, + store: n.store, + hint: hint, + lastNotifyAt: oldestNotifiedAt, + logger: n.logger, + } + diffs, err := dg.generateDiffs() + if err != nil { + return err + } + + n.logger.Debug("notifySubscribers - diffs", + mlog.Any("hint", hint), + mlog.Int("diff_count", len(diffs)), + ) + + if len(diffs) == 0 { + return nil + } + + opts := DiffConvOpts{ + Language: "en", // TODO: use correct language with i18n available on server. + MakeCardLink: func(block *model.Block) string { + return fmt.Sprintf("[%s](%s)", block.Title, utils.MakeCardLink(n.serverRoot, board.WorkspaceID, board.ID, card.ID)) + }, + } + + attachments, err := Diffs2SlackAttachments(diffs, opts) + if err != nil { + return err + } + + merr := merror.New() + for _, sub := range subs { + // don't notify the author of their own changes. + if sub.SubscriberID == hint.ModifiedByID { + n.logger.Debug("notifySubscribers - deliver, skipping author", + mlog.Any("hint", hint), + mlog.String("modified_by_id", hint.ModifiedByID), + mlog.String("modified_by_username", hint.Username), + ) + continue + } + + n.logger.Debug("notifySubscribers - deliver", + mlog.Any("hint", hint), + mlog.String("modified_by_id", hint.ModifiedByID), + mlog.String("subscriber_id", sub.SubscriberID), + mlog.String("subscriber_type", string(sub.SubscriberType)), + ) + + if err = n.delivery.SubscriptionDeliverSlackAttachments(hint.WorkspaceID, sub.SubscriberID, sub.SubscriberType, attachments); err != nil { + merr.Append(fmt.Errorf("cannot deliver notification to subscriber %s [%s]: %w", + sub.SubscriberID, sub.SubscriberType, err)) + } + } + + // find the new NotifiedAt based on the newest diff. + var notifiedAt int64 + for _, d := range diffs { + if d.UpdateAt > notifiedAt { + notifiedAt = d.UpdateAt + } + for _, c := range d.Diffs { + if c.UpdateAt > notifiedAt { + notifiedAt = c.UpdateAt + } + } + } + + // update the last notified_at for all subscribers since we at least attempted to notify all of them. + err = dg.store.UpdateSubscribersNotifiedAt(dg.container, dg.hint.BlockID, notifiedAt) + if err != nil { + merr.Append(fmt.Errorf("could not update subscribers notified_at for block %s: %w", dg.hint.BlockID, err)) + } + + return merr.ErrorOrNil() +} diff --git a/server/services/notify/notifysubscriptions/store.go b/server/services/notify/notifysubscriptions/store.go new file mode 100644 index 000000000..c8182cc25 --- /dev/null +++ b/server/services/notify/notifysubscriptions/store.go @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notifysubscriptions + +import ( + "time" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" +) + +type Store interface { + GetBlock(c store.Container, blockID string) (*model.Block, error) + GetBlockHistory(c store.Container, blockID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error) + GetSubTree2(c store.Container, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) + GetBoardAndCardByID(c store.Container, blockID string) (board *model.Block, card *model.Block, err error) + + GetUserByID(userID string) (*model.User, error) + + CreateSubscription(c store.Container, sub *model.Subscription) (*model.Subscription, error) + GetSubscribersForBlock(c store.Container, blockID string) ([]*model.Subscriber, error) + GetSubscribersCountForBlock(c store.Container, blockID string) (int, error) + UpdateSubscribersNotifiedAt(c store.Container, blockID string, notifyAt int64) error + + UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) + GetNextNotificationHint(remove bool) (*model.NotificationHint, error) + + IsErrNotFound(err error) bool +} diff --git a/server/services/notify/notifysubscriptions/subscriptions_backend.go b/server/services/notify/notifysubscriptions/subscriptions_backend.go new file mode 100644 index 000000000..b18e67e7f --- /dev/null +++ b/server/services/notify/notifysubscriptions/subscriptions_backend.go @@ -0,0 +1,221 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notifysubscriptions + +import ( + "fmt" + "time" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/notify" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/ws" + "github.com/wiggin77/merror" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +const ( + backendName = "notifySubscriptions" +) + +type BackendParams struct { + ServerRoot string + Store Store + Delivery SubscriptionDelivery + WSAdapter ws.Adapter + Logger *mlog.Logger + NotifyFreqCardSeconds int + NotifyFreqBoardSeconds int +} + +// Backend provides the notification backend for subscriptions. +type Backend struct { + store Store + delivery SubscriptionDelivery + notifier *notifier + wsAdapter ws.Adapter + logger *mlog.Logger + notifyFreqCardSeconds int + notifyFreqBoardSeconds int +} + +func New(params BackendParams) *Backend { + return &Backend{ + store: params.Store, + delivery: params.Delivery, + notifier: newNotifier(params), + wsAdapter: params.WSAdapter, + logger: params.Logger, + notifyFreqCardSeconds: params.NotifyFreqCardSeconds, + notifyFreqBoardSeconds: params.NotifyFreqBoardSeconds, + } +} + +func (b *Backend) Start() error { + b.logger.Debug("Starting subscriptions backend", + mlog.Int("freq_card", b.notifyFreqCardSeconds), + mlog.Int("freq_board", b.notifyFreqBoardSeconds), + ) + b.notifier.start() + return nil +} + +func (b *Backend) ShutDown() error { + b.logger.Debug("Stopping subscriptions backend") + b.notifier.stop() + _ = b.logger.Flush() + return nil +} + +func (b *Backend) Name() string { + return backendName +} + +func (b *Backend) getBlockUpdateFreq(blockType model.BlockType) time.Duration { + switch blockType { + case model.TypeCard: + return time.Second * time.Duration(b.notifyFreqCardSeconds) + case model.TypeBoard: + return time.Second * time.Duration(b.notifyFreqBoardSeconds) + default: + return defBlockNotificationFreq + } +} + +func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { + if evt.Board == nil { + b.logger.Warn("No board found for block, skipping notify", + mlog.String("block_id", evt.BlockChanged.ID), + ) + return nil + } + + merr := merror.New() + var err error + + c := store.Container{ + WorkspaceID: evt.Workspace, + } + + // if new card added, automatically subscribe the author. + if evt.Action == notify.Add && evt.BlockChanged.Type == model.TypeCard { + sub := &model.Subscription{ + BlockType: model.TypeCard, + BlockID: evt.BlockChanged.ID, + WorkspaceID: evt.Workspace, + SubscriberType: model.SubTypeUser, + SubscriberID: evt.ModifiedByID, + } + + if sub, err = b.store.CreateSubscription(c, sub); err != nil { + b.logger.Warn("Cannot subscribe card author to card", + mlog.String("card_id", evt.BlockChanged.ID), + mlog.Err(err), + ) + } + b.wsAdapter.BroadcastSubscriptionChange(c.WorkspaceID, sub) + } + + // notify board subscribers + subs, err := b.store.GetSubscribersForBlock(c, evt.Board.ID) + if err != nil { + merr.Append(fmt.Errorf("cannot fetch subscribers for board %s: %w", evt.Board.ID, err)) + } + if err = b.notifySubscribers(subs, evt.Board, evt.ModifiedByID); err != nil { + merr.Append(fmt.Errorf("cannot notify board subscribers for board %s: %w", evt.Board.ID, err)) + } + + if evt.Card == nil { + return merr.ErrorOrNil() + } + + // notify card subscribers + subs, err = b.store.GetSubscribersForBlock(c, evt.Card.ID) + if err != nil { + merr.Append(fmt.Errorf("cannot fetch subscribers for card %s: %w", evt.Card.ID, err)) + } + if err = b.notifySubscribers(subs, evt.Card, evt.ModifiedByID); err != nil { + merr.Append(fmt.Errorf("cannot notify card subscribers for card %s: %w", evt.Card.ID, err)) + } + + // notify block subscribers (if/when other types can be subscribed to) + if evt.Board.ID != evt.BlockChanged.ID && evt.Card.ID != evt.BlockChanged.ID { + subs, err := b.store.GetSubscribersForBlock(c, evt.BlockChanged.ID) + if err != nil { + merr.Append(fmt.Errorf("cannot fetch subscribers for block %s: %w", evt.BlockChanged.ID, err)) + } + if err := b.notifySubscribers(subs, evt.BlockChanged, evt.ModifiedByID); err != nil { + merr.Append(fmt.Errorf("cannot notify block subscribers for block %s: %w", evt.BlockChanged.ID, err)) + } + } + return merr.ErrorOrNil() +} + +// notifySubscribers triggers a change notification for subscribers by writing a notification hint to the database. +func (b *Backend) notifySubscribers(subs []*model.Subscriber, block *model.Block, modifiedByID string) error { + if len(subs) == 0 { + return nil + } + + hint := &model.NotificationHint{ + BlockType: block.Type, + BlockID: block.ID, + WorkspaceID: block.WorkspaceID, + ModifiedByID: modifiedByID, + } + + hint, err := b.store.UpsertNotificationHint(hint, b.getBlockUpdateFreq(block.Type)) + if err != nil { + return fmt.Errorf("cannot upsert notification hint: %w", err) + } + + return b.notifier.onNotifyHint(hint) +} + +// OnMention satisfies the `MentionListener` interface and is called whenever a @mention notification +// is sent. Here we create a subscription for the mentioned user to the card. +func (b *Backend) OnMention(userID string, evt notify.BlockChangeEvent) { + if evt.Card == nil { + b.logger.Debug("Cannot subscribe mentioned user to nil card", + mlog.String("user_id", userID), + mlog.String("block_id", evt.BlockChanged.ID), + ) + return + } + + sub := &model.Subscription{ + BlockType: model.TypeCard, + BlockID: evt.Card.ID, + WorkspaceID: evt.Workspace, + SubscriberType: model.SubTypeUser, + SubscriberID: userID, + } + + c := store.Container{ + WorkspaceID: evt.Workspace, + } + var err error + + if sub, err = b.store.CreateSubscription(c, sub); err != nil { + b.logger.Warn("Cannot subscribe mentioned user to card", + mlog.String("user_id", userID), + mlog.String("card_id", evt.Card.ID), + mlog.Err(err), + ) + return + } + b.wsAdapter.BroadcastSubscriptionChange(c.WorkspaceID, sub) + + b.logger.Debug("Subscribed mentioned user to card", + mlog.String("user_id", userID), + mlog.String("card_id", evt.Card.ID), + ) +} + +// BroadcastSubscriptionChange sends a websocket message with details of the changed subscription to all +// connected users in the workspace. +func (b *Backend) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { + b.wsAdapter.BroadcastSubscriptionChange(workspaceID, subscription) +} diff --git a/server/services/notify/notifysubscriptions/util.go b/server/services/notify/notifysubscriptions/util.go new file mode 100644 index 000000000..800640a63 --- /dev/null +++ b/server/services/notify/notifysubscriptions/util.go @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notifysubscriptions + +import ( + "strings" + + "github.com/mattermost/focalboard/server/model" +) + +func getBoardDescription(board *model.Block) string { + if board == nil { + return "" + } + + descr, ok := board.Fields["description"] + if !ok { + return "" + } + + description, ok := descr.(string) + if !ok { + return "" + } + + return description +} + +func stripNewlines(s string) string { + return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ ")) +} diff --git a/server/services/notify/plugindelivery/mention_deliver.go b/server/services/notify/plugindelivery/mention_deliver.go new file mode 100644 index 000000000..46c06fbfa --- /dev/null +++ b/server/services/notify/plugindelivery/mention_deliver.go @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package plugindelivery + +import ( + "fmt" + + "github.com/mattermost/focalboard/server/services/notify" + "github.com/mattermost/focalboard/server/utils" + + "github.com/mattermost/mattermost-server/v6/model" +) + +// MentionDeliver notifies a user they have been mentioned in a block. +func (pd *PluginDelivery) MentionDeliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) (string, error) { + // determine which team the workspace is associated with + teamID, err := pd.getTeamID(evt) + if err != nil { + return "", fmt.Errorf("cannot determine teamID for block change notification: %w", err) + } + + member, err := teamMemberFromUsername(pd.api, mentionUsername, teamID) + if err != nil { + if isErrNotFound(err) { + // not really an error; could just be someone typed "@sometext" + return "", nil + } else { + return "", fmt.Errorf("cannot lookup mentioned user: %w", err) + } + } + + // check that user is a member of the channel + _, err = pd.api.GetChannelMember(evt.Workspace, member.UserId) + if err != nil { + if pd.api.IsErrNotFound(err) { + // mentioned user is not a member of the channel; fail silently. + return "", nil + } + return "", fmt.Errorf("cannot fetch channel member for user %s: %w", member.UserId, err) + } + + author, err := pd.api.GetUserByID(evt.ModifiedByID) + if err != nil { + return "", fmt.Errorf("cannot find user: %w", err) + } + + channel, err := pd.api.GetDirectChannel(member.UserId, pd.botID) + if err != nil { + return "", fmt.Errorf("cannot get direct channel: %w", err) + } + link := utils.MakeCardLink(pd.serverRoot, evt.Workspace, evt.Board.ID, evt.Card.ID) + + post := &model.Post{ + UserId: pd.botID, + ChannelId: channel.Id, + Message: formatMessage(author.Username, extract, evt.Card.Title, link, evt.BlockChanged), + } + return member.UserId, pd.api.CreatePost(post) +} diff --git a/server/services/notify/plugindelivery/message.go b/server/services/notify/plugindelivery/message.go index 97037428d..f627bf3b7 100644 --- a/server/services/notify/plugindelivery/message.go +++ b/server/services/notify/plugindelivery/message.go @@ -22,7 +22,3 @@ func formatMessage(author string, extract string, card string, link string, bloc } return fmt.Sprintf(template, author, card, link, extract) } - -func makeLink(serverRoot string, workspace string, board string, card string) string { - return fmt.Sprintf("%s/workspace/%s/%s/0/%s/", serverRoot, workspace, board, card) -} diff --git a/server/services/notify/plugindelivery/plugin_delivery.go b/server/services/notify/plugindelivery/plugin_delivery.go index 817c5826a..4aa8a2044 100644 --- a/server/services/notify/plugindelivery/plugin_delivery.go +++ b/server/services/notify/plugindelivery/plugin_delivery.go @@ -4,35 +4,33 @@ package plugindelivery import ( - "fmt" - "github.com/mattermost/focalboard/server/services/notify" - "github.com/mattermost/mattermost-server/v6/model" + mm_model "github.com/mattermost/mattermost-server/v6/model" ) type PluginAPI interface { // GetDirectChannel gets a direct message channel. // If the channel does not exist it will create it. - GetDirectChannel(userID1, userID2 string) (*model.Channel, error) + GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) // CreatePost creates a post. - CreatePost(post *model.Post) error + CreatePost(post *mm_model.Post) error // GetUserByID gets a user by their ID. - GetUserByID(userID string) (*model.User, error) + GetUserByID(userID string) (*mm_model.User, error) // GetUserByUsername gets a user by their username. - GetUserByUsername(name string) (*model.User, error) + GetUserByUsername(name string) (*mm_model.User, error) // GetTeamMember gets a team member by their user id. - GetTeamMember(teamID string, userID string) (*model.TeamMember, error) + GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) // GetChannelByID gets a Channel by its ID. - GetChannelByID(channelID string) (*model.Channel, error) + GetChannelByID(channelID string) (*mm_model.Channel, error) // GetChannelMember gets a channel member by userID. - GetChannelMember(channelID string, userID string) (*model.ChannelMember, error) + GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) // IsErrNotFound returns true if `err` or one of its wrapped children are the `ErrNotFound` // as defined in the plugin API. @@ -54,52 +52,6 @@ func New(botID string, serverRoot string, api PluginAPI) *PluginDelivery { } } -func (pd *PluginDelivery) Deliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) error { - // determine which team the workspace is associated with - teamID, err := pd.getTeamID(evt) - if err != nil { - return fmt.Errorf("cannot determine teamID for block change notification: %w", err) - } - - member, err := teamMemberFromUsername(pd.api, mentionUsername, teamID) - if err != nil { - if isErrNotFound(err) { - // not really an error; could just be someone typed "@sometext" - return nil - } else { - return fmt.Errorf("cannot lookup mentioned user: %w", err) - } - } - - // check that user is a member of the channel - _, err = pd.api.GetChannelMember(evt.Workspace, member.UserId) - if err != nil { - if pd.api.IsErrNotFound(err) { - // mentioned user is not a member of the channel; fail silently. - return nil - } - return fmt.Errorf("cannot fetch channel member for user %s: %w", member.UserId, err) - } - - author, err := pd.api.GetUserByID(evt.UserID) - if err != nil { - return fmt.Errorf("cannot find user: %w", err) - } - - channel, err := pd.api.GetDirectChannel(member.UserId, pd.botID) - if err != nil { - return fmt.Errorf("cannot get direct channel: %w", err) - } - link := makeLink(pd.serverRoot, evt.Workspace, evt.Board.ID, evt.Card.ID) - - post := &model.Post{ - UserId: pd.botID, - ChannelId: channel.Id, - Message: formatMessage(author.Username, extract, evt.Card.Title, link, evt.BlockChanged), - } - return pd.api.CreatePost(post) -} - func (pd *PluginDelivery) getTeamID(evt notify.BlockChangeEvent) (string, error) { // for now, the workspace ID is also the channel ID channel, err := pd.api.GetChannelByID(evt.Workspace) diff --git a/server/services/notify/plugindelivery/subscription_deliver.go b/server/services/notify/plugindelivery/subscription_deliver.go new file mode 100644 index 000000000..16aaec60e --- /dev/null +++ b/server/services/notify/plugindelivery/subscription_deliver.go @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package plugindelivery + +import ( + "errors" + "fmt" + + "github.com/mattermost/focalboard/server/model" + + mm_model "github.com/mattermost/mattermost-server/v6/model" +) + +var ( + ErrUnsupportedSubscriberType = errors.New("invalid subscriber type") +) + +// SubscriptionDeliverSlashAttachments notifies a user that changes were made to a block they are subscribed to. +func (pd *PluginDelivery) SubscriptionDeliverSlackAttachments(workspaceID string, subscriberID string, subscriptionType model.SubscriberType, + attachments []*mm_model.SlackAttachment) error { + // check subscriber is member of channel + _, err := pd.api.GetChannelMember(workspaceID, subscriberID) + if err != nil { + if pd.api.IsErrNotFound(err) { + // subscriber is not a member of the channel; fail silently. + return nil + } + return fmt.Errorf("cannot fetch channel member for user %s: %w", subscriberID, err) + } + + channelID, err := pd.getDirectChannelID(subscriberID, subscriptionType, pd.botID) + if err != nil { + return err + } + + post := &mm_model.Post{ + UserId: pd.botID, + ChannelId: channelID, + } + + mm_model.ParseSlackAttachment(post, attachments) + + return pd.api.CreatePost(post) +} + +func (pd *PluginDelivery) getDirectChannelID(subscriberID string, subscriberType model.SubscriberType, botID string) (string, error) { + switch subscriberType { + case model.SubTypeUser: + user, err := pd.api.GetUserByID(subscriberID) + if err != nil { + return "", fmt.Errorf("cannot find user: %w", err) + } + channel, err := pd.api.GetDirectChannel(user.Id, botID) + if err != nil { + return "", fmt.Errorf("cannot get direct channel: %w", err) + } + return channel.Id, nil + case model.SubTypeChannel: + return subscriberID, nil + default: + return "", ErrUnsupportedSubscriberType + } +} diff --git a/server/services/notify/service.go b/server/services/notify/service.go index b400fad2f..760a0b19e 100644 --- a/server/services/notify/service.go +++ b/server/services/notify/service.go @@ -27,7 +27,11 @@ type BlockChangeEvent struct { Card *model.Block BlockChanged *model.Block BlockOld *model.Block - UserID string + ModifiedByID string +} + +type SubscriptionChangeNotifier interface { + BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) } // Backend provides an interface for sending notifications. @@ -106,3 +110,23 @@ func (s *Service) BlockChanged(evt BlockChangeEvent) { } } } + +// BroadcastSubscriptionChange sends a websocket message with details of the changed subscription to all +// connected users in the workspace. +func (s *Service) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { + s.mux.RLock() + backends := make([]Backend, len(s.backends)) + copy(backends, s.backends) + s.mux.RUnlock() + + for _, backend := range backends { + if scn, ok := backend.(SubscriptionChangeNotifier); ok { + s.logger.Debug("Delivering subscription change notification", + mlog.String("workspace_id", workspaceID), + mlog.String("block_id", subscription.BlockID), + mlog.String("subscriber_id", subscription.SubscriberID), + ) + scn.BroadcastSubscriptionChange(workspaceID, subscription) + } + } +} diff --git a/server/services/store/generators/main.go b/server/services/store/generators/main.go index be6850100..568109973 100644 --- a/server/services/store/generators/main.go +++ b/server/services/store/generators/main.go @@ -80,7 +80,8 @@ type storeMetadata struct { } var blacklistedStoreMethodNames = map[string]bool{ - "Shutdown": true, + "Shutdown": true, + "IsErrNotFound": true, } func extractMethodMetadata(method *ast.Field, src []byte) methodData { diff --git a/server/services/store/generators/transactional_store.go.tmpl b/server/services/store/generators/transactional_store.go.tmpl index e006dced1..ae46b6794 100644 --- a/server/services/store/generators/transactional_store.go.tmpl +++ b/server/services/store/generators/transactional_store.go.tmpl @@ -14,6 +14,7 @@ package sqlstore import ( "context" + "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index 02b6bcc3a..923907708 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -6,6 +6,7 @@ package mockstore import ( reflect "reflect" + time "time" gomock "github.com/golang/mock/gomock" model "github.com/mattermost/focalboard/server/model" @@ -63,6 +64,21 @@ func (mr *MockStoreMockRecorder) CreateSession(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockStore)(nil).CreateSession), arg0) } +// CreateSubscription mocks base method. +func (m *MockStore) CreateSubscription(arg0 store.Container, arg1 *model.Subscription) (*model.Subscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSubscription", arg0, arg1) + ret0, _ := ret[0].(*model.Subscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateSubscription indicates an expected call of CreateSubscription. +func (mr *MockStoreMockRecorder) CreateSubscription(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSubscription", reflect.TypeOf((*MockStore)(nil).CreateSubscription), arg0, arg1) +} + // CreateUser mocks base method. func (m *MockStore) CreateUser(arg0 *model.User) error { m.ctrl.T.Helper() @@ -91,6 +107,20 @@ func (mr *MockStoreMockRecorder) DeleteBlock(arg0, arg1, arg2 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBlock", reflect.TypeOf((*MockStore)(nil).DeleteBlock), arg0, arg1, arg2) } +// DeleteNotificationHint mocks base method. +func (m *MockStore) DeleteNotificationHint(arg0 store.Container, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNotificationHint", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNotificationHint indicates an expected call of DeleteNotificationHint. +func (mr *MockStoreMockRecorder) DeleteNotificationHint(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNotificationHint", reflect.TypeOf((*MockStore)(nil).DeleteNotificationHint), arg0, arg1) +} + // DeleteSession mocks base method. func (m *MockStore) DeleteSession(arg0 string) error { m.ctrl.T.Helper() @@ -105,6 +135,20 @@ func (mr *MockStoreMockRecorder) DeleteSession(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSession", reflect.TypeOf((*MockStore)(nil).DeleteSession), arg0) } +// DeleteSubscription mocks base method. +func (m *MockStore) DeleteSubscription(arg0 store.Container, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSubscription", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSubscription indicates an expected call of DeleteSubscription. +func (mr *MockStoreMockRecorder) DeleteSubscription(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSubscription", reflect.TypeOf((*MockStore)(nil).DeleteSubscription), arg0, arg1, arg2) +} + // GetActiveUserCount mocks base method. func (m *MockStore) GetActiveUserCount(arg0 int64) (int, error) { m.ctrl.T.Helper() @@ -165,6 +209,21 @@ func (mr *MockStoreMockRecorder) GetBlockCountsByType() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockCountsByType", reflect.TypeOf((*MockStore)(nil).GetBlockCountsByType)) } +// GetBlockHistory mocks base method. +func (m *MockStore) GetBlockHistory(arg0 store.Container, arg1 string, arg2 model.QueryBlockHistoryOptions) ([]model.Block, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBlockHistory", arg0, arg1, arg2) + ret0, _ := ret[0].([]model.Block) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBlockHistory indicates an expected call of GetBlockHistory. +func (mr *MockStoreMockRecorder) GetBlockHistory(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockHistory", reflect.TypeOf((*MockStore)(nil).GetBlockHistory), arg0, arg1, arg2) +} + // GetBlocksWithParent mocks base method. func (m *MockStore) GetBlocksWithParent(arg0 store.Container, arg1 string) ([]model.Block, error) { m.ctrl.T.Helper() @@ -225,6 +284,68 @@ func (mr *MockStoreMockRecorder) GetBlocksWithType(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksWithType", reflect.TypeOf((*MockStore)(nil).GetBlocksWithType), arg0, arg1) } +// GetBoardAndCard mocks base method. +func (m *MockStore) GetBoardAndCard(arg0 store.Container, arg1 *model.Block) (*model.Block, *model.Block, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBoardAndCard", arg0, arg1) + ret0, _ := ret[0].(*model.Block) + ret1, _ := ret[1].(*model.Block) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetBoardAndCard indicates an expected call of GetBoardAndCard. +func (mr *MockStoreMockRecorder) GetBoardAndCard(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardAndCard", reflect.TypeOf((*MockStore)(nil).GetBoardAndCard), arg0, arg1) +} + +// GetBoardAndCardByID mocks base method. +func (m *MockStore) GetBoardAndCardByID(arg0 store.Container, arg1 string) (*model.Block, *model.Block, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBoardAndCardByID", arg0, arg1) + ret0, _ := ret[0].(*model.Block) + ret1, _ := ret[1].(*model.Block) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetBoardAndCardByID indicates an expected call of GetBoardAndCardByID. +func (mr *MockStoreMockRecorder) GetBoardAndCardByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardAndCardByID", reflect.TypeOf((*MockStore)(nil).GetBoardAndCardByID), arg0, arg1) +} + +// GetNextNotificationHint mocks base method. +func (m *MockStore) GetNextNotificationHint(arg0 bool) (*model.NotificationHint, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNextNotificationHint", arg0) + ret0, _ := ret[0].(*model.NotificationHint) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNextNotificationHint indicates an expected call of GetNextNotificationHint. +func (mr *MockStoreMockRecorder) GetNextNotificationHint(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNextNotificationHint", reflect.TypeOf((*MockStore)(nil).GetNextNotificationHint), arg0) +} + +// GetNotificationHint mocks base method. +func (m *MockStore) GetNotificationHint(arg0 store.Container, arg1 string) (*model.NotificationHint, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationHint", arg0, arg1) + ret0, _ := ret[0].(*model.NotificationHint) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationHint indicates an expected call of GetNotificationHint. +func (mr *MockStoreMockRecorder) GetNotificationHint(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationHint", reflect.TypeOf((*MockStore)(nil).GetNotificationHint), arg0, arg1) +} + // GetParentID mocks base method. func (m *MockStore) GetParentID(arg0 store.Container, arg1 string) (string, error) { m.ctrl.T.Helper() @@ -301,33 +422,93 @@ func (mr *MockStoreMockRecorder) GetSharing(arg0, arg1 interface{}) *gomock.Call } // GetSubTree2 mocks base method. -func (m *MockStore) GetSubTree2(arg0 store.Container, arg1 string) ([]model.Block, error) { +func (m *MockStore) GetSubTree2(arg0 store.Container, arg1 string, arg2 model.QuerySubtreeOptions) ([]model.Block, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSubTree2", arg0, arg1) + ret := m.ctrl.Call(m, "GetSubTree2", arg0, arg1, arg2) ret0, _ := ret[0].([]model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSubTree2 indicates an expected call of GetSubTree2. -func (mr *MockStoreMockRecorder) GetSubTree2(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetSubTree2(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree2", reflect.TypeOf((*MockStore)(nil).GetSubTree2), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree2", reflect.TypeOf((*MockStore)(nil).GetSubTree2), arg0, arg1, arg2) } // GetSubTree3 mocks base method. -func (m *MockStore) GetSubTree3(arg0 store.Container, arg1 string) ([]model.Block, error) { +func (m *MockStore) GetSubTree3(arg0 store.Container, arg1 string, arg2 model.QuerySubtreeOptions) ([]model.Block, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSubTree3", arg0, arg1) + ret := m.ctrl.Call(m, "GetSubTree3", arg0, arg1, arg2) ret0, _ := ret[0].([]model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSubTree3 indicates an expected call of GetSubTree3. -func (mr *MockStoreMockRecorder) GetSubTree3(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetSubTree3(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree3", reflect.TypeOf((*MockStore)(nil).GetSubTree3), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree3", reflect.TypeOf((*MockStore)(nil).GetSubTree3), arg0, arg1, arg2) +} + +// GetSubscribersCountForBlock mocks base method. +func (m *MockStore) GetSubscribersCountForBlock(arg0 store.Container, arg1 string) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSubscribersCountForBlock", arg0, arg1) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSubscribersCountForBlock indicates an expected call of GetSubscribersCountForBlock. +func (mr *MockStoreMockRecorder) GetSubscribersCountForBlock(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscribersCountForBlock", reflect.TypeOf((*MockStore)(nil).GetSubscribersCountForBlock), arg0, arg1) +} + +// GetSubscribersForBlock mocks base method. +func (m *MockStore) GetSubscribersForBlock(arg0 store.Container, arg1 string) ([]*model.Subscriber, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSubscribersForBlock", arg0, arg1) + ret0, _ := ret[0].([]*model.Subscriber) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSubscribersForBlock indicates an expected call of GetSubscribersForBlock. +func (mr *MockStoreMockRecorder) GetSubscribersForBlock(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscribersForBlock", reflect.TypeOf((*MockStore)(nil).GetSubscribersForBlock), arg0, arg1) +} + +// GetSubscription mocks base method. +func (m *MockStore) GetSubscription(arg0 store.Container, arg1, arg2 string) (*model.Subscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSubscription", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Subscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSubscription indicates an expected call of GetSubscription. +func (mr *MockStoreMockRecorder) GetSubscription(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscription", reflect.TypeOf((*MockStore)(nil).GetSubscription), arg0, arg1, arg2) +} + +// GetSubscriptions mocks base method. +func (m *MockStore) GetSubscriptions(arg0 store.Container, arg1 string) ([]*model.Subscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSubscriptions", arg0, arg1) + ret0, _ := ret[0].([]*model.Subscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSubscriptions indicates an expected call of GetSubscriptions. +func (mr *MockStoreMockRecorder) GetSubscriptions(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscriptions", reflect.TypeOf((*MockStore)(nil).GetSubscriptions), arg0, arg1) } // GetSystemSetting mocks base method. @@ -508,6 +689,20 @@ func (mr *MockStoreMockRecorder) InsertBlocks(arg0, arg1, arg2 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBlocks", reflect.TypeOf((*MockStore)(nil).InsertBlocks), arg0, arg1, arg2) } +// IsErrNotFound mocks base method. +func (m *MockStore) IsErrNotFound(arg0 error) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsErrNotFound", arg0) + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsErrNotFound indicates an expected call of IsErrNotFound. +func (mr *MockStoreMockRecorder) IsErrNotFound(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsErrNotFound", reflect.TypeOf((*MockStore)(nil).IsErrNotFound), arg0) +} + // PatchBlock mocks base method. func (m *MockStore) PatchBlock(arg0 store.Container, arg1 string, arg2 *model.BlockPatch, arg3 string) error { m.ctrl.T.Helper() @@ -592,6 +787,20 @@ func (mr *MockStoreMockRecorder) UpdateSession(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSession", reflect.TypeOf((*MockStore)(nil).UpdateSession), arg0) } +// UpdateSubscribersNotifiedAt mocks base method. +func (m *MockStore) UpdateSubscribersNotifiedAt(arg0 store.Container, arg1 string, arg2 int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateSubscribersNotifiedAt", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateSubscribersNotifiedAt indicates an expected call of UpdateSubscribersNotifiedAt. +func (mr *MockStoreMockRecorder) UpdateSubscribersNotifiedAt(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSubscribersNotifiedAt", reflect.TypeOf((*MockStore)(nil).UpdateSubscribersNotifiedAt), arg0, arg1, arg2) +} + // UpdateUser mocks base method. func (m *MockStore) UpdateUser(arg0 *model.User) error { m.ctrl.T.Helper() @@ -634,6 +843,21 @@ func (mr *MockStoreMockRecorder) UpdateUserPasswordByID(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPasswordByID", reflect.TypeOf((*MockStore)(nil).UpdateUserPasswordByID), arg0, arg1) } +// UpsertNotificationHint mocks base method. +func (m *MockStore) UpsertNotificationHint(arg0 *model.NotificationHint, arg1 time.Duration) (*model.NotificationHint, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertNotificationHint", arg0, arg1) + ret0, _ := ret[0].(*model.NotificationHint) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertNotificationHint indicates an expected call of UpsertNotificationHint. +func (mr *MockStoreMockRecorder) UpsertNotificationHint(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationHint", reflect.TypeOf((*MockStore)(nil).UpsertNotificationHint), arg0, arg1) +} + // UpsertSharing mocks base method. func (m *MockStore) UpsertSharing(arg0 store.Container, arg1 model.Sharing) error { m.ctrl.T.Helper() diff --git a/server/services/store/sqlstore/blocks.go b/server/services/store/sqlstore/blocks.go index 7f2998758..efd6b5ef1 100644 --- a/server/services/store/sqlstore/blocks.go +++ b/server/services/store/sqlstore/blocks.go @@ -16,6 +16,10 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mlog" ) +const ( + maxSearchDepth = 50 +) + type RootIDNilError struct{} func (re RootIDNilError) Error() string { @@ -121,13 +125,26 @@ func (s *SQLStore) getBlocksWithType(db sq.BaseRunner, c store.Container, blockT return s.blocksFromRows(rows) } -// GetSubTree2 returns blocks within 2 levels of the given blockID. -func (s *SQLStore) getSubTree2(db sq.BaseRunner, c store.Container, blockID string) ([]model.Block, error) { +// getSubTree2 returns blocks within 2 levels of the given blockID. +func (s *SQLStore) getSubTree2(db sq.BaseRunner, c store.Container, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) { query := s.getQueryBuilder(db). Select(s.blockFields()...). From(s.tablePrefix + "blocks"). Where(sq.Or{sq.Eq{"id": blockID}, sq.Eq{"parent_id": blockID}}). - Where(sq.Eq{"coalesce(workspace_id, '0')": c.WorkspaceID}) + Where(sq.Eq{"coalesce(workspace_id, '0')": c.WorkspaceID}). + OrderBy("insert_at") + + if opts.BeforeUpdateAt != 0 { + query = query.Where(sq.LtOrEq{"update_at": opts.BeforeUpdateAt}) + } + + if opts.AfterUpdateAt != 0 { + query = query.Where(sq.GtOrEq{"update_at": opts.AfterUpdateAt}) + } + + if opts.Limit != 0 { + query = query.Limit(opts.Limit) + } rows, err := query.Query() if err != nil { @@ -140,8 +157,8 @@ func (s *SQLStore) getSubTree2(db sq.BaseRunner, c store.Container, blockID stri return s.blocksFromRows(rows) } -// GetSubTree3 returns blocks within 3 levels of the given blockID. -func (s *SQLStore) getSubTree3(db sq.BaseRunner, c store.Container, blockID string) ([]model.Block, error) { +// getSubTree3 returns blocks within 3 levels of the given blockID. +func (s *SQLStore) getSubTree3(db sq.BaseRunner, c store.Container, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) { // This first subquery returns repeated blocks query := s.getQueryBuilder(db).Select( "l3.id", @@ -158,11 +175,20 @@ func (s *SQLStore) getSubTree3(db sq.BaseRunner, c store.Container, blockID stri "l3.delete_at", "COALESCE(l3.workspace_id, '0')", ). - From(s.tablePrefix + "blocks as l1"). - Join(s.tablePrefix + "blocks as l2 on l2.parent_id = l1.id or l2.id = l1.id"). - Join(s.tablePrefix + "blocks as l3 on l3.parent_id = l2.id or l3.id = l2.id"). + From(s.tablePrefix + "blocks" + " as l1"). + Join(s.tablePrefix + "blocks" + " as l2 on l2.parent_id = l1.id or l2.id = l1.id"). + Join(s.tablePrefix + "blocks" + " as l3 on l3.parent_id = l2.id or l3.id = l2.id"). Where(sq.Eq{"l1.id": blockID}). - Where(sq.Eq{"COALESCE(l3.workspace_id, '0')": c.WorkspaceID}) + Where(sq.Eq{"COALESCE(l3.workspace_id, '0')": c.WorkspaceID}). + OrderBy("l1.insert_at") + + if opts.BeforeUpdateAt != 0 { + query = query.Where(sq.LtOrEq{"update_at": opts.BeforeUpdateAt}) + } + + if opts.AfterUpdateAt != 0 { + query = query.Where(sq.GtOrEq{"update_at": opts.AfterUpdateAt}) + } if s.dbType == postgresDBType { query = query.Options("DISTINCT ON (l3.id)") @@ -170,6 +196,10 @@ func (s *SQLStore) getSubTree3(db sq.BaseRunner, c store.Container, blockID stri query = query.Distinct() } + if opts.Limit != 0 { + query = query.Limit(opts.Limit) + } + rows, err := query.Query() if err != nil { s.logger.Error(`getSubTree3 ERROR`, mlog.Err(err)) @@ -296,6 +326,9 @@ func (s *SQLStore) insertBlock(db sq.BaseRunner, c store.Container, block *model return err } + block.UpdateAt = utils.GetMillis() + block.ModifiedBy = userID + insertQuery := s.getQueryBuilder(db).Insert(""). Columns( "workspace_id", @@ -329,9 +362,6 @@ func (s *SQLStore) insertBlock(db sq.BaseRunner, c store.Container, block *model "update_at": block.UpdateAt, } - block.UpdateAt = utils.GetMillis() - block.ModifiedBy = userID - if existingBlock != nil { // block with ID exists, so this is an update operation query := s.getQueryBuilder(db).Update(s.tablePrefix+"blocks"). @@ -354,8 +384,6 @@ func (s *SQLStore) insertBlock(db sq.BaseRunner, c store.Container, block *model } else { block.CreatedBy = userID block.CreateAt = utils.GetMillis() - block.ModifiedBy = userID - block.UpdateAt = utils.GetMillis() insertQueryValues["created_by"] = block.CreatedBy insertQueryValues["create_at"] = block.CreateAt @@ -531,6 +559,99 @@ func (s *SQLStore) getBlock(db sq.BaseRunner, c store.Container, blockID string) return &blocks[0], nil } +func (s *SQLStore) getBlockHistory(db sq.BaseRunner, c store.Container, blockID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error) { + var order string + if opts.Descending { + order = " DESC " + } + + query := s.getQueryBuilder(db). + Select(s.blockFields()...). + From(s.tablePrefix + "blocks_history"). + Where(sq.Eq{"id": blockID}). + Where(sq.Eq{"coalesce(workspace_id, '0')": c.WorkspaceID}). + OrderBy("insert_at" + order) + + if opts.BeforeUpdateAt != 0 { + query = query.Where(sq.LtOrEq{"update_at": opts.BeforeUpdateAt}) + } + + if opts.AfterUpdateAt != 0 { + query = query.Where(sq.GtOrEq{"update_at": opts.AfterUpdateAt}) + } + + if opts.Limit != 0 { + query = query.Limit(opts.Limit) + } + + rows, err := query.Query() + if err != nil { + s.logger.Error(`GetBlockHistory ERROR`, mlog.Err(err)) + return nil, err + } + + return s.blocksFromRows(rows) +} + +// getBoardAndCardByID returns the first parent of type `card` and first parent of type `board` for the block specified by ID. +// `board` and/or `card` may return nil without error if the block does not belong to a board or card. +func (s *SQLStore) getBoardAndCardByID(db sq.BaseRunner, c store.Container, blockID string) (board *model.Block, card *model.Block, err error) { + // use block_history to fetch block in case it was deleted and no longer exists in blocks table. + opts := model.QueryBlockHistoryOptions{ + Limit: 1, + Descending: true, + } + + blocks, err := s.getBlockHistory(db, c, blockID, opts) + if err != nil { + return nil, nil, err + } + + if len(blocks) == 0 { + return nil, nil, store.NewErrNotFound(blockID) + } + + return s.getBoardAndCard(db, c, &blocks[0]) +} + +// getBoardAndCard returns the first parent of type `card` and first parent of type `board` for the specified block. +// `board` and/or `card` may return nil without error if the block does not belong to a board or card. +func (s *SQLStore) getBoardAndCard(db sq.BaseRunner, c store.Container, block *model.Block) (board *model.Block, card *model.Block, err error) { + var count int // don't let invalid blocks hierarchy cause infinite loop. + iter := block + + // use block_history to fetch blocks in case they were deleted and no longer exist in blocks table. + opts := model.QueryBlockHistoryOptions{ + Limit: 1, + Descending: true, + } + + for { + count++ + if board == nil && iter.Type == model.TypeBoard { + board = iter + } + + if card == nil && iter.Type == model.TypeCard { + card = iter + } + + if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth { + break + } + + blocks, err := s.getBlockHistory(db, c, iter.ParentID, opts) + if err != nil { + return nil, nil, err + } + if len(blocks) == 0 { + return board, card, nil + } + iter = &blocks[0] + } + return board, card, nil +} + func (s *SQLStore) getBlocksWithSameID(db sq.BaseRunner) ([]model.Block, error) { subquery, _, _ := s.getQueryBuilder(db). Select("id"). diff --git a/server/services/store/sqlstore/migrations/bindata.go b/server/services/store/sqlstore/migrations/bindata.go index 244a4b95c..81f0ad65f 100644 --- a/server/services/store/sqlstore/migrations/bindata.go +++ b/server/services/store/sqlstore/migrations/bindata.go @@ -30,6 +30,8 @@ // migrations_files/000014_add_not_null_constraint.up.sql // migrations_files/000015_blocks_history_no_nulls.down.sql // migrations_files/000015_blocks_history_no_nulls.up.sql +// migrations_files/000016_subscriptions_table.down.sql +// migrations_files/000016_subscriptions_table.up.sql // DO NOT EDIT! package migrations @@ -692,7 +694,47 @@ func _000015_blocks_history_no_nullsUpSql() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "000015_blocks_history_no_nulls.up.sql", size: 3143, mode: os.FileMode(436), modTime: time.Unix(1639096820, 0)} + info := bindataFileInfo{name: "000015_blocks_history_no_nulls.up.sql", size: 3143, mode: os.FileMode(436), modTime: time.Unix(1639144692, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var __000016_subscriptions_tableDownSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\xa8\xae\xd6\x2b\x28\x4a\x4d\xcb\xac\xa8\xad\x2d\x2e\x4d\x2a\x4e\x2e\xca\x2c\x28\xc9\xcc\xcf\x2b\xb6\xe6\xc2\xae\x28\x2f\xbf\x24\x33\x2d\x33\x39\x11\xa4\x28\x3e\x23\x33\xaf\xa4\xd8\x9a\x0b\x10\x00\x00\xff\xff\x8c\x8f\xec\x6f\x4f\x00\x00\x00") + +func _000016_subscriptions_tableDownSqlBytes() ([]byte, error) { + return bindataRead( + __000016_subscriptions_tableDownSql, + "000016_subscriptions_table.down.sql", + ) +} + +func _000016_subscriptions_tableDownSql() (*asset, error) { + bytes, err := _000016_subscriptions_tableDownSqlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "000016_subscriptions_table.down.sql", size: 79, mode: os.FileMode(436), modTime: time.Unix(1639144681, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var __000016_subscriptions_tableUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xac\x90\x5f\x4b\xf3\x30\x14\x87\xaf\xd7\x4f\x71\x2e\x5b\x28\xe3\x7d\x51\x44\xf0\x2a\xab\x99\x16\xe7\x94\x34\x8a\xbb\x2a\xfd\x73\x8a\x61\x6d\x53\x93\x0c\x2d\x21\xdf\x5d\xea\x9c\x38\x3b\x50\xc4\xdb\xdf\x43\x4e\x1e\x9e\x88\x51\xc2\x29\x70\x32\x5b\x50\x88\xe7\xb0\xbc\xe1\x40\x1f\xe2\x84\x27\x60\xed\xb4\x53\x58\x89\x17\xe7\xf4\x26\xd7\x85\x12\x9d\x11\xb2\xd5\xe0\x7b\x93\xbc\x96\xc5\x3a\x35\x7d\x87\x70\x4f\x58\x74\x49\x98\xff\xff\x5f\x10\xee\x80\x28\x3f\xe6\xa3\x93\x61\x7e\x96\x6a\xad\xbb\xac\xc0\x31\x7a\xbf\x9d\xa3\x3a\x74\xef\x13\x1d\xbd\x6c\xa5\x11\x95\xc0\x32\xcd\x0c\xcc\xe2\x8b\x78\xc9\x43\x6f\x52\x28\xcc\x0c\xee\x4d\x25\xd6\xf8\x65\xba\x65\xf1\x35\x61\x2b\xb8\xa2\x2b\xf0\x77\xce\x21\xec\xfd\x16\x78\x01\x58\x2b\x2a\x98\x36\xbd\x7e\xaa\x9d\x3b\xa7\x73\x72\xb7\xe0\x30\x28\x90\x88\x53\x06\x09\xe5\xb0\x31\xd5\x69\x93\x1f\x5b\x8b\x6d\xe9\xdc\x99\xe7\xfd\x2c\xe9\x56\xbe\xc8\x86\xa4\xe9\xa3\x68\xcd\x5f\x77\x6d\x64\xb9\xad\x93\xf7\x63\x78\xa0\xd2\x9b\x50\xff\x7d\xa5\xdf\x66\x79\x0d\x00\x00\xff\xff\xbe\x9d\xee\xc3\x6a\x02\x00\x00") + +func _000016_subscriptions_tableUpSqlBytes() ([]byte, error) { + return bindataRead( + __000016_subscriptions_tableUpSql, + "000016_subscriptions_table.up.sql", + ) +} + +func _000016_subscriptions_tableUpSql() (*asset, error) { + bytes, err := _000016_subscriptions_tableUpSqlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "000016_subscriptions_table.up.sql", size: 618, mode: os.FileMode(436), modTime: time.Unix(1639144681, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -779,6 +821,8 @@ var _bindata = map[string]func() (*asset, error){ "000014_add_not_null_constraint.up.sql": _000014_add_not_null_constraintUpSql, "000015_blocks_history_no_nulls.down.sql": _000015_blocks_history_no_nullsDownSql, "000015_blocks_history_no_nulls.up.sql": _000015_blocks_history_no_nullsUpSql, + "000016_subscriptions_table.down.sql": _000016_subscriptions_tableDownSql, + "000016_subscriptions_table.up.sql": _000016_subscriptions_tableUpSql, } // AssetDir returns the file names below a certain @@ -851,6 +895,8 @@ var _bintree = &bintree{nil, map[string]*bintree{ "000014_add_not_null_constraint.up.sql": &bintree{_000014_add_not_null_constraintUpSql, map[string]*bintree{}}, "000015_blocks_history_no_nulls.down.sql": &bintree{_000015_blocks_history_no_nullsDownSql, map[string]*bintree{}}, "000015_blocks_history_no_nulls.up.sql": &bintree{_000015_blocks_history_no_nullsUpSql, map[string]*bintree{}}, + "000016_subscriptions_table.down.sql": &bintree{_000016_subscriptions_tableDownSql, map[string]*bintree{}}, + "000016_subscriptions_table.up.sql": &bintree{_000016_subscriptions_tableUpSql, map[string]*bintree{}}, }} // RestoreAsset restores an asset under the given directory diff --git a/server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.down.sql b/server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.down.sql new file mode 100644 index 000000000..2b8b6fc20 --- /dev/null +++ b/server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE {{.prefix}}subscriptions; +DROP TABLE {{.prefix}}notification_hints; diff --git a/server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.up.sql b/server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.up.sql new file mode 100644 index 000000000..0443dbf52 --- /dev/null +++ b/server/services/store/sqlstore/migrations/migrations_files/000016_subscriptions_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS {{.prefix}}subscriptions ( + block_type VARCHAR(10), + block_id VARCHAR(36), + workspace_id VARCHAR(36), + subscriber_type VARCHAR(10), + subscriber_id VARCHAR(36), + notified_at BIGINT, + create_at BIGINT, + delete_at BIGINT, + PRIMARY KEY (block_id, subscriber_id) +) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; + +CREATE TABLE IF NOT EXISTS {{.prefix}}notification_hints ( + block_type VARCHAR(10), + block_id VARCHAR(36), + workspace_id VARCHAR(36), + modified_by_id VARCHAR(36), + create_at BIGINT, + notify_at BIGINT, + PRIMARY KEY (block_id) +) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; + diff --git a/server/services/store/sqlstore/notificationhints.go b/server/services/store/sqlstore/notificationhints.go new file mode 100644 index 000000000..93a5782e3 --- /dev/null +++ b/server/services/store/sqlstore/notificationhints.go @@ -0,0 +1,203 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "fmt" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/utils" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +var notificationHintFields = []string{ + "block_type", + "block_id", + "workspace_id", + "modified_by_id", + "create_at", + "notify_at", +} + +func valuesForNotificationHint(hint *model.NotificationHint) []interface{} { + return []interface{}{ + hint.BlockType, + hint.BlockID, + hint.WorkspaceID, + hint.ModifiedByID, + hint.CreateAt, + hint.NotifyAt, + } +} + +func (s *SQLStore) notificationHintFromRows(rows *sql.Rows) ([]*model.NotificationHint, error) { + hints := []*model.NotificationHint{} + + for rows.Next() { + var hint model.NotificationHint + err := rows.Scan( + &hint.BlockType, + &hint.BlockID, + &hint.WorkspaceID, + &hint.ModifiedByID, + &hint.CreateAt, + &hint.NotifyAt, + ) + if err != nil { + return nil, err + } + hints = append(hints, &hint) + } + return hints, nil +} + +// upsertNotificationHint creates or updates a notification hint. When updating the `notify_at` is set +// to the current time plus `notifyFreq`. +func (s *SQLStore) upsertNotificationHint(db sq.BaseRunner, hint *model.NotificationHint, notifyFreq time.Duration) (*model.NotificationHint, error) { + if err := hint.IsValid(); err != nil { + return nil, err + } + + hint.CreateAt = utils.GetMillis() + + notifyAt := utils.GetMillisForTime(time.Now().Add(notifyFreq)) + hint.NotifyAt = notifyAt + + query := s.getQueryBuilder(db).Insert(s.tablePrefix + "notification_hints"). + Columns(notificationHintFields...). + Values(valuesForNotificationHint(hint)...) + + if s.dbType == mysqlDBType { + query = query.Suffix("ON DUPLICATE KEY UPDATE notify_at = ?", notifyAt) + } else { + query = query.Suffix("ON CONFLICT (block_id) DO UPDATE SET notify_at = ?", notifyAt) + } + + if _, err := query.Exec(); err != nil { + s.logger.Error("Cannot upsert notification hint", + mlog.String("block_id", hint.BlockID), + mlog.String("workspace_id", hint.WorkspaceID), + mlog.Err(err), + ) + return nil, err + } + return hint, nil +} + +// deleteNotificationHint deletes the notification hint for the specified block. +func (s *SQLStore) deleteNotificationHint(db sq.BaseRunner, c store.Container, blockID string) error { + query := s.getQueryBuilder(db). + Delete(s.tablePrefix + "notification_hints"). + Where(sq.Eq{"block_id": blockID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}) + + result, err := query.Exec() + if err != nil { + return err + } + + count, err := result.RowsAffected() + if err != nil { + return err + } + + if count == 0 { + return store.NewErrNotFound(blockID) + } + + return nil +} + +// getNotificationHint fetches the notification hint for the specified block. +func (s *SQLStore) getNotificationHint(db sq.BaseRunner, c store.Container, blockID string) (*model.NotificationHint, error) { + query := s.getQueryBuilder(db). + Select(notificationHintFields...). + From(s.tablePrefix + "notification_hints"). + Where(sq.Eq{"block_id": blockID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}) + + rows, err := query.Query() + if err != nil { + s.logger.Error("Cannot fetch notification hint", + mlog.String("block_id", blockID), + mlog.String("workspace_id", c.WorkspaceID), + mlog.Err(err), + ) + return nil, err + } + defer s.CloseRows(rows) + + hint, err := s.notificationHintFromRows(rows) + if err != nil { + s.logger.Error("Cannot get notification hint", + mlog.String("block_id", blockID), + mlog.String("workspace_id", c.WorkspaceID), + mlog.Err(err), + ) + return nil, err + } + if len(hint) == 0 { + return nil, store.NewErrNotFound(blockID) + } + return hint[0], nil +} + +// getNextNotificationHint fetches the next scheduled notification hint. If remove is true +// then the hint is removed from the database as well, as if popping from a stack. +func (s *SQLStore) getNextNotificationHint(db sq.BaseRunner, remove bool) (*model.NotificationHint, error) { + selectQuery := s.getQueryBuilder(db). + Select(notificationHintFields...). + From(s.tablePrefix + "notification_hints"). + OrderBy("notify_at"). + Limit(1) + + rows, err := selectQuery.Query() + if err != nil { + s.logger.Error("Cannot fetch next notification hint", + mlog.Err(err), + ) + return nil, err + } + defer s.CloseRows(rows) + + hints, err := s.notificationHintFromRows(rows) + if err != nil { + s.logger.Error("Cannot get next notification hint", + mlog.Err(err), + ) + return nil, err + } + if len(hints) == 0 { + return nil, store.NewErrNotFound("") + } + + hint := hints[0] + + if remove { + deleteQuery := s.getQueryBuilder(db). + Delete(s.tablePrefix + "notification_hints"). + Where(sq.Eq{"block_id": hint.BlockID}) + + result, err := deleteQuery.Exec() + if err != nil { + return nil, fmt.Errorf("cannot delete while getting next notification hint: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("cannot verify delete while getting next notification hint: %w", err) + } + if rows == 0 { + // another node likely has grabbed this hint for processing concurrently; let that node handle it + // and we'll return an error here so we try again. + return nil, fmt.Errorf("cannot delete missing hint while getting next notification hint: %w", err) + } + } + + return hint, nil +} diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index 2667c7fda..ce42f9b7c 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -14,6 +14,7 @@ package sqlstore import ( "context" + "time" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store" @@ -31,6 +32,11 @@ func (s *SQLStore) CreateSession(session *model.Session) error { } +func (s *SQLStore) CreateSubscription(c store.Container, sub *model.Subscription) (*model.Subscription, error) { + return s.createSubscription(s.db, c, sub) + +} + func (s *SQLStore) CreateUser(user *model.User) error { return s.createUser(s.db, user) @@ -57,11 +63,21 @@ func (s *SQLStore) DeleteBlock(c store.Container, blockID string, modifiedBy str } +func (s *SQLStore) DeleteNotificationHint(c store.Container, blockID string) error { + return s.deleteNotificationHint(s.db, c, blockID) + +} + func (s *SQLStore) DeleteSession(sessionID string) error { return s.deleteSession(s.db, sessionID) } +func (s *SQLStore) DeleteSubscription(c store.Container, blockID string, subscriberID string) error { + return s.deleteSubscription(s.db, c, blockID, subscriberID) + +} + func (s *SQLStore) GetActiveUserCount(updatedSecondsAgo int64) (int, error) { return s.getActiveUserCount(s.db, updatedSecondsAgo) @@ -82,6 +98,11 @@ func (s *SQLStore) GetBlockCountsByType() (map[string]int64, error) { } +func (s *SQLStore) GetBlockHistory(c store.Container, blockID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error) { + return s.getBlockHistory(s.db, c, blockID, opts) + +} + func (s *SQLStore) GetBlocksWithParent(c store.Container, parentID string) ([]model.Block, error) { return s.getBlocksWithParent(s.db, c, parentID) @@ -102,6 +123,26 @@ func (s *SQLStore) GetBlocksWithType(c store.Container, blockType string) ([]mod } +func (s *SQLStore) GetBoardAndCard(c store.Container, block *model.Block) (*model.Block, *model.Block, error) { + return s.getBoardAndCard(s.db, c, block) + +} + +func (s *SQLStore) GetBoardAndCardByID(c store.Container, blockID string) (*model.Block, *model.Block, error) { + return s.getBoardAndCardByID(s.db, c, blockID) + +} + +func (s *SQLStore) GetNextNotificationHint(remove bool) (*model.NotificationHint, error) { + return s.getNextNotificationHint(s.db, remove) + +} + +func (s *SQLStore) GetNotificationHint(c store.Container, blockID string) (*model.NotificationHint, error) { + return s.getNotificationHint(s.db, c, blockID) + +} + func (s *SQLStore) GetParentID(c store.Container, blockID string) (string, error) { return s.getParentID(s.db, c, blockID) @@ -127,13 +168,33 @@ func (s *SQLStore) GetSharing(c store.Container, rootID string) (*model.Sharing, } -func (s *SQLStore) GetSubTree2(c store.Container, blockID string) ([]model.Block, error) { - return s.getSubTree2(s.db, c, blockID) +func (s *SQLStore) GetSubTree2(c store.Container, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) { + return s.getSubTree2(s.db, c, blockID, opts) } -func (s *SQLStore) GetSubTree3(c store.Container, blockID string) ([]model.Block, error) { - return s.getSubTree3(s.db, c, blockID) +func (s *SQLStore) GetSubTree3(c store.Container, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) { + return s.getSubTree3(s.db, c, blockID, opts) + +} + +func (s *SQLStore) GetSubscribersCountForBlock(c store.Container, blockID string) (int, error) { + return s.getSubscribersCountForBlock(s.db, c, blockID) + +} + +func (s *SQLStore) GetSubscribersForBlock(c store.Container, blockID string) ([]*model.Subscriber, error) { + return s.getSubscribersForBlock(s.db, c, blockID) + +} + +func (s *SQLStore) GetSubscription(c store.Container, blockID string, subscriberID string) (*model.Subscription, error) { + return s.getSubscription(s.db, c, blockID, subscriberID) + +} + +func (s *SQLStore) GetSubscriptions(c store.Container, subscriberID string) ([]*model.Subscription, error) { + return s.getSubscriptions(s.db, c, subscriberID) } @@ -286,6 +347,11 @@ func (s *SQLStore) UpdateSession(session *model.Session) error { } +func (s *SQLStore) UpdateSubscribersNotifiedAt(c store.Container, blockID string, notifiedAt int64) error { + return s.updateSubscribersNotifiedAt(s.db, c, blockID, notifiedAt) + +} + func (s *SQLStore) UpdateUser(user *model.User) error { return s.updateUser(s.db, user) @@ -301,6 +367,11 @@ func (s *SQLStore) UpdateUserPasswordByID(userID string, password string) error } +func (s *SQLStore) UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) { + return s.upsertNotificationHint(s.db, hint, notificationFreq) + +} + func (s *SQLStore) UpsertSharing(c store.Container, sharing model.Sharing) error { return s.upsertSharing(s.db, c, sharing) diff --git a/server/services/store/sqlstore/sqlstore_test.go b/server/services/store/sqlstore/sqlstore_test.go index 6e71efe12..cc6f46169 100644 --- a/server/services/store/sqlstore/sqlstore_test.go +++ b/server/services/store/sqlstore/sqlstore_test.go @@ -1,3 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + package sqlstore import ( @@ -13,4 +16,6 @@ func TestBlocksStore(t *testing.T) { t.Run("UserStore", func(t *testing.T) { storetests.StoreTestUserStore(t, SetupTests) }) t.Run("SessionStore", func(t *testing.T) { storetests.StoreTestSessionStore(t, SetupTests) }) t.Run("WorkspaceStore", func(t *testing.T) { storetests.StoreTestWorkspaceStore(t, SetupTests) }) + t.Run("SubscriptionStore", func(t *testing.T) { storetests.StoreTestSubscriptionsStore(t, SetupTests) }) + t.Run("NotificationHintStore", func(t *testing.T) { storetests.StoreTestNotificationHintsStore(t, SetupTests) }) } diff --git a/server/services/store/sqlstore/subscriptions.go b/server/services/store/sqlstore/subscriptions.go new file mode 100644 index 000000000..1c440e9ea --- /dev/null +++ b/server/services/store/sqlstore/subscriptions.go @@ -0,0 +1,273 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + + sq "github.com/Masterminds/squirrel" + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +var subscriptionFields = []string{ + "block_type", + "block_id", + "workspace_id", + "subscriber_type", + "subscriber_id", + "notified_at", + "create_at", + "delete_at", +} + +func valuesForSubscription(sub *model.Subscription) []interface{} { + return []interface{}{ + sub.BlockType, + sub.BlockID, + sub.WorkspaceID, + sub.SubscriberType, + sub.SubscriberID, + sub.NotifiedAt, + sub.CreateAt, + sub.DeleteAt, + } +} + +func (s *SQLStore) subscriptionsFromRows(rows *sql.Rows) ([]*model.Subscription, error) { + subscriptions := []*model.Subscription{} + + for rows.Next() { + var sub model.Subscription + err := rows.Scan( + &sub.BlockType, + &sub.BlockID, + &sub.WorkspaceID, + &sub.SubscriberType, + &sub.SubscriberID, + &sub.NotifiedAt, + &sub.CreateAt, + &sub.DeleteAt, + ) + if err != nil { + return nil, err + } + subscriptions = append(subscriptions, &sub) + } + return subscriptions, nil +} + +// createSubscription creates a new subscription, or returns an existing subscription +// for the block & subscriber. +func (s *SQLStore) createSubscription(db sq.BaseRunner, c store.Container, sub *model.Subscription) (*model.Subscription, error) { + sub.WorkspaceID = c.WorkspaceID + + if err := sub.IsValid(); err != nil { + return nil, err + } + + now := model.GetMillis() + + subAdd := *sub + subAdd.NotifiedAt = now // notified_at set so first notification doesn't pick up all history + subAdd.CreateAt = now + subAdd.DeleteAt = 0 + + query := s.getQueryBuilder(db). + Insert(s.tablePrefix + "subscriptions"). + Columns(subscriptionFields...). + Values(valuesForSubscription(&subAdd)...) + + if s.dbType == mysqlDBType { + query = query.Suffix("ON DUPLICATE KEY UPDATE delete_at = 0, notified_at = ?", now) + } else { + query = query.Suffix("ON CONFLICT (block_id,subscriber_id) DO UPDATE SET delete_at = 0, notified_at = ?", now) + } + + if _, err := query.Exec(); err != nil { + s.logger.Error("Cannot create subscription", + mlog.String("block_id", sub.BlockID), + mlog.String("workspace_id", sub.WorkspaceID), + mlog.String("subscriber_id", sub.SubscriberID), + mlog.Err(err), + ) + return nil, err + } + return &subAdd, nil +} + +// deleteSubscription soft deletes the subscription for a specific block and subscriber. +func (s *SQLStore) deleteSubscription(db sq.BaseRunner, c store.Container, blockID string, subscriberID string) error { + now := model.GetMillis() + + query := s.getQueryBuilder(db). + Update(s.tablePrefix+"subscriptions"). + Set("delete_at", now). + Where(sq.Eq{"block_id": blockID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}). + Where(sq.Eq{"subscriber_id": subscriberID}) + + result, err := query.Exec() + if err != nil { + return err + } + + count, err := result.RowsAffected() + if err != nil { + return err + } + + if count == 0 { + return store.NewErrNotFound(c.WorkspaceID + "," + blockID + "," + subscriberID) + } + + return nil +} + +// getSubscription fetches the subscription for a specific block and subscriber. +func (s *SQLStore) getSubscription(db sq.BaseRunner, c store.Container, blockID string, subscriberID string) (*model.Subscription, error) { + query := s.getQueryBuilder(db). + Select(subscriptionFields...). + From(s.tablePrefix + "subscriptions"). + Where(sq.Eq{"block_id": blockID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}). + Where(sq.Eq{"subscriber_id": subscriberID}). + Where(sq.Eq{"delete_at": 0}) + + rows, err := query.Query() + if err != nil { + s.logger.Error("Cannot fetch subscription for block & subscriber", + mlog.String("block_id", blockID), + mlog.String("workspace_id", c.WorkspaceID), + mlog.String("subscriber_id", subscriberID), + mlog.Err(err), + ) + return nil, err + } + defer s.CloseRows(rows) + + subscriptions, err := s.subscriptionsFromRows(rows) + if err != nil { + s.logger.Error("Cannot get subscription for block & subscriber", + mlog.String("block_id", blockID), + mlog.String("workspace_id", c.WorkspaceID), + mlog.String("subscriber_id", subscriberID), + mlog.Err(err), + ) + return nil, err + } + if len(subscriptions) == 0 { + return nil, store.NewErrNotFound(c.WorkspaceID + "," + blockID + "," + subscriberID) + } + return subscriptions[0], nil +} + +// getSubscriptions fetches all subscriptions for a specific subscriber. +func (s *SQLStore) getSubscriptions(db sq.BaseRunner, c store.Container, subscriberID string) ([]*model.Subscription, error) { + query := s.getQueryBuilder(db). + Select(subscriptionFields...). + From(s.tablePrefix + "subscriptions"). + Where(sq.Eq{"subscriber_id": subscriberID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}). + Where(sq.Eq{"delete_at": 0}) + + rows, err := query.Query() + if err != nil { + s.logger.Error("Cannot fetch subscriptions for subscriber", + mlog.String("subscriber_id", subscriberID), + mlog.Err(err), + ) + return nil, err + } + defer s.CloseRows(rows) + + return s.subscriptionsFromRows(rows) +} + +// getSubscribersForBlock fetches all subscribers for a block. +func (s *SQLStore) getSubscribersForBlock(db sq.BaseRunner, c store.Container, blockID string) ([]*model.Subscriber, error) { + query := s.getQueryBuilder(db). + Select( + "subscriber_type", + "subscriber_id", + "notified_at", + ). + From(s.tablePrefix + "subscriptions"). + Where(sq.Eq{"block_id": blockID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}). + Where(sq.Eq{"delete_at": 0}). + OrderBy("notified_at") + + rows, err := query.Query() + if err != nil { + s.logger.Error("Cannot fetch subscribers for block", + mlog.String("block_id", blockID), + mlog.String("workspace_id", c.WorkspaceID), + mlog.Err(err), + ) + return nil, err + } + defer s.CloseRows(rows) + + subscribers := []*model.Subscriber{} + + for rows.Next() { + var sub model.Subscriber + err := rows.Scan( + &sub.SubscriberType, + &sub.SubscriberID, + &sub.NotifiedAt, + ) + if err != nil { + return nil, err + } + subscribers = append(subscribers, &sub) + } + return subscribers, nil +} + +// getSubscribersCountForBlock returns a count of all subscribers for a block. +func (s *SQLStore) getSubscribersCountForBlock(db sq.BaseRunner, c store.Container, blockID string) (int, error) { + query := s.getQueryBuilder(db). + Select("count(subscriber_id)"). + From(s.tablePrefix + "subscriptions"). + Where(sq.Eq{"block_id": blockID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}). + Where(sq.Eq{"delete_at": 0}) + + row := query.QueryRow() + + var count int + err := row.Scan(&count) + if err != nil { + s.logger.Error("Cannot count subscribers for block", + mlog.String("block_id", blockID), + mlog.String("workspace_id", c.WorkspaceID), + mlog.Err(err), + ) + return 0, err + } + return count, nil +} + +// updateSubscribersNotifiedAt updates the notified_at field of all subscribers for a block. +func (s *SQLStore) updateSubscribersNotifiedAt(db sq.BaseRunner, c store.Container, blockID string, notifiedAt int64) error { + query := s.getQueryBuilder(db). + Update(s.tablePrefix+"subscriptions"). + Set("notified_at", notifiedAt). + Where(sq.Eq{"block_id": blockID}). + Where(sq.Eq{"workspace_id": c.WorkspaceID}). + Where(sq.Eq{"delete_at": 0}) + + if _, err := query.Exec(); err != nil { + s.logger.Error("UpdateSubscribersNotifiedAt error occurred while updating subscriber(s)", + mlog.String("blockID", blockID), + mlog.Err(err), + ) + return err + } + return nil +} diff --git a/server/services/store/sqlstore/util.go b/server/services/store/sqlstore/util.go index 3b64bdd3a..0f5eae3d1 100644 --- a/server/services/store/sqlstore/util.go +++ b/server/services/store/sqlstore/util.go @@ -3,6 +3,8 @@ package sqlstore import ( "database/sql" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/mattermost-server/v6/shared/mlog" ) @@ -11,3 +13,7 @@ func (s *SQLStore) CloseRows(rows *sql.Rows) { s.logger.Error("error closing MattermostAuthLayer row set", mlog.Err(err)) } } + +func (s *SQLStore) IsErrNotFound(err error) bool { + return store.IsErrNotFound(err) +} diff --git a/server/services/store/store.go b/server/services/store/store.go index deb4899c0..46a9f03ec 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -3,6 +3,10 @@ package store import ( + "errors" + "fmt" + "time" + "github.com/mattermost/focalboard/server/model" ) @@ -18,8 +22,8 @@ type Store interface { GetBlocksWithParent(c Container, parentID string) ([]model.Block, error) GetBlocksWithRootID(c Container, rootID string) ([]model.Block, error) GetBlocksWithType(c Container, blockType string) ([]model.Block, error) - GetSubTree2(c Container, blockID string) ([]model.Block, error) - GetSubTree3(c Container, blockID string) ([]model.Block, error) + GetSubTree2(c Container, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) + GetSubTree3(c Container, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) GetAllBlocks(c Container) ([]model.Block, error) GetRootID(c Container, blockID string) (string, error) GetParentID(c Container, blockID string) (string, error) @@ -33,6 +37,9 @@ type Store interface { GetBlock(c Container, blockID string) (*model.Block, error) // @withTransaction PatchBlock(c Container, blockID string, blockPatch *model.BlockPatch, userID string) error + GetBlockHistory(c Container, blockID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error) + GetBoardAndCardByID(c Container, blockID string) (board *model.Block, card *model.Block, err error) + GetBoardAndCard(c Container, block *model.Block) (board *model.Block, card *model.Block, err error) // @withTransaction PatchBlocks(c Container, blockPatches *model.BlockPatchBatch, userID string) error @@ -69,4 +76,45 @@ type Store interface { HasWorkspaceAccess(userID string, workspaceID string) (bool, error) GetWorkspaceCount() (int64, error) GetUserWorkspaces(userID string) ([]model.UserWorkspace, error) + + CreateSubscription(c Container, sub *model.Subscription) (*model.Subscription, error) + DeleteSubscription(c Container, blockID string, subscriberID string) error + GetSubscription(c Container, blockID string, subscriberID string) (*model.Subscription, error) + GetSubscriptions(c Container, subscriberID string) ([]*model.Subscription, error) + GetSubscribersForBlock(c Container, blockID string) ([]*model.Subscriber, error) + GetSubscribersCountForBlock(c Container, blockID string) (int, error) + UpdateSubscribersNotifiedAt(c Container, blockID string, notifiedAt int64) error + + UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) + DeleteNotificationHint(c Container, blockID string) error + GetNotificationHint(c Container, blockID string) (*model.NotificationHint, error) + GetNextNotificationHint(remove bool) (*model.NotificationHint, error) + + IsErrNotFound(err error) bool +} + +// ErrNotFound is an error type that can be returned by store APIs when a query unexpectedly fetches no records. +type ErrNotFound struct { + resource string +} + +// NewErrNotFound creates a new ErrNotFound instance. +func NewErrNotFound(resource string) *ErrNotFound { + return &ErrNotFound{ + resource: resource, + } +} + +func (nf *ErrNotFound) Error() string { + return fmt.Sprintf("{%s} not found", nf.resource) +} + +// IsErrNotFound returns true if `err` is or wraps a ErrNotFound. +func IsErrNotFound(err error) bool { + if err == nil { + return false + } + + var nf *ErrNotFound + return errors.As(err, &nf) } diff --git a/server/services/store/storetests/blocks.go b/server/services/store/storetests/blocks.go index 4f4029950..5b90ba315 100644 --- a/server/services/store/storetests/blocks.go +++ b/server/services/store/storetests/blocks.go @@ -468,7 +468,7 @@ func testGetSubTree2(t *testing.T, store store.Store, container store.Container) require.Len(t, blocks, initialCount+6) t.Run("from root id", func(t *testing.T) { - blocks, err = store.GetSubTree2(container, "parent") + blocks, err = store.GetSubTree2(container, "parent", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 3) require.True(t, ContainsBlockWithID(blocks, "parent")) @@ -477,7 +477,7 @@ func testGetSubTree2(t *testing.T, store store.Store, container store.Container) }) t.Run("from child id", func(t *testing.T) { - blocks, err = store.GetSubTree2(container, "child1") + blocks, err = store.GetSubTree2(container, "child1", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 2) require.True(t, ContainsBlockWithID(blocks, "child1")) @@ -485,7 +485,7 @@ func testGetSubTree2(t *testing.T, store store.Store, container store.Container) }) t.Run("from not existing id", func(t *testing.T) { - blocks, err = store.GetSubTree2(container, "not-exists") + blocks, err = store.GetSubTree2(container, "not-exists", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 0) }) @@ -504,7 +504,7 @@ func testGetSubTree3(t *testing.T, store store.Store, container store.Container) require.Len(t, blocks, initialCount+6) t.Run("from root id", func(t *testing.T) { - blocks, err = store.GetSubTree3(container, "parent") + blocks, err = store.GetSubTree3(container, "parent", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 5) require.True(t, ContainsBlockWithID(blocks, "parent")) @@ -515,7 +515,7 @@ func testGetSubTree3(t *testing.T, store store.Store, container store.Container) }) t.Run("from child id", func(t *testing.T) { - blocks, err = store.GetSubTree3(container, "child1") + blocks, err = store.GetSubTree3(container, "child1", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 3) require.True(t, ContainsBlockWithID(blocks, "child1")) @@ -524,7 +524,7 @@ func testGetSubTree3(t *testing.T, store store.Store, container store.Container) }) t.Run("from not existing id", func(t *testing.T) { - blocks, err = store.GetSubTree3(container, "not-exists") + blocks, err = store.GetSubTree3(container, "not-exists", model.QuerySubtreeOptions{}) require.NoError(t, err) require.Len(t, blocks, 0) }) diff --git a/server/services/store/storetests/notificationhints.go b/server/services/store/storetests/notificationhints.go new file mode 100644 index 000000000..6d2da690b --- /dev/null +++ b/server/services/store/storetests/notificationhints.go @@ -0,0 +1,270 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetests + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/utils" +) + +func StoreTestNotificationHintsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { + container := store.Container{ + WorkspaceID: "0", + } + + t.Run("UpsertNotificationHint", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testUpsertNotificationHint(t, store, container) + }) + + t.Run("DeleteNotificationHint", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testDeleteNotificationHint(t, store, container) + }) + + t.Run("GetNotificationHint", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testGetNotificationHint(t, store, container) + }) + + t.Run("GetNextNotificationHint", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testGetNextNotificationHint(t, store, container) + }) +} + +func testUpsertNotificationHint(t *testing.T, store store.Store, container store.Container) { + t.Run("create notification hint", func(t *testing.T) { + hint := &model.NotificationHint{ + BlockType: model.TypeCard, + BlockID: utils.NewID(utils.IDTypeBlock), + ModifiedByID: utils.NewID(utils.IDTypeUser), + WorkspaceID: container.WorkspaceID, + } + + hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) + require.NoError(t, err, "upsert notification hint should not error") + assert.Equal(t, hint.BlockID, hintNew.BlockID) + assert.NoError(t, hintNew.IsValid()) + }) + + t.Run("duplicate notification hint", func(t *testing.T) { + hint := &model.NotificationHint{ + BlockType: model.TypeCard, + BlockID: utils.NewID(utils.IDTypeBlock), + ModifiedByID: utils.NewID(utils.IDTypeUser), + WorkspaceID: container.WorkspaceID, + } + hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) + require.NoError(t, err, "upsert notification hint should not error") + + // sleep a short time so the notify_at timestamps won't collide + time.Sleep(time.Millisecond * 20) + + hint = &model.NotificationHint{ + BlockType: model.TypeCard, + BlockID: hintNew.BlockID, + ModifiedByID: hintNew.ModifiedByID, + WorkspaceID: container.WorkspaceID, + } + hintDup, err := store.UpsertNotificationHint(hint, time.Second*15) + + require.NoError(t, err, "upsert notification hint should not error") + // notify_at should be updated + assert.Greater(t, hintDup.NotifyAt, hintNew.NotifyAt) + }) + + t.Run("invalid notification hint", func(t *testing.T) { + hint := &model.NotificationHint{} + + _, err := store.UpsertNotificationHint(hint, time.Second*15) + assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") + + hint.BlockType = model.TypeBoard + _, err = store.UpsertNotificationHint(hint, time.Second*15) + assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") + + hint.WorkspaceID = container.WorkspaceID + _, err = store.UpsertNotificationHint(hint, time.Second*15) + assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") + + hint.ModifiedByID = utils.NewID(utils.IDTypeUser) + _, err = store.UpsertNotificationHint(hint, time.Second*15) + assert.ErrorAs(t, err, &model.ErrInvalidNotificationHint{}, "invalid notification hint should error") + + hint.BlockID = utils.NewID(utils.IDTypeBlock) + hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) + assert.NoError(t, err, "valid notification hint should not error") + assert.NoError(t, hintNew.IsValid(), "created notification hint should be valid") + }) +} + +func testDeleteNotificationHint(t *testing.T, store store.Store, container store.Container) { + t.Run("delete notification hint", func(t *testing.T) { + hint := &model.NotificationHint{ + BlockType: model.TypeCard, + BlockID: utils.NewID(utils.IDTypeBlock), + ModifiedByID: utils.NewID(utils.IDTypeUser), + WorkspaceID: container.WorkspaceID, + } + hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) + require.NoError(t, err, "create notification hint should not error") + + // check the notification hint exists + hint, err = store.GetNotificationHint(container, hintNew.BlockID) + require.NoError(t, err, "get notification hint should not error") + assert.Equal(t, hintNew.BlockID, hint.BlockID) + assert.Equal(t, hintNew.CreateAt, hint.CreateAt) + + err = store.DeleteNotificationHint(container, hintNew.BlockID) + require.NoError(t, err, "delete notification hint should not error") + + // check the notification hint was deleted + hint, err = store.GetNotificationHint(container, hintNew.BlockID) + require.True(t, store.IsErrNotFound(err), "error should be of type store.ErrNotFound") + assert.Nil(t, hint) + }) + + t.Run("delete non-existent notification hint", func(t *testing.T) { + err := store.DeleteNotificationHint(container, "bogus") + require.True(t, store.IsErrNotFound(err), "error should be of type store.ErrNotFound") + }) +} + +func testGetNotificationHint(t *testing.T, store store.Store, container store.Container) { + t.Run("get notification hint", func(t *testing.T) { + hint := &model.NotificationHint{ + BlockType: model.TypeCard, + BlockID: utils.NewID(utils.IDTypeBlock), + ModifiedByID: utils.NewID(utils.IDTypeUser), + WorkspaceID: container.WorkspaceID, + } + hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) + require.NoError(t, err, "create notification hint should not error") + + // make sure notification hint can be fetched + hint, err = store.GetNotificationHint(container, hintNew.BlockID) + require.NoError(t, err, "get notification hint should not error") + assert.Equal(t, hintNew, hint) + }) + + t.Run("get non-existent notification hint", func(t *testing.T) { + hint, err := store.GetNotificationHint(container, "bogus") + require.True(t, store.IsErrNotFound(err), "error should be of type store.ErrNotFound") + assert.Nil(t, hint, "hint should be nil") + }) +} + +func testGetNextNotificationHint(t *testing.T, store store.Store, container store.Container) { + t.Run("get next notification hint", func(t *testing.T) { + const loops = 5 + ids := [5]string{} + modifiedBy := utils.NewID(utils.IDTypeUser) + + // create some hints with unique notifyAt + for i := 0; i < loops; i++ { + hint := &model.NotificationHint{ + BlockType: model.TypeCard, + BlockID: utils.NewID(utils.IDTypeBlock), + ModifiedByID: modifiedBy, + WorkspaceID: container.WorkspaceID, + } + hintNew, err := store.UpsertNotificationHint(hint, time.Second*15) + require.NoError(t, err, "create notification hint should not error") + + ids[i] = hintNew.BlockID + time.Sleep(time.Millisecond * 20) // ensure next timestamp is unique + } + + // check the hints come back in the right order + notifyAt := utils.GetMillisForTime(time.Now().Add(time.Millisecond * 50)) + for i := 0; i < loops; i++ { + hint, err := store.GetNextNotificationHint(false) + require.NoError(t, err, "get next notification hint should not error") + require.NotNil(t, hint, "get next notification hint should not return nil") + assert.Equal(t, ids[i], hint.BlockID) + assert.Less(t, notifyAt, hint.NotifyAt) + notifyAt = hint.NotifyAt + + err = store.DeleteNotificationHint(container, hint.BlockID) + require.NoError(t, err, "delete notification hint should not error") + } + }) + + t.Run("get next notification hint from empty table", func(t *testing.T) { + // empty the table + err := emptyNotificationHintTable(store) + require.NoError(t, err, "emptying notification hint table should not error") + + for { + hint, err2 := store.GetNextNotificationHint(false) + if store.IsErrNotFound(err2) { + break + } + require.NoError(t, err2, "get next notification hint should not error") + + err2 = store.DeleteNotificationHint(container, hint.BlockID) + require.NoError(t, err2, "delete notification hint should not error") + } + + _, err = store.GetNextNotificationHint(false) + require.True(t, store.IsErrNotFound(err), "error should be of type store.ErrNotFound") + }) + + t.Run("get next notification hint and remove", func(t *testing.T) { + // empty the table + err := emptyNotificationHintTable(store) + require.NoError(t, err, "emptying notification hint table should not error") + + hint := &model.NotificationHint{ + BlockType: model.TypeCard, + BlockID: utils.NewID(utils.IDTypeBlock), + ModifiedByID: utils.NewID(utils.IDTypeUser), + WorkspaceID: container.WorkspaceID, + } + hintNew, err := store.UpsertNotificationHint(hint, time.Second*1) + require.NoError(t, err, "create notification hint should not error") + + hintDeleted, err := store.GetNextNotificationHint(true) + require.NoError(t, err, "get next notification hint should not error") + require.NotNil(t, hintDeleted, "get next notification hint should not return nil") + assert.Equal(t, hintNew.BlockID, hintDeleted.BlockID) + + // should be no hint left + _, err = store.GetNextNotificationHint(false) + require.True(t, store.IsErrNotFound(err), "error should be of type store.ErrNotFound") + }) +} + +func emptyNotificationHintTable(store store.Store) error { + for { + hint, err := store.GetNextNotificationHint(false) + if store.IsErrNotFound(err) { + break + } + + if err != nil { + return err + } + + c := containerForWorkspace(hint.WorkspaceID) + + err = store.DeleteNotificationHint(c, hint.BlockID) + if err != nil { + return err + } + } + return nil +} diff --git a/server/services/store/storetests/subscriptions.go b/server/services/store/storetests/subscriptions.go new file mode 100644 index 000000000..3abb2ec93 --- /dev/null +++ b/server/services/store/storetests/subscriptions.go @@ -0,0 +1,333 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetests + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" +) + +func StoreTestSubscriptionsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { + container := store.Container{ + WorkspaceID: "0", + } + + t.Run("CreateSubscription", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testCreateSubscription(t, store, container) + }) + + t.Run("DeleteSubscription", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testDeleteSubscription(t, store, container) + }) + + t.Run("UndeleteSubscription", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testUndeleteSubscription(t, store, container) + }) + + t.Run("GetSubscription", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testGetSubscription(t, store, container) + }) + + t.Run("GetSubscriptions", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testGetSubscriptions(t, store, container) + }) + + t.Run("GetSubscribersForBlock", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testGetSubscribersForBlock(t, store, container) + }) +} + +func testCreateSubscription(t *testing.T, store store.Store, container store.Container) { + t.Run("create subscriptions", func(t *testing.T) { + users := createTestUsers(t, store, 10) + blocks := createTestBlocks(t, store, container, users[0].ID, 50) + + for i, user := range users { + for j := 0; j < i; j++ { + sub := &model.Subscription{ + BlockType: blocks[j].Type, + BlockID: blocks[j].ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + subNew, err := store.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + + assert.NotZero(t, subNew.NotifiedAt) + assert.NotZero(t, subNew.CreateAt) + assert.Zero(t, subNew.DeleteAt) + } + } + + // ensure each user has the right number of subscriptions + for i, user := range users { + subs, err := store.GetSubscriptions(container, user.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Len(t, subs, i) + } + }) + + t.Run("duplicate subscription", func(t *testing.T) { + admin := createTestUsers(t, store, 1)[0] + user := createTestUsers(t, store, 1)[0] + block := createTestBlocks(t, store, container, admin.ID, 1)[0] + + sub := &model.Subscription{ + BlockType: block.Type, + BlockID: block.ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + subNew, err := store.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + + sub = &model.Subscription{ + BlockType: block.Type, + BlockID: block.ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + + subDup, err := store.CreateSubscription(container, sub) + require.NoError(t, err, "create duplicate subscription should not error") + + assert.Equal(t, subNew.BlockID, subDup.BlockID) + assert.Equal(t, subNew.WorkspaceID, subDup.WorkspaceID) + assert.Equal(t, subNew.SubscriberID, subDup.SubscriberID) + }) + + t.Run("invalid subscription", func(t *testing.T) { + admin := createTestUsers(t, store, 1)[0] + user := createTestUsers(t, store, 1)[0] + block := createTestBlocks(t, store, container, admin.ID, 1)[0] + + sub := &model.Subscription{} + + _, err := store.CreateSubscription(container, sub) + assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") + + sub.BlockType = block.Type + _, err = store.CreateSubscription(container, sub) + assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") + + sub.BlockID = block.ID + _, err = store.CreateSubscription(container, sub) + assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") + + sub.SubscriberType = "user" + _, err = store.CreateSubscription(container, sub) + assert.ErrorAs(t, err, &model.ErrInvalidSubscription{}, "invalid subscription should error") + + sub.SubscriberID = user.ID + subNew, err := store.CreateSubscription(container, sub) + assert.NoError(t, err, "valid subscription should not error") + + assert.NoError(t, subNew.IsValid(), "created subscription should be valid") + }) +} + +func testDeleteSubscription(t *testing.T, s store.Store, container store.Container) { + t.Run("delete subscription", func(t *testing.T) { + user := createTestUsers(t, s, 1)[0] + block := createTestBlocks(t, s, container, user.ID, 1)[0] + + sub := &model.Subscription{ + BlockType: block.Type, + BlockID: block.ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + subNew, err := s.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + + // check the subscription exists + subs, err := s.GetSubscriptions(container, user.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Len(t, subs, 1) + assert.Equal(t, subNew.BlockID, subs[0].BlockID) + assert.Equal(t, subNew.SubscriberID, subs[0].SubscriberID) + + err = s.DeleteSubscription(container, block.ID, user.ID) + require.NoError(t, err, "delete subscription should not error") + + // check the subscription was deleted + subs, err = s.GetSubscriptions(container, user.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Empty(t, subs) + }) + + t.Run("delete non-existent subscription", func(t *testing.T) { + err := s.DeleteSubscription(container, "bogus", "bogus") + require.Error(t, err, "delete non-existent subscription should error") + var nf *store.ErrNotFound + require.ErrorAs(t, err, &nf, "error should be of type store.ErrNotFound") + require.True(t, store.IsErrNotFound(err)) + }) +} + +func testUndeleteSubscription(t *testing.T, s store.Store, container store.Container) { + t.Run("undelete subscription", func(t *testing.T) { + user := createTestUsers(t, s, 1)[0] + block := createTestBlocks(t, s, container, user.ID, 1)[0] + + sub := &model.Subscription{ + BlockType: block.Type, + BlockID: block.ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + subNew, err := s.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + + // check the subscription exists + subs, err := s.GetSubscriptions(container, user.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Len(t, subs, 1) + assert.Equal(t, subNew.BlockID, subs[0].BlockID) + assert.Equal(t, subNew.SubscriberID, subs[0].SubscriberID) + + err = s.DeleteSubscription(container, block.ID, user.ID) + require.NoError(t, err, "delete subscription should not error") + + // check the subscription was deleted + subs, err = s.GetSubscriptions(container, user.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Empty(t, subs) + + // re-create the subscription + subUndeleted, err := s.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + + // check the undeleted subscription exists + subs, err = s.GetSubscriptions(container, user.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Len(t, subs, 1) + assert.Equal(t, subUndeleted.BlockID, subs[0].BlockID) + assert.Equal(t, subUndeleted.SubscriberID, subs[0].SubscriberID) + }) +} + +func testGetSubscription(t *testing.T, s store.Store, container store.Container) { + t.Run("get subscription", func(t *testing.T) { + user := createTestUsers(t, s, 1)[0] + block := createTestBlocks(t, s, container, user.ID, 1)[0] + + sub := &model.Subscription{ + BlockType: block.Type, + BlockID: block.ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + subNew, err := s.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + + // make sure subscription can be fetched + sub, err = s.GetSubscription(container, block.ID, user.ID) + require.NoError(t, err, "get subscription should not error") + assert.Equal(t, subNew, sub) + }) + + t.Run("get non-existent subscription", func(t *testing.T) { + sub, err := s.GetSubscription(container, "bogus", "bogus") + require.Error(t, err, "get non-existent subscription should error") + var nf *store.ErrNotFound + require.ErrorAs(t, err, &nf, "error should be of type store.ErrNotFound") + require.True(t, store.IsErrNotFound(err)) + require.Nil(t, sub, "get subscription should return nil") + }) +} + +func testGetSubscriptions(t *testing.T, store store.Store, container store.Container) { + t.Run("get subscriptions", func(t *testing.T) { + author := createTestUsers(t, store, 1)[0] + user := createTestUsers(t, store, 1)[0] + blocks := createTestBlocks(t, store, container, author.ID, 50) + + for _, block := range blocks { + sub := &model.Subscription{ + BlockType: block.Type, + BlockID: block.ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + _, err := store.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + } + + // ensure user has the right number of subscriptions + subs, err := store.GetSubscriptions(container, user.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Len(t, subs, len(blocks)) + + // ensure author has no subscriptions + subs, err = store.GetSubscriptions(container, author.ID) + require.NoError(t, err, "get subscriptions should not error") + assert.Empty(t, subs) + }) + + t.Run("get subscriptions for invalid user", func(t *testing.T) { + subs, err := store.GetSubscriptions(container, "bogus") + require.NoError(t, err, "get subscriptions should not error") + assert.Empty(t, subs) + }) +} + +func testGetSubscribersForBlock(t *testing.T, store store.Store, container store.Container) { + t.Run("get subscribers for block", func(t *testing.T) { + users := createTestUsers(t, store, 50) + blocks := createTestBlocks(t, store, container, users[0].ID, 2) + + for _, user := range users { + sub := &model.Subscription{ + BlockType: blocks[1].Type, + BlockID: blocks[1].ID, + SubscriberType: "user", + SubscriberID: user.ID, + } + _, err := store.CreateSubscription(container, sub) + require.NoError(t, err, "create subscription should not error") + } + + // make sure block[1] has the right number of users subscribed + subs, err := store.GetSubscribersForBlock(container, blocks[1].ID) + require.NoError(t, err, "get subscribers for block should not error") + assert.Len(t, subs, 50) + + count, err := store.GetSubscribersCountForBlock(container, blocks[1].ID) + require.NoError(t, err, "get subscribers for block should not error") + assert.Equal(t, 50, count) + + // make sure block[0] has zero users subscribed + subs, err = store.GetSubscribersForBlock(container, blocks[0].ID) + require.NoError(t, err, "get subscribers for block should not error") + assert.Empty(t, subs) + + count, err = store.GetSubscribersCountForBlock(container, blocks[0].ID) + require.NoError(t, err, "get subscribers for block should not error") + assert.Zero(t, count) + }) + + t.Run("get subscribers for invalid block", func(t *testing.T) { + subs, err := store.GetSubscribersForBlock(container, "bogus") + require.NoError(t, err, "get subscribers for block should not error") + assert.Empty(t, subs) + }) +} diff --git a/server/services/store/storetests/util.go b/server/services/store/storetests/util.go new file mode 100644 index 000000000..d1407845d --- /dev/null +++ b/server/services/store/storetests/util.go @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetests + +import ( + "fmt" + "testing" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/utils" + "github.com/stretchr/testify/require" +) + +func createTestUsers(t *testing.T, store store.Store, num int) []*model.User { + var users []*model.User + for i := 0; i < num; i++ { + user := &model.User{ + ID: utils.NewID(utils.IDTypeUser), + Username: fmt.Sprintf("mooncake.%d", i), + Email: fmt.Sprintf("mooncake.%d@example.com", i), + } + err := store.CreateUser(user) + require.NoError(t, err) + + users = append(users, user) + } + return users +} + +func createTestBlocks(t *testing.T, store store.Store, container store.Container, userID string, num int) []*model.Block { + var blocks []*model.Block + for i := 0; i < num; i++ { + block := &model.Block{ + ID: utils.NewID(utils.IDTypeBlock), + RootID: utils.NewID(utils.IDTypeBlock), + Type: "card", + CreatedBy: userID, + WorkspaceID: container.WorkspaceID, + } + err := store.InsertBlock(container, block, userID) + require.NoError(t, err) + + blocks = append(blocks, block) + } + return blocks +} + +func containerForWorkspace(workspaceID string) store.Container { + return store.Container{ + WorkspaceID: workspaceID, + } +} diff --git a/server/utils/links.go b/server/utils/links.go new file mode 100644 index 000000000..4b8e70a1b --- /dev/null +++ b/server/utils/links.go @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package utils + +import "fmt" + +// MakeCardLink creates fully qualified card links based on card id and parents. +func MakeCardLink(serverRoot string, workspace string, board string, card string) string { + return fmt.Sprintf("%s/workspace/%s/%s/0/%s/", serverRoot, workspace, board, card) +} diff --git a/server/ws/adapter.go b/server/ws/adapter.go index bf77dcd3d..3a5774c14 100644 --- a/server/ws/adapter.go +++ b/server/ws/adapter.go @@ -12,10 +12,12 @@ const ( websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS" websocketActionUpdateBlock = "UPDATE_BLOCK" websocketActionUpdateConfig = "UPDATE_CLIENT_CONFIG" + websocketActionUpdateSubscription = "UPDATE_SUBSCRIPTION" ) type Adapter interface { BroadcastBlockChange(workspaceID string, block model.Block) BroadcastBlockDelete(workspaceID, blockID, parentID string) BroadcastConfigChange(clientConfig model.ClientConfig) + BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) } diff --git a/server/ws/common.go b/server/ws/common.go index 4f3063414..e130ed0a3 100644 --- a/server/ws/common.go +++ b/server/ws/common.go @@ -10,6 +10,12 @@ type UpdateMsg struct { Block model.Block `json:"block"` } +// UpdateSubscription is sent on subscription updates. +type UpdateSubscription struct { + Action string `json:"action"` + Subscription *model.Subscription `json:"subscription"` +} + // WebsocketCommand is an incoming command from the client. type WebsocketCommand struct { Action string `json:"action"` diff --git a/server/ws/plugin_adapter.go b/server/ws/plugin_adapter.go index 5235a6cfb..a36b36295 100644 --- a/server/ws/plugin_adapter.go +++ b/server/ws/plugin_adapter.go @@ -27,6 +27,7 @@ type PluginAdapterInterface interface { BroadcastConfigChange(clientConfig model.ClientConfig) BroadcastBlockChange(workspaceID string, block model.Block) BroadcastBlockDelete(workspaceID, blockID, parentID string) + BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) HandleClusterEvent(ev mmModel.PluginClusterEvent) } @@ -338,16 +339,16 @@ func (pa *PluginAdapter) BroadcastConfigChange(pluginConfig model.ClientConfig) // sendWorkspaceMessageSkipCluster sends a message to all the users // with a websocket client connected to. -func (pa *PluginAdapter) sendWorkspaceMessageSkipCluster(workspaceID string, payload map[string]interface{}) { +func (pa *PluginAdapter) sendWorkspaceMessageSkipCluster(event string, workspaceID string, payload map[string]interface{}) { userIDs := pa.getUserIDsForWorkspace(workspaceID) for _, userID := range userIDs { - pa.api.PublishWebSocketEvent(websocketActionUpdateBlock, payload, &mmModel.WebsocketBroadcast{UserId: userID}) + pa.api.PublishWebSocketEvent(event, payload, &mmModel.WebsocketBroadcast{UserId: userID}) } } // sendWorkspaceMessage sends and propagates a message that is aimed // for all the users that are subscribed to a given workspace. -func (pa *PluginAdapter) sendWorkspaceMessage(workspaceID string, payload map[string]interface{}) { +func (pa *PluginAdapter) sendWorkspaceMessage(event string, workspaceID string, payload map[string]interface{}) { go func() { clusterMessage := &ClusterMessage{ WorkspaceID: workspaceID, @@ -357,7 +358,7 @@ func (pa *PluginAdapter) sendWorkspaceMessage(workspaceID string, payload map[st pa.sendMessageToCluster("websocket_message", clusterMessage) }() - pa.sendWorkspaceMessageSkipCluster(workspaceID, payload) + pa.sendWorkspaceMessageSkipCluster(event, workspaceID, payload) } func (pa *PluginAdapter) BroadcastBlockChange(workspaceID string, block model.Block) { @@ -371,7 +372,7 @@ func (pa *PluginAdapter) BroadcastBlockChange(workspaceID string, block model.Bl Block: block, } - pa.sendWorkspaceMessage(workspaceID, utils.StructToMap(message)) + pa.sendWorkspaceMessage(websocketActionUpdateBlock, workspaceID, utils.StructToMap(message)) } func (pa *PluginAdapter) BroadcastBlockDelete(workspaceID, blockID, parentID string) { @@ -385,3 +386,18 @@ func (pa *PluginAdapter) BroadcastBlockDelete(workspaceID, blockID, parentID str pa.BroadcastBlockChange(workspaceID, block) } + +func (pa *PluginAdapter) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { + pa.api.LogInfo("BroadcastingSubscriptionChange", + "workspaceID", workspaceID, + "blockID", subscription.BlockID, + "subscriberID", subscription.SubscriberID, + ) + + message := UpdateSubscription{ + Action: websocketActionUpdateSubscription, + Subscription: subscription, + } + + pa.sendWorkspaceMessage(websocketActionUpdateSubscription, workspaceID, utils.StructToMap(message)) +} diff --git a/server/ws/plugin_adapter_cluster.go b/server/ws/plugin_adapter_cluster.go index 69bde6b2f..9336eb528 100644 --- a/server/ws/plugin_adapter_cluster.go +++ b/server/ws/plugin_adapter_cluster.go @@ -46,5 +46,20 @@ func (pa *PluginAdapter) HandleClusterEvent(ev mmModel.PluginClusterEvent) { return } - pa.sendWorkspaceMessageSkipCluster(clusterMessage.WorkspaceID, clusterMessage.Payload) + var action string + if actionRaw, ok := clusterMessage.Payload["action"]; ok { + if s, ok := actionRaw.(string); ok { + action = s + } + } + if action == "" { + // no action was specified in the event; assume block change and warn. + action = websocketActionUpdateBlock + pa.api.LogWarn("cannot determine action from cluster message data", + "id", ev.Id, + "payload", clusterMessage.Payload, + ) + } + + pa.sendWorkspaceMessageSkipCluster(action, clusterMessage.WorkspaceID, clusterMessage.Payload) } diff --git a/server/ws/server.go b/server/ws/server.go index 92efe9f43..89c5b92ec 100644 --- a/server/ws/server.go +++ b/server/ws/server.go @@ -536,3 +536,7 @@ func (ws *Server) BroadcastConfigChange(clientConfig model.ClientConfig) { } } } + +func (ws *Server) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { + // not implemented for standalone server. +} diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index c30b84e08..af36d75bc 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -61,6 +61,8 @@ "CardDetailProperty.property-deleted": "Deleted {propertyName} Successfully!", "CardDetailProperty.property-name-change-subtext": "type from \"{oldPropType}\" to \"{newPropType}\"", "CardDetailProperty.property-type-change-subtext": "name to \"{newPropName}\"", + "CardDetail.Follow": "Follow", + "CardDetail.Following": "Following", "CardDialog.copiedLink": "Copied!", "CardDialog.copyLink": "Copy link", "CardDialog.editing-template": "You're editing a template.", @@ -252,4 +254,4 @@ "login.register-button": "or create an account if you don't have one", "register.login-button": "or log in if you already have an account", "register.signup-title": "Sign up for your account" -} \ No newline at end of file +} diff --git a/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap b/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap index 93e002cac..50ed7e831 100644 --- a/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap +++ b/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap @@ -1,5 +1,283 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`components/cardDialog already following card 1`] = ` +
+
+
+
+
+ +
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ " + /> +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+`; + exports[`components/cardDialog return a cardDialog readonly 1`] = `
+
+ +
+
+ +