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:
parent
7952d6b018
commit
75bd409ba0
71 changed files with 4986 additions and 211 deletions
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
@ -28,9 +29,11 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
boardsFeatureFlagName = "BoardsFeatureFlags"
|
||||
pluginName = "focalboard"
|
||||
sharedBoardsName = "enablepublicsharedboards"
|
||||
boardsFeatureFlagName = "BoardsFeatureFlags"
|
||||
pluginName = "focalboard"
|
||||
sharedBoardsName = "enablepublicsharedboards"
|
||||
notifyFreqCardSecondsKey = "notify_freq_card_seconds"
|
||||
notifyFreqBoardSecondsKey = "notify_freq_board_seconds"
|
||||
)
|
||||
|
||||
type BoardsEmbed struct {
|
||||
|
@ -111,9 +114,28 @@ func (p *Plugin) OnActivate() error {
|
|||
|
||||
p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db))
|
||||
|
||||
mentionsBackend, err := createMentionsNotifyBackend(client, baseURL+"/boards", logger)
|
||||
backendParams := notifyBackendParams{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
serverRoot: baseURL + "/boards",
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
var notifyBackends []notify.Backend
|
||||
|
||||
mentionsBackend, err := createMentionsNotifyBackend(backendParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating mentions notifications backend: %w", err)
|
||||
return fmt.Errorf("error creating mention notifications backend: %w", err)
|
||||
}
|
||||
notifyBackends = append(notifyBackends, mentionsBackend)
|
||||
|
||||
if cfg.IsSubscriptionsEnabled() {
|
||||
subscriptionsBackend, err2 := createSubscriptionsNotifyBackend(backendParams, db, p.wsPluginAdapter)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("error creating subscription notifications backend: %w", err2)
|
||||
}
|
||||
notifyBackends = append(notifyBackends, subscriptionsBackend)
|
||||
mentionsBackend.AddListener(subscriptionsBackend)
|
||||
}
|
||||
|
||||
params := server.Params{
|
||||
|
@ -123,7 +145,7 @@ func (p *Plugin) OnActivate() error {
|
|||
Logger: logger,
|
||||
ServerID: serverID,
|
||||
WSAdapter: p.wsPluginAdapter,
|
||||
NotifyBackends: []notify.Backend{mentionsBackend},
|
||||
NotifyBackends: notifyBackends,
|
||||
}
|
||||
|
||||
server, err := server.New(params)
|
||||
|
@ -204,9 +226,36 @@ func (p *Plugin) createBoardsConfig(mmconfig mmModel.Config, baseURL string, ser
|
|||
AuthMode: "mattermost",
|
||||
EnablePublicSharedBoards: enablePublicSharedBoards,
|
||||
FeatureFlags: featureFlags,
|
||||
NotifyFreqCardSeconds: getPluginSettingInt(mmconfig, notifyFreqCardSecondsKey, 120),
|
||||
NotifyFreqBoardSeconds: getPluginSettingInt(mmconfig, notifyFreqBoardSecondsKey, 86400),
|
||||
}
|
||||
}
|
||||
|
||||
func getPluginSetting(mmConfig mmModel.Config, key string) (interface{}, bool) {
|
||||
plugin, ok := mmConfig.PluginSettings.Plugins[pluginName]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
val, ok := plugin[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
func getPluginSettingInt(mmConfig mmModel.Config, key string, def int) int {
|
||||
val, ok := getPluginSetting(mmConfig, key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
valFloat, ok := val.(float64)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return int(math.Round(valFloat))
|
||||
}
|
||||
|
||||
func parseFeatureFlags(configFeatureFlags map[string]string) map[string]string {
|
||||
featureFlags := make(map[string]string)
|
||||
for key, value := range configFeatureFlags {
|
||||
|
@ -270,7 +319,22 @@ func defaultLoggingConfig() string {
|
|||
{"id": 1, "name": "fatal", "stacktrace": true},
|
||||
{"id": 0, "name": "panic", "stacktrace": true}
|
||||
]
|
||||
}
|
||||
},
|
||||
"errors_file": {
|
||||
"Type": "file",
|
||||
"Format": "plain",
|
||||
"Levels": [
|
||||
{"ID": 2, "Name": "error", "Stacktrace": true}
|
||||
],
|
||||
"Options": {
|
||||
"Compress": true,
|
||||
"Filename": "focalboard_errors.log",
|
||||
"MaxAgeDays": 0,
|
||||
"MaxBackups": 5,
|
||||
"MaxSizeMB": 10
|
||||
},
|
||||
"MaxQueueSize": 1000
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
42
server/app/subscriptions.go
Normal file
42
server/app/subscriptions.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
package integrationtests
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/focalboard/server/api"
|
||||
"github.com/mattermost/focalboard/server/client"
|
||||
"github.com/mattermost/focalboard/server/server"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
type TestHelper struct {
|
||||
Server *server.Server
|
||||
Client *client.Client
|
||||
Server *server.Server
|
||||
Client *client.Client
|
||||
Client2 *client.Client
|
||||
}
|
||||
|
||||
func getTestConfig() *config.Configuration {
|
||||
|
@ -103,6 +107,7 @@ func SetupTestHelperWithoutToken() *TestHelper {
|
|||
th := &TestHelper{}
|
||||
th.Server = newTestServer("")
|
||||
th.Client = client.NewClient(th.Server.Config().ServerRoot, "")
|
||||
th.Client2 = client.NewClient(th.Server.Config().ServerRoot, "")
|
||||
return th
|
||||
}
|
||||
|
||||
|
@ -139,6 +144,51 @@ func (th *TestHelper) InitBasic() *TestHelper {
|
|||
return th
|
||||
}
|
||||
|
||||
var ErrRegisterFail = errors.New("register failed")
|
||||
|
||||
func (th *TestHelper) InitUsers(username1 string, username2 string) error {
|
||||
workspace, err := th.Server.App().GetRootWorkspace()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clients := []*client.Client{th.Client, th.Client2}
|
||||
usernames := []string{username1, username2}
|
||||
|
||||
for i, client := range clients {
|
||||
// register a new user
|
||||
password := utils.NewID(utils.IDTypeNone)
|
||||
registerRequest := &api.RegisterRequest{
|
||||
Username: usernames[i],
|
||||
Email: usernames[i] + "@example.com",
|
||||
Password: password,
|
||||
Token: workspace.SignupToken,
|
||||
}
|
||||
success, resp := client.Register(registerRequest)
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
if !success {
|
||||
return ErrRegisterFail
|
||||
}
|
||||
|
||||
// login
|
||||
loginRequest := &api.LoginRequest{
|
||||
Type: "normal",
|
||||
Username: registerRequest.Username,
|
||||
Email: registerRequest.Email,
|
||||
Password: registerRequest.Password,
|
||||
}
|
||||
data, resp := client.Login(loginRequest)
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
|
||||
client.Token = data.Token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (th *TestHelper) TearDown() {
|
||||
defer func() { _ = th.Server.Logger().Shutdown() }()
|
||||
|
||||
|
|
146
server/integrationtests/subscriptions_test.go
Normal file
146
server/integrationtests/subscriptions_test.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
|
92
server/model/notification.go
Normal file
92
server/model/notification.go
Normal 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
172
server/model/properties.go
Normal 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
|
||||
}
|
138
server/model/properties_test.go
Normal file
138
server/model/properties_test.go
Normal 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
|
||||
}
|
||||
`
|
||||
)
|
110
server/model/subscription.go
Normal file
110
server/model/subscription.go
Normal 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
25
server/model/util.go
Normal 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)
|
||||
}
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
17
server/services/notify/notifysubscriptions/delivery.go
Normal file
17
server/services/notify/notifysubscriptions/delivery.go
Normal 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
|
||||
}
|
317
server/services/notify/notifysubscriptions/diff.go
Normal file
317
server/services/notify/notifysubscriptions/diff.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
251
server/services/notify/notifysubscriptions/notifier.go
Normal file
251
server/services/notify/notifysubscriptions/notifier.go
Normal 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 ¬ifier{
|
||||
serverRoot: params.ServerRoot,
|
||||
store: params.Store,
|
||||
delivery: params.Delivery,
|
||||
logger: params.Logger,
|
||||
done: nil,
|
||||
hints: make(chan *model.NotificationHint, hintQueueSize),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) start() {
|
||||
n.mux.Lock()
|
||||
defer n.mux.Unlock()
|
||||
|
||||
if n.done == nil {
|
||||
n.done = make(chan struct{})
|
||||
go n.loop()
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) stop() {
|
||||
n.mux.Lock()
|
||||
defer n.mux.Unlock()
|
||||
|
||||
if n.done != nil {
|
||||
close(n.done)
|
||||
n.done = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) loop() {
|
||||
done := n.done
|
||||
var nextNotify time.Time
|
||||
|
||||
for {
|
||||
hint, err := n.store.GetNextNotificationHint(false)
|
||||
switch {
|
||||
case n.store.IsErrNotFound(err):
|
||||
// no hints in table; wait up to an hour or when `onNotifyHint` is called again
|
||||
nextNotify = time.Now().Add(time.Hour * 1)
|
||||
n.logger.Debug("notify loop - no hints in queue", mlog.Time("next_check", nextNotify))
|
||||
case err != nil:
|
||||
// try again in a minute
|
||||
nextNotify = time.Now().Add(time.Minute * 1)
|
||||
n.logger.Error("notify loop - error fetching next notification", mlog.Err(err))
|
||||
case hint.NotifyAt > utils.GetMillis():
|
||||
// next hint is not ready yet; sleep until hint.NotifyAt
|
||||
nextNotify = utils.GetTimeForMillis(hint.NotifyAt)
|
||||
default:
|
||||
// it's time to notify
|
||||
n.notify()
|
||||
continue
|
||||
}
|
||||
|
||||
n.logger.Debug("subscription notifier loop",
|
||||
mlog.Time("next_notify", nextNotify),
|
||||
)
|
||||
|
||||
select {
|
||||
case <-n.hints:
|
||||
// A new hint was added. Wake up and check if next hint is ready to go.
|
||||
case <-time.After(time.Until(nextNotify)):
|
||||
// Next scheduled hint should be ready now.
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) onNotifyHint(hint *model.NotificationHint) error {
|
||||
n.logger.Debug("onNotifyHint - enqueing hint", mlog.Any("hint", hint))
|
||||
|
||||
select {
|
||||
case n.hints <- hint:
|
||||
case <-time.After(enqueueNotifyHintTimeout):
|
||||
return errEnqueueNotifyHintTimeout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *notifier) notify() {
|
||||
var hint *model.NotificationHint
|
||||
var err error
|
||||
|
||||
hint, err = n.store.GetNextNotificationHint(true)
|
||||
if err != nil {
|
||||
// try again later
|
||||
n.logger.Error("notify - error fetching next notification", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err = n.notifySubscribers(hint); err != nil {
|
||||
n.logger.Error("Error notifying subscribers", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) notifySubscribers(hint *model.NotificationHint) error {
|
||||
c := store.Container{
|
||||
WorkspaceID: hint.WorkspaceID,
|
||||
}
|
||||
|
||||
// get the subscriber list
|
||||
subs, err := n.store.GetSubscribersForBlock(c, hint.BlockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
n.logger.Debug("notifySubscribers - no subscribers", mlog.Any("hint", hint))
|
||||
return nil
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - subscribers",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.Int("sub_count", len(subs)),
|
||||
)
|
||||
|
||||
// subs slice is sorted by `NotifiedAt`, therefore subs[0] contains the oldest NotifiedAt needed
|
||||
oldestNotifiedAt := subs[0].NotifiedAt
|
||||
|
||||
// need the block's board and card.
|
||||
board, card, err := n.store.GetBoardAndCardByID(c, hint.BlockID)
|
||||
if err != nil || board == nil || card == nil {
|
||||
return fmt.Errorf("could not get board & card for block %s: %w", hint.BlockID, err)
|
||||
}
|
||||
|
||||
dg := &diffGenerator{
|
||||
container: c,
|
||||
board: board,
|
||||
card: card,
|
||||
store: n.store,
|
||||
hint: hint,
|
||||
lastNotifyAt: oldestNotifiedAt,
|
||||
logger: n.logger,
|
||||
}
|
||||
diffs, err := dg.generateDiffs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - diffs",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.Int("diff_count", len(diffs)),
|
||||
)
|
||||
|
||||
if len(diffs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := DiffConvOpts{
|
||||
Language: "en", // TODO: use correct language with i18n available on server.
|
||||
MakeCardLink: func(block *model.Block) string {
|
||||
return fmt.Sprintf("[%s](%s)", block.Title, utils.MakeCardLink(n.serverRoot, board.WorkspaceID, board.ID, card.ID))
|
||||
},
|
||||
}
|
||||
|
||||
attachments, err := Diffs2SlackAttachments(diffs, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
for _, sub := range subs {
|
||||
// don't notify the author of their own changes.
|
||||
if sub.SubscriberID == hint.ModifiedByID {
|
||||
n.logger.Debug("notifySubscribers - deliver, skipping author",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("modified_by_id", hint.ModifiedByID),
|
||||
mlog.String("modified_by_username", hint.Username),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - deliver",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("modified_by_id", hint.ModifiedByID),
|
||||
mlog.String("subscriber_id", sub.SubscriberID),
|
||||
mlog.String("subscriber_type", string(sub.SubscriberType)),
|
||||
)
|
||||
|
||||
if err = n.delivery.SubscriptionDeliverSlackAttachments(hint.WorkspaceID, sub.SubscriberID, sub.SubscriberType, attachments); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot deliver notification to subscriber %s [%s]: %w",
|
||||
sub.SubscriberID, sub.SubscriberType, err))
|
||||
}
|
||||
}
|
||||
|
||||
// find the new NotifiedAt based on the newest diff.
|
||||
var notifiedAt int64
|
||||
for _, d := range diffs {
|
||||
if d.UpdateAt > notifiedAt {
|
||||
notifiedAt = d.UpdateAt
|
||||
}
|
||||
for _, c := range d.Diffs {
|
||||
if c.UpdateAt > notifiedAt {
|
||||
notifiedAt = c.UpdateAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the last notified_at for all subscribers since we at least attempted to notify all of them.
|
||||
err = dg.store.UpdateSubscribersNotifiedAt(dg.container, dg.hint.BlockID, notifiedAt)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("could not update subscribers notified_at for block %s: %w", dg.hint.BlockID, err))
|
||||
}
|
||||
|
||||
return merr.ErrorOrNil()
|
||||
}
|
30
server/services/notify/notifysubscriptions/store.go
Normal file
30
server/services/notify/notifysubscriptions/store.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
32
server/services/notify/notifysubscriptions/util.go
Normal file
32
server/services/notify/notifysubscriptions/util.go
Normal 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", "¶ "))
|
||||
}
|
60
server/services/notify/plugindelivery/mention_deliver.go
Normal file
60
server/services/notify/plugindelivery/mention_deliver.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -80,7 +80,8 @@ type storeMetadata struct {
|
|||
}
|
||||
|
||||
var blacklistedStoreMethodNames = map[string]bool{
|
||||
"Shutdown": true,
|
||||
"Shutdown": true,
|
||||
"IsErrNotFound": true,
|
||||
}
|
||||
|
||||
func extractMethodMetadata(method *ast.Field, src []byte) methodData {
|
||||
|
|
|
@ -14,6 +14,7 @@ package sqlstore
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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").
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE {{.prefix}}subscriptions;
|
||||
DROP TABLE {{.prefix}}notification_hints;
|
|
@ -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}};
|
||||
|
203
server/services/store/sqlstore/notificationhints.go
Normal file
203
server/services/store/sqlstore/notificationhints.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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) })
|
||||
}
|
||||
|
|
273
server/services/store/sqlstore/subscriptions.go
Normal file
273
server/services/store/sqlstore/subscriptions.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
270
server/services/store/storetests/notificationhints.go
Normal file
270
server/services/store/storetests/notificationhints.go
Normal 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
|
||||
}
|
333
server/services/store/storetests/subscriptions.go
Normal file
333
server/services/store/storetests/subscriptions.go
Normal 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)
|
||||
})
|
||||
}
|
54
server/services/store/storetests/util.go
Normal file
54
server/services/store/storetests/util.go
Normal 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
11
server/utils/links.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
|
|
|
@ -61,6 +61,8 @@
|
|||
"CardDetailProperty.property-deleted": "Deleted {propertyName} Successfully!",
|
||||
"CardDetailProperty.property-name-change-subtext": "type from \"{oldPropType}\" to \"{newPropType}\"",
|
||||
"CardDetailProperty.property-type-change-subtext": "name to \"{newPropName}\"",
|
||||
"CardDetail.Follow": "Follow",
|
||||
"CardDetail.Following": "Following",
|
||||
"CardDialog.copiedLink": "Copied!",
|
||||
"CardDialog.copyLink": "Copy link",
|
||||
"CardDialog.editing-template": "You're editing a template.",
|
||||
|
@ -252,4 +254,4 @@
|
|||
"login.register-button": "or create an account if you don't have one",
|
||||
"register.login-button": "or log in if you already have an account",
|
||||
"register.signup-title": "Sign up for your account"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
14
webapp/src/components/cardDialog.scss
Normal file
14
webapp/src/components/cardDialog.scss
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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,39 +138,67 @@ 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'>
|
||||
<FormattedMessage
|
||||
id='CardDialog.editing-template'
|
||||
defaultMessage="You're editing a template."
|
||||
/>
|
||||
</div>}
|
||||
<div className='banner'>
|
||||
<FormattedMessage
|
||||
id='CardDialog.editing-template'
|
||||
defaultMessage="You're editing a template."
|
||||
/>
|
||||
</div>}
|
||||
|
||||
{card &&
|
||||
<CardDetail
|
||||
board={board}
|
||||
activeView={activeView}
|
||||
views={views}
|
||||
cards={cards}
|
||||
card={card}
|
||||
contents={contents}
|
||||
comments={comments}
|
||||
readonly={props.readonly}
|
||||
/>}
|
||||
<CardDetail
|
||||
board={board}
|
||||
activeView={activeView}
|
||||
views={views}
|
||||
cards={cards}
|
||||
card={card}
|
||||
contents={contents}
|
||||
comments={comments}
|
||||
readonly={props.readonly}
|
||||
/>}
|
||||
|
||||
{!card &&
|
||||
<div className='banner error'>
|
||||
<FormattedMessage
|
||||
id='CardDialog.nocard'
|
||||
defaultMessage="This card doesn't exist or is inaccessible."
|
||||
/>
|
||||
</div>}
|
||||
<div className='banner error'>
|
||||
<FormattedMessage
|
||||
id='CardDialog.nocard'
|
||||
defaultMessage="This card doesn't exist or is inaccessible."
|
||||
/>
|
||||
</div>}
|
||||
</Dialog>
|
||||
|
||||
{showConfirmationDialogBox && <ConfirmationDialogBox dialogBox={confirmDialogProps}/>}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -84,4 +84,9 @@
|
|||
padding-left: 78px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardToolbar {
|
||||
width: 100%;
|
||||
margin: 0 16px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -102,6 +102,7 @@ describe('src/components/workspace', () => {
|
|||
users: {
|
||||
me,
|
||||
workspaceUsers: [me],
|
||||
blockSubscriptions: [],
|
||||
},
|
||||
boards: {
|
||||
current: board.id,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue