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.
This commit is contained in:
Doug Lauder 2021-12-10 10:46:37 -05:00 committed by GitHub
parent 7952d6b018
commit 75bd409ba0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 4986 additions and 211 deletions

View file

@ -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
)

View file

@ -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=

View file

@ -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 {

View file

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"path"
@ -31,6 +32,8 @@ const (
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,6 +319,21 @@ 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
}
}`
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -1,13 +1,16 @@
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"
)
@ -15,6 +18,7 @@ import (
type TestHelper struct {
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() }()

View file

@ -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)
})
}

View file

@ -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

View file

@ -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
}

172
server/model/properties.go Normal file
View file

@ -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
}

View file

@ -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
}
`
)

View file

@ -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"`
}

25
server/model/util.go Normal file
View file

@ -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)
}

View file

@ -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{

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 &notifier{
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()
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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", "¶ "))
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -81,6 +81,7 @@ type storeMetadata struct {
var blacklistedStoreMethodNames = map[string]bool{
"Shutdown": true,
"IsErrNotFound": true,
}
func extractMethodMetadata(method *ast.Field, src []byte) methodData {

View file

@ -14,6 +14,7 @@ package sqlstore
import (
"context"
"time"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/store"

View file

@ -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()

View file

@ -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").

View file

@ -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

View file

@ -0,0 +1,2 @@
DROP TABLE {{.prefix}}subscriptions;
DROP TABLE {{.prefix}}notification_hints;

View file

@ -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}};

View file

@ -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
}

View file

@ -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)

View file

@ -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) })
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
})

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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,
}
}

11
server/utils/links.go Normal file
View file

@ -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)
}

View file

@ -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)
}

View file

@ -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"`

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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.
}

View file

@ -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.",

View file

@ -1,5 +1,283 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/cardDialog already following card 1`] = `
<div>
<div
class="Dialog dialog-back undefined"
>
<div
class="wrapper"
>
<div
class="dialog"
>
<div
class="toolbar"
>
<button
aria-label="Close dialog"
class="Button IconButton IconButton--large"
title="Close dialog"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<button
type="button"
>
<span>
Following
</span>
</button>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="Button IconButton IconButton--large"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
<div
class="CardDetail content"
>
<div
class="BlockIconSelector"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="octo-icon size-l"
>
<span>
i
</span>
</div>
</div>
</div>
<div
class="EditableAreaWrap"
>
<textarea
class="EditableArea Editable title"
height="0"
placeholder="Untitled"
rows="1"
spellcheck="true"
title="title"
>
title
</textarea>
<div
class="EditableAreaContainer"
>
<textarea
aria-hidden="true"
class="EditableAreaReference Editable title"
dir="auto"
disabled=""
rows="1"
>
title
</textarea>
</div>
</div>
<div
class="octo-propertylist CardDetailProperties"
>
<div
class="octo-propertyname add-property"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<span>
+ Add a property
</span>
</button>
</div>
</div>
</div>
<hr />
<div
class="CommentsList"
>
<div
class="commentrow"
>
<img
class="comment-avatar"
src="data:image/svg+xml,<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 100 100\\" style=\\"fill: rgb(192, 192, 192);\\"><rect width=\\"100\\" height=\\"100\\" /></svg>"
/>
<div
class="MarkdownEditor octo-editor newcomment "
>
<div
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<hr />
</div>
</div>
<div
class="CardDetail content fullwidth content-blocks"
>
<div
class="octo-content CardDetailContents"
>
<div
class="octo-block"
>
<div
class="octo-block-margin"
/>
<div
class="MarkdownEditor octo-editor "
>
<div
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="CardDetailContentsMenu content add-content"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<span>
Add content
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/cardDialog return a cardDialog readonly 1`] = `
<div>
<div
@ -24,6 +302,17 @@ exports[`components/cardDialog return a cardDialog readonly 1`] = `
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<button
type="button"
>
<span>
Follow
</span>
</button>
</div>
</div>
<div
class="CardDetail content"
@ -120,6 +409,17 @@ exports[`components/cardDialog return cardDialog menu content 1`] = `
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<button
type="button"
>
<span>
Follow
</span>
</button>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
@ -474,6 +774,17 @@ exports[`components/cardDialog return cardDialog menu content and cancel delete
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<button
type="button"
>
<span>
Follow
</span>
</button>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"
@ -741,6 +1052,17 @@ exports[`components/cardDialog should match snapshot 1`] = `
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<button
type="button"
>
<span>
Follow
</span>
</button>
</div>
<div
aria-label="menuwrapper"
class="MenuWrapper"

View file

@ -0,0 +1,14 @@
.cardFollowBtn {
float: right;
height: 100%;
&.follow {
color: rgba(var(--center-channel-color-rgb), 0.64);
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
&.unfollow {
color: rgb(var(--button-bg-rgb));
background-color: rgba(var(--button-bg-rgb), 0.08);
}
}

View file

@ -39,6 +39,13 @@ describe('components/cardDialog', () => {
card.createdBy = 'user-id-1'
const state = {
clientConfig: {
value: {
featureFlags: {
subscriptions: true,
},
},
},
comments: {
comments: {},
},
@ -56,6 +63,7 @@ describe('components/cardDialog', () => {
4: {username: 'f'},
5: {username: 'g'},
},
blockSubscriptions: [],
},
}
const store = mockStateStore([], state)
@ -269,4 +277,40 @@ describe('components/cardDialog', () => {
userEvent.click(buttonCopy)
expect(mockedUtils.copyTextToClipboard).toBeCalledTimes(1)
})
test('already following card', async () => {
// simply doing {...state} gives a TypeScript error
// when you try updating it's values.
const newState = JSON.parse(JSON.stringify(state))
newState.users.blockSubscriptions = [{blockId: card.id}]
newState.clientConfig = {
value: {
featureFlags: {
subscriptions: true,
},
},
}
const newStore = mockStateStore([], newState)
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={newStore}>
<CardDialog
board={board}
activeView={boardView}
views={[boardView]}
cards={[card]}
cardId={card.id}
onClose={jest.fn()}
showCard={jest.fn()}
readonly={false}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
})

View file

@ -19,10 +19,21 @@ import Menu from '../widgets/menu'
import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../components/confirmationDialogBox'
import Button from '../widgets/buttons/button'
import {getUserBlockSubscriptionList} from '../store/initialLoad'
import {IUser} from '../user'
import {getMe} from '../store/users'
import {getClientConfig} from '../store/clientConfig'
import CardDetail from './cardDetail/cardDetail'
import Dialog from './dialog'
import {sendFlashMessage} from './flashMessages'
import './cardDialog.scss'
type Props = {
board: Board
activeView: BoardView
@ -40,6 +51,8 @@ const CardDialog = (props: Props): JSX.Element => {
const contents = useAppSelector(getCardContents(props.cardId))
const comments = useAppSelector(getCardComments(props.cardId))
const intl = useIntl()
const me = useAppSelector<IUser|null>(getMe)
const clientConfig = useAppSelector(getClientConfig)
const [showConfirmationDialogBox, setShowConfirmationDialogBox] = useState<boolean>(false)
const makeTemplateClicked = async () => {
@ -125,11 +138,39 @@ const CardDialog = (props: Props): JSX.Element => {
}
</Menu>
)
const followActionButton = (following: boolean): React.ReactNode => {
const followBtn = (
<Button
className='cardFollowBtn follow'
onClick={() => mutator.followBlock(props.cardId, 'card', me!.id)}
>
{intl.formatMessage({id: 'CardDetail.Follow', defaultMessage: 'Follow'})}
</Button>
)
const unfollowBtn = (
<Button
className='cardFollowBtn unfollow'
onClick={() => mutator.unfollowBlock(props.cardId, 'card', me!.id)}
>
{intl.formatMessage({id: 'CardDetail.Following', defaultMessage: 'Following'})}
</Button>
)
return following ? unfollowBtn : followBtn
}
const followingCards = useAppSelector(getUserBlockSubscriptionList)
const isFollowingCard = Boolean(followingCards.find((following) => following.blockId === props.cardId))
const toolbar = clientConfig.featureFlags.subscriptions ? followActionButton(isFollowingCard) : null
return (
<>
<Dialog
onClose={props.onClose}
toolsMenu={!props.readonly && menu}
toolbar={toolbar}
>
{card && card.fields.isTemplate &&
<div className='banner'>

View file

@ -68,12 +68,20 @@ describe('components/centerPanel', () => {
],
}
const state = {
clientConfig: {
value: {
featureFlags: {
subscriptions: true,
},
},
},
searchText: '',
users: {
me: {},
workspaceUsers: [
{username: 'username_1'},
],
blockSubscriptions: [],
},
boards: {
current: board.id,

View file

@ -84,4 +84,9 @@
padding-left: 78px;
}
}
.cardToolbar {
width: 100%;
margin: 0 16px;
}
}

View file

@ -13,6 +13,7 @@ import './dialog.scss'
type Props = {
children: React.ReactNode
toolsMenu?: React.ReactNode // some dialogs may not require a toolmenu
toolbar?: React.ReactNode
hideCloseButton?: boolean
className?: string
onClose: () => void,
@ -20,6 +21,7 @@ type Props = {
const Dialog = React.memo((props: Props) => {
const {toolsMenu} = props
const {toolbar} = props
const intl = useIntl()
const closeDialogText = intl.formatMessage({
@ -50,6 +52,7 @@ const Dialog = React.memo((props: Props) => {
className='IconButton--large'
/>
}
{toolbar && <div className='cardToolbar'>{toolbar}</div>}
{toolsMenu && <MenuWrapper>
<IconButton
className='IconButton--large'

View file

@ -102,6 +102,7 @@ describe('src/components/workspace', () => {
users: {
me,
workspaceUsers: [me],
blockSubscriptions: [],
},
boards: {
current: board.id,

View file

@ -642,6 +642,32 @@ class Mutator {
await this.updateBlock(newView, view, description)
}
async followBlock(blockId: string, blockType: string, userId: string) {
await undoManager.perform(
async () => {
await octoClient.followBlock(blockId, blockType, userId)
},
async () => {
await octoClient.unfollowBlock(blockId, blockType, userId)
},
'follow block',
this.undoGroupId,
)
}
async unfollowBlock(blockId: string, blockType: string, userId: string) {
await undoManager.perform(
async () => {
await octoClient.unfollowBlock(blockId, blockType, userId)
},
async () => {
await octoClient.followBlock(blockId, blockType, userId)
},
'follow block',
this.undoGroupId,
)
}
// Duplicate
async duplicateCard(

View file

@ -8,6 +8,7 @@ import {IUser, UserWorkspace} from './user'
import {Utils} from './utils'
import {ClientConfig} from './config/clientConfig'
import {UserSettings} from './userSettings'
import {Subscription} from './wsclient'
//
// OctoClient is the client interface to the server APIs
@ -290,6 +291,29 @@ class OctoClient {
})
}
async followBlock(blockId: string, blockType: string, userId: string): Promise<Response> {
const body: Subscription = {
blockType,
blockId,
workspaceId: this.workspaceId,
subscriberType: 'user',
subscriberId: userId,
}
return fetch(this.getBaseURL() + `/api/v1/workspaces/${this.workspaceId}/subscriptions`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify(body),
})
}
async unfollowBlock(blockId: string, blockType: string, userId: string): Promise<Response> {
return fetch(this.getBaseURL() + `/api/v1/workspaces/${this.workspaceId}/subscriptions/${blockId}/${userId}`, {
method: 'DELETE',
headers: this.headers(),
})
}
async insertBlock(block: Block): Promise<Response> {
return this.insertBlocks([block])
}
@ -438,6 +462,16 @@ class OctoClient {
const path = this.workspacePath('0') + '/blocks?type=board'
return this.getBlocksWithPath(path)
}
async getUserBlockSubscriptions(userId: string): Promise<Array<Subscription>> {
const path = `/api/v1/workspaces/${this.workspaceId}/subscriptions/${userId}`
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
if (response.status !== 200) {
return []
}
return (await this.getJson(response, [])) as Subscription[]
}
}
const octoClient = new OctoClient()

View file

@ -17,7 +17,7 @@ import Workspace from '../components/workspace'
import mutator from '../mutator'
import octoClient from '../octoClient'
import {Utils} from '../utils'
import wsClient, {WSClient} from '../wsclient'
import wsClient, {Subscription, WSClient} from '../wsclient'
import './boardPage.scss'
import {updateBoards, getCurrentBoard, setCurrent as setCurrentBoard} from '../store/boards'
import {updateViews, getCurrentView, setCurrent as setCurrentView, getCurrentBoardViews} from '../store/views'
@ -32,6 +32,8 @@ import IconButton from '../widgets/buttons/iconButton'
import CloseIcon from '../widgets/icons/close'
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../telemetry/telemetryClient'
import {fetchUserBlockSubscriptions, followBlock, getMe, unfollowBlock} from '../store/users'
import {IUser} from '../user'
type Props = {
readonly?: boolean
}
@ -50,6 +52,7 @@ const BoardPage = (props: Props): JSX.Element => {
const [websocketClosed, setWebsocketClosed] = useState(false)
const queryString = new URLSearchParams(useLocation().search)
const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed)
const me = useAppSelector<IUser|null>(getMe)
let workspaceId = match.params.workspaceId || UserSettings.lastWorkspaceId || '0'
@ -66,6 +69,18 @@ const BoardPage = (props: Props): JSX.Element => {
octoClient.workspaceId = workspaceId
}, [match.params.workspaceId])
// Load user's block subscriptions when workspace changes
// block subscriptions are relevant only in plugin mode.
if (Utils.isFocalboardPlugin()) {
useEffect(() => {
if (!me) {
return
}
dispatch(fetchUserBlockSubscriptions(me!.id))
}, [match.params.workspaceId])
}
// Backward compatibility: This can be removed in the future, this is for
// transform the old query params into routes
useEffect(() => {
@ -233,6 +248,16 @@ const BoardPage = (props: Props): JSX.Element => {
wsClient.addOnChange(incrementalUpdate)
wsClient.addOnReconnect(() => dispatch(loadAction(match.params.boardId)))
wsClient.addOnStateChange(updateWebsocketState)
wsClient.setOnFollowBlock((_: WSClient, subscription: Subscription): void => {
if (subscription.subscriberId === me?.id && subscription.workspaceId === match.params.workspaceId) {
dispatch(followBlock(subscription))
}
})
wsClient.setOnUnfollowBlock((_: WSClient, subscription: Subscription): void => {
if (subscription.subscriberId === me?.id && subscription.workspaceId === match.params.workspaceId) {
dispatch(unfollowBlock(subscription))
}
})
return () => {
if (timeout) {
clearTimeout(timeout)

View file

@ -1,13 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createAsyncThunk} from '@reduxjs/toolkit'
import {createAsyncThunk, createSelector} from '@reduxjs/toolkit'
import {default as client} from '../octoClient'
import {UserWorkspace} from '../user'
import {Utils} from '../utils'
const getUserWorkspaces = async ():Promise<UserWorkspace[]> => {
import {Subscription} from '../wsclient'
import {RootState} from './index'
const fetchUserWorkspaces = async ():Promise<UserWorkspace[]> => {
// Concept of workspaces is only applicable when running as a plugin.
// There is always only one, single workspace in personal server edition.
return Utils.isFocalboardPlugin() ? client.getUserWorkspaces() : []
@ -20,7 +24,7 @@ export const initialLoad = createAsyncThunk(
client.getWorkspace(),
client.getWorkspaceUsers(),
client.getAllBlocks(),
getUserWorkspaces(),
fetchUserWorkspaces(),
])
// if no workspace, either bad id, or user doesn't have access
@ -43,3 +47,10 @@ export const initialReadOnlyLoad = createAsyncThunk(
return blocks
},
)
export const getUserBlockSubscriptions = (state: RootState): Array<Subscription> => state.users.blockSubscriptions
export const getUserBlockSubscriptionList = createSelector(
getUserBlockSubscriptions,
(subscriptions) => subscriptions,
)

View file

@ -6,6 +6,10 @@ import {createSlice, createAsyncThunk, PayloadAction, createSelector} from '@red
import {default as client} from '../octoClient'
import {IUser} from '../user'
import {Utils} from '../utils'
import {Subscription} from '../wsclient'
import {initialLoad} from './initialLoad'
import {RootState} from './index'
@ -19,11 +23,25 @@ type UsersStatus = {
me: IUser|null
workspaceUsers: {[key: string]: IUser}
loggedIn: boolean|null
blockSubscriptions: Array<Subscription>
}
export const fetchUserBlockSubscriptions = createAsyncThunk(
'user/blockSubscriptions',
async (userId: string) => (Utils.isFocalboardPlugin() ? client.getUserBlockSubscriptions(userId) : []),
)
const initialState = {
me: null,
workspaceUsers: {},
loggedIn: null,
userWorkspaces: [],
blockSubscriptions: [],
} as UsersStatus
const usersSlice = createSlice({
name: 'users',
initialState: {me: null, workspaceUsers: {}, loggedIn: null, userWorkspaces: []} as UsersStatus,
initialState,
reducers: {
setMe: (state, action: PayloadAction<IUser>) => {
state.me = action.payload
@ -34,6 +52,13 @@ const usersSlice = createSlice({
return acc
}, {})
},
followBlock: (state, action: PayloadAction<Subscription>) => {
state.blockSubscriptions.push(action.payload)
},
unfollowBlock: (state, action: PayloadAction<Subscription>) => {
const oldSubscriptions = state.blockSubscriptions
state.blockSubscriptions = oldSubscriptions.filter((subscription) => subscription.blockId !== action.payload.blockId)
},
},
extraReducers: (builder) => {
builder.addCase(fetchMe.fulfilled, (state, action) => {
@ -50,6 +75,9 @@ const usersSlice = createSlice({
return acc
}, {})
})
builder.addCase(fetchUserBlockSubscriptions.fulfilled, (state, action) => {
state.blockSubscriptions = action.payload
})
},
})
@ -71,3 +99,5 @@ export const getUser = (userId: string): (state: RootState) => IUser|undefined =
return users[userId]
}
}
export const {followBlock, unfollowBlock} = usersSlice.actions

View file

@ -22,6 +22,23 @@ type WSMessage = {
error?: string
}
type WSSubscriptionMsg = {
action?: string
subscription?: Subscription
error?: string
}
export interface Subscription {
blockId: string
workspaceId: string
subscriberId: string
blockType: string
subscriberType: string
notifiedAt?: number
createAt?: number
deleteAt?: number
}
export const ACTION_UPDATE_BLOCK = 'UPDATE_BLOCK'
export const ACTION_AUTH = 'AUTH'
export const ACTION_SUBSCRIBE_BLOCKS = 'SUBSCRIBE_BLOCKS'
@ -29,6 +46,7 @@ export const ACTION_SUBSCRIBE_WORKSPACE = 'SUBSCRIBE_WORKSPACE'
export const ACTION_UNSUBSCRIBE_WORKSPACE = 'UNSUBSCRIBE_WORKSPACE'
export const ACTION_UNSUBSCRIBE_BLOCKS = 'UNSUBSCRIBE_BLOCKS'
export const ACTION_UPDATE_CLIENT_CONFIG = 'UPDATE_CLIENT_CONFIG'
export const ACTION_UPDATE_SUBSCRIPTION = 'UPDATE_SUBSCRIPTION'
// The Mattermost websocket client interface
export interface MMWebSocketClient {
@ -45,6 +63,7 @@ type OnReconnectHandler = (client: WSClient) => void
type OnStateChangeHandler = (client: WSClient, state: 'init' | 'open' | 'close') => void
type OnErrorHandler = (client: WSClient, e: Event) => void
type OnConfigChangeHandler = (client: WSClient, clientConfig: ClientConfig) => void
type FollowChangeHandler = (client: WSClient, subscription: Subscription) => void
class WSClient {
ws: WebSocket|null = null
@ -61,6 +80,8 @@ class WSClient {
onChange: OnChangeHandler[] = []
onError: OnErrorHandler[] = []
onConfigChange: OnConfigChangeHandler[] = []
onFollowBlock: FollowChangeHandler = () => {}
onUnfollowBlock: FollowChangeHandler = () => {}
private notificationDelay = 100
private reopenDelay = 3000
private updatedBlocks: Block[] = []
@ -300,12 +321,31 @@ class WSClient {
this.queueUpdateNotification(Utils.fixBlock(message.block!))
}
setOnFollowBlock(handler: FollowChangeHandler): void {
this.onFollowBlock = handler
}
setOnUnfollowBlock(handler: FollowChangeHandler): void {
this.onUnfollowBlock = handler
}
updateClientConfigHandler(config: ClientConfig): void {
for (const handler of this.onConfigChange) {
handler(this, config)
}
}
updateSubscriptionHandler(message: WSSubscriptionMsg): void {
Utils.log('updateSubscriptionHandler: ' + message.action + '; blockId=' + message.subscription?.blockId)
if (!message.subscription) {
return
}
const handler = message.subscription.deleteAt ? this.onUnfollowBlock : this.onFollowBlock
handler(this, message.subscription)
}
setOnAppVersionChangeHandler(fn: (versionHasChanged: boolean) => void): void {
this.onAppVersionChangeHandler = fn
}