Feature: board insights - against 7.0 release (#3086)
* Add user, team boards endpoint * Revert accidental mmModel import delete * Bump squirrel version to 1.5.2 * Change queries to separately handle private and public boards * Use utils.GetMillisForTime instead of time library function which isn't compatible with older go versions * Add squirrel to linux/go.sum * Add authorization check for user accessing team insights, comment and lint modifications for board_insights store * Add storetests for board insights and utilities * Improve team insights tests * Refactor insights queries, add user insights tests * Fix durationSelector tests * Optimize iconPopulate query, add more app,store tests for insights feature * Remove unnecessary go-bindata package metadata update * Add license and guest user checks * blank identifier for unused parameter in isUserGuest * Fix user-id in test * Undo Makefile changes * Make teamID a query parameter * Handle time conversion error in sqlstore * Remove redundant fmt logs, lint fixes * Lint fix * Add features to paginate response, change duration param to -> time_range * Fix swagger docstring for insights endpoints * lint fixes * fix reading of page parameter into per_page * initial commit for focalboard insights endpoint * adding client function * Add workspace_id to response * Lint fixes * renaming registry function * fixing lint * fixing lint * Fix http codes when parameters are invalid * Include public boards for insights, fix user insights WHERE clause bug * Lint fixes * Lint squirrel query * Fix ctx instantization, name of auth function which checks if user is in a team * Remove redundant constants declaration, improve guest rejection message, add team permission check for getUserInsights * Merge queries for private and public workspaces for user into one * Fix timestamp generation used for filtering boards * Update mattermost-server version to 182ae1234a49d0c0a2867905234e93b25caa003a * Run 'go mod tidy' in modules * Replace UnixMilli() since it's incompatible with go=1.16 in linux CI Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Benjamin Cooke <benjamincooke@Benjamins-MacBook-Pro.local> Co-authored-by: Ben Cooke <benkcooke@gmail.com>
This commit is contained in:
parent
747efba206
commit
c4c4ce5578
28 changed files with 2411 additions and 302 deletions
|
@ -7,6 +7,6 @@ replace github.com/mattermost/focalboard/server => ../server
|
|||
require (
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/mattermost/focalboard/server v0.0.0-20210422230105-f5ae0b265a8d
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
|
||||
github.com/webview/webview v0.0.0-20200724072439-e0c01595b361
|
||||
)
|
||||
|
|
|
@ -1128,8 +1128,8 @@ github.com/mattermost/logr/v2 v2.0.15/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXp
|
|||
github.com/mattermost/mattermost-plugin-api v0.0.21 h1:0uQ9kQwFFEvb+qxwXqwbaDGmxwwv3wv6iHdhMWG3KiA=
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.21/go.mod h1:qz19Y+5HLbjtzY2RZ6B8ZZx90XUrmoRsHkSAfvfKU1M=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20210901153517-42e75fad4dae/go.mod h1:kmxJuVgpX13Th+e5L1ZsBs4aq+ETmmDg9joo5r4cIw8=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee h1:4zobTblf+33T9Y6SVXy0zX1YJ999Tyxff7unycul82s=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49 h1:7uFRdTX54qUp7jNodn0+vrhxuwnudHxWRv7kLN72Fig=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
|
||||
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs=
|
||||
github.com/mattermost/squirrel v0.2.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU=
|
||||
|
|
|
@ -4,8 +4,8 @@ go 1.12
|
|||
|
||||
require (
|
||||
github.com/go-git/go-git/v5 v5.1.0
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20210817091833-04b27ce93c02
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/stretchr/testify v1.7.1
|
||||
sigs.k8s.io/yaml v1.2.0
|
||||
)
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -7,7 +7,7 @@ replace github.com/mattermost/focalboard/server => ../server
|
|||
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-20220518113816-388842803cee
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
|
||||
github.com/prometheus/common v0.31.1 // indirect
|
||||
github.com/stretchr/testify v1.7.1
|
||||
)
|
||||
|
|
|
@ -1128,8 +1128,8 @@ github.com/mattermost/logr/v2 v2.0.15/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXp
|
|||
github.com/mattermost/mattermost-plugin-api v0.0.21 h1:0uQ9kQwFFEvb+qxwXqwbaDGmxwwv3wv6iHdhMWG3KiA=
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.21/go.mod h1:qz19Y+5HLbjtzY2RZ6B8ZZx90XUrmoRsHkSAfvfKU1M=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20210901153517-42e75fad4dae/go.mod h1:kmxJuVgpX13Th+e5L1ZsBs4aq+ETmmDg9joo5r4cIw8=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee h1:4zobTblf+33T9Y6SVXy0zX1YJ999Tyxff7unycul82s=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49 h1:7uFRdTX54qUp7jNodn0+vrhxuwnudHxWRv7kLN72Fig=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
|
||||
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs=
|
||||
github.com/mattermost/squirrel v0.2.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU=
|
||||
|
|
|
@ -253,6 +253,21 @@ export default class Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Insights handler
|
||||
if (this.registry?.registerInsightsHandler) {
|
||||
this.registry?.registerInsightsHandler(async (timeRange: string, page: number, perPage: number, teamId: string, insightType: string) => {
|
||||
if (insightType === 'MY') {
|
||||
const data = await octoClient.getMyTopBoards(timeRange, page, perPage, teamId)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const data = await octoClient.getTeamTopBoards(timeRange, page, perPage, teamId)
|
||||
|
||||
return data
|
||||
});
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
|
|
@ -15,6 +15,7 @@ export interface PluginRegistry {
|
|||
registerWebSocketEventHandler(event: string, handler: (e: any) => void)
|
||||
unregisterWebSocketEventHandler(event: string)
|
||||
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
|
||||
registerInsightsHandler(handler: (timeRange: string, page: number, perPage: number, teamId: string, insightType: string) => void)
|
||||
|
||||
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
|
@ -101,6 +102,10 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
|||
|
||||
apiv1.HandleFunc("/workspaces", a.sessionRequired(a.handleGetUserWorkspaces)).Methods("GET")
|
||||
|
||||
// Insights APIs
|
||||
apiv1.HandleFunc("/teams/{teamID}/boards/insights", a.sessionRequired(a.handleTeamBoardsInsights)).Methods("GET")
|
||||
apiv1.HandleFunc("/users/me/boards/insights", a.sessionRequired(a.handleUserBoardsInsights)).Methods("GET")
|
||||
|
||||
// Get Files API
|
||||
|
||||
files := r.PathPrefix("/files").Subrouter()
|
||||
|
@ -125,7 +130,200 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
|||
// limits
|
||||
apiv1.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET")
|
||||
}
|
||||
func (a *API) handleTeamBoardsInsights(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards/insights getTeamBoardsInsights
|
||||
//
|
||||
// Returns team boards insights
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: time_range
|
||||
// in: query
|
||||
// description: duration of data to calculate insights for
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page offset for top boards
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: per_page
|
||||
// in: query
|
||||
// description: limit for boards in a page.
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/BoardInsight"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
session := r.Context().Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
query := r.URL.Query()
|
||||
timeRange := query.Get("time_range")
|
||||
|
||||
if !a.app.HasPermissionToTeam(userID, teamID) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "Access denied to team", PermissionError{"access denied to team"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getTeamBoardsInsights", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
page, err := strconv.Atoi(query.Get("page"))
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting page parameter to integer", err)
|
||||
return
|
||||
}
|
||||
perPage, err := strconv.Atoi(query.Get("per_page"))
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting per_page parameter to integer", err)
|
||||
return
|
||||
}
|
||||
|
||||
userTimezone, aErr := a.app.GetUserTimezone(userID)
|
||||
if aErr != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Error getting time zone of user", aErr)
|
||||
return
|
||||
}
|
||||
userLocation, _ := time.LoadLocation(userTimezone)
|
||||
if userLocation == nil {
|
||||
userLocation = time.Now().UTC().Location()
|
||||
}
|
||||
// get unix time for duration
|
||||
startTime := mmModel.StartOfDayForTimeRange(timeRange, userLocation)
|
||||
boardsInsights, err := a.app.GetTeamBoardsInsights(userID, teamID, &mmModel.InsightsOpts{
|
||||
StartUnixMilli: mmModel.GetMillisForTime(*startTime),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "time_range="+timeRange, err)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(boardsInsights)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("teamBoardInsightCount", len(boardsInsights.Items))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUserBoardsInsights(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /users/me/boards/insights getUserBoardsInsights
|
||||
//
|
||||
// Returns team boards insights
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: team_id
|
||||
// in: query
|
||||
// description: teamID of the boards to be considered.
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: time_range
|
||||
// in: query
|
||||
// description: duration of data to calculate insights for
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page offset for top boards
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: per_page
|
||||
// in: query
|
||||
// description: limit for boards in a page.
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/BoardInsight"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
session := r.Context().Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
query := r.URL.Query()
|
||||
teamID := query.Get("team_id")
|
||||
timeRange := query.Get("time_range")
|
||||
|
||||
if !a.app.HasPermissionToTeam(userID, teamID) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "Access denied to team", PermissionError{"access denied to team"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getUserBoardsInsights", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
page, err := strconv.Atoi(query.Get("page"))
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting page parameter to integer", err)
|
||||
return
|
||||
}
|
||||
perPage, err := strconv.Atoi(query.Get("per_page"))
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting per_page parameter to integer", err)
|
||||
return
|
||||
}
|
||||
|
||||
userTimezone, aErr := a.app.GetUserTimezone(userID)
|
||||
if aErr != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Error getting time zone of user", aErr)
|
||||
return
|
||||
}
|
||||
userLocation, _ := time.LoadLocation(userTimezone)
|
||||
if userLocation == nil {
|
||||
userLocation = time.Now().UTC().Location()
|
||||
}
|
||||
// get unix time for duration
|
||||
startTime := mmModel.StartOfDayForTimeRange(timeRange, userLocation)
|
||||
boardsInsights, err := a.app.GetUserBoardsInsights(userID, teamID, &mmModel.InsightsOpts{
|
||||
StartUnixMilli: mmModel.GetMillisForTime(*startTime),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "time_range="+timeRange, err)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(boardsInsights)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("userBoardInsightCount", len(boardsInsights.Items))
|
||||
auditRec.Success()
|
||||
}
|
||||
func (a *API) RegisterAdminRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/admin/users/{username}/password", a.adminRequired(a.handleAdminSetPassword)).Methods("POST")
|
||||
}
|
||||
|
|
68
server/app/insights.go
Normal file
68
server/app/insights.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (a *App) GetTeamBoardsInsights(userID string, teamID string, opts *mmModel.InsightsOpts) (*model.BoardInsightsList, error) {
|
||||
// check if server is properly licensed, and user is not a guest
|
||||
licenseAndGuestCheckFlag, err := licenseAndGuestCheck(a, userID)
|
||||
if !licenseAndGuestCheckFlag {
|
||||
return nil, err
|
||||
}
|
||||
channels, err := a.store.GetUserWorkspacesInTeam(userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channelIDs := make([]string, len(channels))
|
||||
for index, channel := range channels {
|
||||
channelIDs[index] = channel.ID
|
||||
}
|
||||
return a.store.GetTeamBoardsInsights(channelIDs, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage)
|
||||
}
|
||||
|
||||
func (a *App) GetUserBoardsInsights(userID string, teamID string, opts *mmModel.InsightsOpts) (*model.BoardInsightsList, error) {
|
||||
// check if server is properly licensed, and user is not a guest
|
||||
licenseAndGuestCheckFlag, err := licenseAndGuestCheck(a, userID)
|
||||
if !licenseAndGuestCheckFlag {
|
||||
return nil, err
|
||||
}
|
||||
channels, err := a.store.GetUserWorkspacesInTeam(userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channelIDs := make([]string, len(channels))
|
||||
for index, channel := range channels {
|
||||
channelIDs[index] = channel.ID
|
||||
}
|
||||
return a.store.GetUserBoardsInsights(userID, channelIDs, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage)
|
||||
}
|
||||
|
||||
func licenseAndGuestCheck(a *App, userID string) (bool, error) {
|
||||
licenseError := errors.New("invalid license/authorization to use insights API")
|
||||
guestError := errors.New("Guests aren't authorized to use insights API")
|
||||
lic := a.store.GetLicense()
|
||||
if lic == nil {
|
||||
a.logger.Debug("Deployment doesn't have a license")
|
||||
return false, licenseError
|
||||
}
|
||||
isGuest, err := a.store.IsUserGuest(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if lic.SkuShortName != mmModel.LicenseShortSkuProfessional && lic.SkuShortName != mmModel.LicenseShortSkuEnterprise {
|
||||
return false, licenseError
|
||||
}
|
||||
if isGuest {
|
||||
return false, guestError
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUserTimezone(userID string) (string, error) {
|
||||
return a.store.GetUserTimezone(userID)
|
||||
}
|
93
server/app/insights_test.go
Normal file
93
server/app/insights_test.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var mockInsightsWorkspaces = []model.UserWorkspace{
|
||||
{
|
||||
ID: "mock-user-workspace-id",
|
||||
Title: "MockUserWorkspace",
|
||||
},
|
||||
}
|
||||
|
||||
var mockTeamInsights = []*model.BoardInsight{
|
||||
{
|
||||
BoardID: "board-id-1",
|
||||
},
|
||||
{
|
||||
BoardID: "board-id-2",
|
||||
},
|
||||
}
|
||||
|
||||
var mockTeamInsightsList = &model.BoardInsightsList{
|
||||
InsightsListData: mmModel.InsightsListData{HasNext: false},
|
||||
Items: mockTeamInsights,
|
||||
}
|
||||
|
||||
type insightError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (ie insightError) Error() string {
|
||||
return ie.msg
|
||||
}
|
||||
|
||||
func TestGetTeamBoardsInsights(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success query", func(t *testing.T) {
|
||||
th.Store.EXPECT().IsUserGuest("user-id").Return(false, nil)
|
||||
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id", "team-id").Return(mockInsightsWorkspaces, nil)
|
||||
th.Store.EXPECT().GetTeamBoardsInsights([]string{"mock-user-workspace-id"}, int64(0), 0, 10).Return(mockTeamInsightsList, nil)
|
||||
results, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Items, 2)
|
||||
})
|
||||
|
||||
t.Run("fail query", func(t *testing.T) {
|
||||
th.Store.EXPECT().IsUserGuest("user-id").Return(false, nil)
|
||||
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id", "team-id").Return(mockInsightsWorkspaces, nil)
|
||||
th.Store.EXPECT().GetTeamBoardsInsights([]string{"mock-user-workspace-id"}, int64(0), 0, 10).Return(nil, insightError{"board-insight-error"})
|
||||
_, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, insightError{"board-insight-error"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUserBoardsInsights(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success query", func(t *testing.T) {
|
||||
th.Store.EXPECT().IsUserGuest("user-id-1").Return(false, nil)
|
||||
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id-1", "team-id").Return(mockInsightsWorkspaces, nil)
|
||||
th.Store.EXPECT().GetUserBoardsInsights("user-id-1", []string{"mock-user-workspace-id"}, int64(0), 0, 10).Return(mockTeamInsightsList, nil)
|
||||
results, err := th.App.GetUserBoardsInsights("user-id-1", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Items, 2)
|
||||
})
|
||||
|
||||
t.Run("fail query", func(t *testing.T) {
|
||||
th.Store.EXPECT().IsUserGuest("user-id-1").Return(false, nil)
|
||||
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id-1", "team-id").Return(mockInsightsWorkspaces, nil)
|
||||
th.Store.EXPECT().GetUserBoardsInsights("user-id-1", []string{"mock-user-workspace-id"},
|
||||
int64(0), 0, 10).Return(nil, insightError{"board-insight-error"})
|
||||
_, err := th.App.GetUserBoardsInsights("user-id-1", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, insightError{"board-insight-error"})
|
||||
})
|
||||
}
|
|
@ -18,3 +18,7 @@ func (a *App) UpdateUserConfig(userID string, patch model.UserPropPatch) (map[st
|
|||
|
||||
return user.Props, nil
|
||||
}
|
||||
|
||||
func (a *App) HasPermissionToTeam(userID string, teamID string) bool {
|
||||
return a.store.HasPermissionToTeam(userID, teamID)
|
||||
}
|
||||
|
|
|
@ -443,6 +443,20 @@ func (c *Client) GetSubscriptions(workspaceID string, subscriberID string) ([]*m
|
|||
return subs, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) GetTeamRoute(id string) string {
|
||||
return fmt.Sprintf("/teams/%s", id)
|
||||
}
|
||||
|
||||
func (c *Client) GetTeamBoardsInsights(teamID string, duration string) ([]model.BoardInsight, *Response) {
|
||||
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/insights?duration="+duration, "")
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return model.BoardInsightsFromJSON(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) GetLimits() (*model.BoardsCloudLimits, *Response) {
|
||||
r, err := c.DoAPIGet("/limits", "")
|
||||
if err != nil {
|
||||
|
|
|
@ -12,7 +12,7 @@ require (
|
|||
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94
|
||||
github.com/lib/pq v1.10.5
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.21
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/oklog/run v1.1.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
|
|
|
@ -1128,8 +1128,8 @@ github.com/mattermost/logr/v2 v2.0.15/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXp
|
|||
github.com/mattermost/mattermost-plugin-api v0.0.21 h1:0uQ9kQwFFEvb+qxwXqwbaDGmxwwv3wv6iHdhMWG3KiA=
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.21/go.mod h1:qz19Y+5HLbjtzY2RZ6B8ZZx90XUrmoRsHkSAfvfKU1M=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20210901153517-42e75fad4dae/go.mod h1:kmxJuVgpX13Th+e5L1ZsBs4aq+ETmmDg9joo5r4cIw8=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee h1:4zobTblf+33T9Y6SVXy0zX1YJ999Tyxff7unycul82s=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49 h1:7uFRdTX54qUp7jNodn0+vrhxuwnudHxWRv7kLN72Fig=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
|
||||
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs=
|
||||
github.com/mattermost/squirrel v0.2.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU=
|
||||
|
|
65
server/model/board_insights.go
Normal file
65
server/model/board_insights.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// BoardInsightsList is a response type with pagination support.
|
||||
type BoardInsightsList struct {
|
||||
mmModel.InsightsListData
|
||||
Items []*BoardInsight `json:"items"`
|
||||
}
|
||||
|
||||
// BoardInsight gives insight into activities in a Board
|
||||
// swagger:model
|
||||
type BoardInsight struct {
|
||||
// ID of the board
|
||||
// required: true
|
||||
BoardID string `json:"boardID"`
|
||||
|
||||
// Icon of the board
|
||||
// required: false
|
||||
Icon string `json:"icon"`
|
||||
|
||||
// Title of the board
|
||||
// required: false
|
||||
Title string `json:"title"`
|
||||
|
||||
// Metric of how active the board is
|
||||
// required: true
|
||||
ActivityCount int `json:"activityCount"`
|
||||
|
||||
// IDs of users active on the board
|
||||
// required: true
|
||||
ActiveUsers string `json:"activeUsers"`
|
||||
|
||||
// ID of user who created the board
|
||||
// required: true
|
||||
CreatedBy string `json:"createdBy"`
|
||||
|
||||
// WorkspaceID of the board
|
||||
WorkspaceID string `json:"workspaceID"`
|
||||
}
|
||||
|
||||
func BoardInsightsFromJSON(data io.Reader) []BoardInsight {
|
||||
var boardInsights []BoardInsight
|
||||
_ = json.NewDecoder(data).Decode(&boardInsights)
|
||||
return boardInsights
|
||||
}
|
||||
|
||||
// GetTopBoardInsightsListWithPagination adds a rank to each item in the given list of BoardInsight and checks if there is
|
||||
// another page that can be fetched based on the given limit and offset. The given list of BoardInsight is assumed to be
|
||||
// sorted by ActivityCount(score). Returns a BoardInsightsList.
|
||||
func GetTopBoardInsightsListWithPagination(boards []*BoardInsight, limit int) *BoardInsightsList {
|
||||
// Add pagination support
|
||||
var hasNext bool
|
||||
if (limit != 0) && (len(boards) == limit+1) {
|
||||
hasNext = true
|
||||
boards = boards[:len(boards)-1]
|
||||
}
|
||||
|
||||
return &BoardInsightsList{InsightsListData: mmModel.InsightsListData{HasNext: hasNext}, Items: boards}
|
||||
}
|
|
@ -24,11 +24,11 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
sqliteDBType = "sqlite3"
|
||||
postgresDBType = "postgres"
|
||||
mysqlDBType = "mysql"
|
||||
|
||||
directChannelType = "D"
|
||||
sqliteDBType = "sqlite3"
|
||||
postgresDBType = "postgres"
|
||||
mysqlDBType = "mysql"
|
||||
nonTemplateFilterMySQL = "focalboard_blocks.fields LIKE '%\"isTemplate\":false%'"
|
||||
nonTemplateFilterPostgres = "focalboard_blocks.fields ->> 'isTemplate' = 'false'"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -428,6 +428,64 @@ func (s *MattermostAuthLayer) GetUserWorkspaces(userID string) ([]model.UserWork
|
|||
return s.userWorkspacesFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetUserWorkspacesInTeam(userID string, teamID string) ([]model.UserWorkspace, error) {
|
||||
var memberWorkspacesQuery, accessibleWorkspacesQuery sq.SelectBuilder
|
||||
|
||||
var nonTemplateFilter string
|
||||
|
||||
switch s.dbType {
|
||||
case mysqlDBType:
|
||||
nonTemplateFilter = nonTemplateFilterMySQL
|
||||
case postgresDBType:
|
||||
nonTemplateFilter = nonTemplateFilterPostgres
|
||||
default:
|
||||
return nil, fmt.Errorf("GetUserWorkspaces - %w", errUnsupportedDatabaseError)
|
||||
}
|
||||
|
||||
memberWorkspacesQuery = s.getQueryBuilder().
|
||||
Select("Channels.ID", "Channels.DisplayName", "COUNT(focalboard_blocks.id)", "Channels.Type", "Channels.Name").
|
||||
From("ChannelMembers").
|
||||
// select channels without a corresponding workspace
|
||||
LeftJoin(
|
||||
"focalboard_blocks ON focalboard_blocks.workspace_id = ChannelMembers.ChannelId AND "+
|
||||
"focalboard_blocks.type = 'board' AND "+
|
||||
nonTemplateFilter,
|
||||
).
|
||||
Join("Channels ON ChannelMembers.ChannelId = Channels.Id").
|
||||
Where(sq.Eq{"ChannelMembers.UserId": userID}).
|
||||
Where(sq.Eq{"Channels.TeamID": teamID}).
|
||||
GroupBy("Channels.Id", "Channels.DisplayName")
|
||||
|
||||
accessibleWorkspacesQuery = s.getQueryBuilder().
|
||||
Select("pc.ID", "pc.DisplayName", "COUNT(focalboard_blocks.id)", "'O' as Type", "pc.Name").Prefix(" UNION ALL ").
|
||||
From("PublicChannels as pc").
|
||||
// select channels without a corresponding workspace
|
||||
LeftJoin(
|
||||
"focalboard_blocks ON focalboard_blocks.workspace_id = pc.ID AND "+
|
||||
"focalboard_blocks.type = 'board' AND "+
|
||||
nonTemplateFilter,
|
||||
).
|
||||
Where(sq.Eq{"pc.TeamID": teamID}).
|
||||
GroupBy("pc.Id", "pc.DisplayName")
|
||||
|
||||
workspacesQuery := memberWorkspacesQuery.SuffixExpr(accessibleWorkspacesQuery)
|
||||
|
||||
rows, err := workspacesQuery.Query()
|
||||
if err != nil {
|
||||
s.logger.Error("ERROR GetUserWorkspaces", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer s.CloseRows(rows)
|
||||
memberWorkspaces, err := s.userWorkspacesFromRows(rows)
|
||||
if err != nil {
|
||||
s.logger.Error("ERROR userWorkspacesFromRows", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memberWorkspaces, nil
|
||||
}
|
||||
|
||||
type UserWorkspaceRawModel struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
|
@ -456,7 +514,7 @@ func (s *MattermostAuthLayer) userWorkspacesFromRows(rows *sql.Rows) ([]model.Us
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if rawUserWorkspace.Type == directChannelType {
|
||||
if rawUserWorkspace.Type == string(mmModel.ChannelTypeDirect) {
|
||||
userIDs := strings.Split(rawUserWorkspace.Name, "__")
|
||||
usersToFetch = append(usersToFetch, userIDs...)
|
||||
}
|
||||
|
@ -477,7 +535,7 @@ func (s *MattermostAuthLayer) userWorkspacesFromRows(rows *sql.Rows) ([]model.Us
|
|||
userWorkspaces := []model.UserWorkspace{}
|
||||
|
||||
for i := range rawUserWorkspaces {
|
||||
if rawUserWorkspaces[i].Type == directChannelType {
|
||||
if rawUserWorkspaces[i].Type == string(mmModel.ChannelTypeDirect) {
|
||||
userIDs := strings.Split(rawUserWorkspaces[i].Name, "__")
|
||||
names := []string{}
|
||||
|
||||
|
@ -674,6 +732,34 @@ func (s *MattermostAuthLayer) GetLicense() *mmModel.License {
|
|||
return s.pluginAPI.GetLicense()
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) HasPermissionToTeam(userID string, teamID string) bool {
|
||||
return s.pluginAPI.HasPermissionToTeam(userID, teamID, mmModel.PermissionViewTeam)
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetCloudLimits() (*mmModel.ProductLimits, error) {
|
||||
return s.pluginAPI.GetCloudLimits()
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) IsUserGuest(userID string) (bool, error) {
|
||||
user, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.Contains(user.Roles, "guest"), nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetUserTimezone(userID string) (string, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("timezone").
|
||||
From("Users").
|
||||
Where(sq.Eq{"id": userID})
|
||||
row := query.QueryRow()
|
||||
|
||||
var timezone mmModel.StringMap
|
||||
err := row.Scan(&timezone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return mmModel.GetPreferredTimezone(timezone), nil
|
||||
}
|
||||
|
|
|
@ -675,6 +675,21 @@ func (mr *MockStoreMockRecorder) GetSystemSettings() *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemSettings", reflect.TypeOf((*MockStore)(nil).GetSystemSettings))
|
||||
}
|
||||
|
||||
// GetTeamBoardsInsights mocks base method.
|
||||
func (m *MockStore) GetTeamBoardsInsights(arg0 []string, arg1 int64, arg2, arg3 int) (*model.BoardInsightsList, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTeamBoardsInsights", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].(*model.BoardInsightsList)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTeamBoardsInsights indicates an expected call of GetTeamBoardsInsights.
|
||||
func (mr *MockStoreMockRecorder) GetTeamBoardsInsights(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamBoardsInsights", reflect.TypeOf((*MockStore)(nil).GetTeamBoardsInsights), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// GetUsedCardsCount mocks base method.
|
||||
func (m *MockStore) GetUsedCardsCount() (int, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -690,6 +705,21 @@ func (mr *MockStoreMockRecorder) GetUsedCardsCount() *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsedCardsCount", reflect.TypeOf((*MockStore)(nil).GetUsedCardsCount))
|
||||
}
|
||||
|
||||
// GetUserBoardsInsights mocks base method.
|
||||
func (m *MockStore) GetUserBoardsInsights(arg0 string, arg1 []string, arg2 int64, arg3, arg4 int) (*model.BoardInsightsList, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserBoardsInsights", arg0, arg1, arg2, arg3, arg4)
|
||||
ret0, _ := ret[0].(*model.BoardInsightsList)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserBoardsInsights indicates an expected call of GetUserBoardsInsights.
|
||||
func (mr *MockStoreMockRecorder) GetUserBoardsInsights(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserBoardsInsights", reflect.TypeOf((*MockStore)(nil).GetUserBoardsInsights), arg0, arg1, arg2, arg3, arg4)
|
||||
}
|
||||
|
||||
// GetUserByEmail mocks base method.
|
||||
func (m *MockStore) GetUserByEmail(arg0 string) (*model.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -735,6 +765,21 @@ func (mr *MockStoreMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Cal
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockStore)(nil).GetUserByUsername), arg0)
|
||||
}
|
||||
|
||||
// GetUserTimezone mocks base method.
|
||||
func (m *MockStore) GetUserTimezone(arg0 string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserTimezone", arg0)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserTimezone indicates an expected call of GetUserTimezone.
|
||||
func (mr *MockStoreMockRecorder) GetUserTimezone(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTimezone", reflect.TypeOf((*MockStore)(nil).GetUserTimezone), arg0)
|
||||
}
|
||||
|
||||
// GetUserWorkspaces mocks base method.
|
||||
func (m *MockStore) GetUserWorkspaces(arg0 string) ([]model.UserWorkspace, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -750,6 +795,21 @@ func (mr *MockStoreMockRecorder) GetUserWorkspaces(arg0 interface{}) *gomock.Cal
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserWorkspaces", reflect.TypeOf((*MockStore)(nil).GetUserWorkspaces), arg0)
|
||||
}
|
||||
|
||||
// GetUserWorkspacesInTeam mocks base method.
|
||||
func (m *MockStore) GetUserWorkspacesInTeam(arg0, arg1 string) ([]model.UserWorkspace, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserWorkspacesInTeam", arg0, arg1)
|
||||
ret0, _ := ret[0].([]model.UserWorkspace)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserWorkspacesInTeam indicates an expected call of GetUserWorkspacesInTeam.
|
||||
func (mr *MockStoreMockRecorder) GetUserWorkspacesInTeam(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserWorkspacesInTeam", reflect.TypeOf((*MockStore)(nil).GetUserWorkspacesInTeam), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetUsersByWorkspace mocks base method.
|
||||
func (m *MockStore) GetUsersByWorkspace(arg0 string) ([]*model.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -810,6 +870,20 @@ func (mr *MockStoreMockRecorder) GetWorkspaceTeam(arg0 interface{}) *gomock.Call
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceTeam", reflect.TypeOf((*MockStore)(nil).GetWorkspaceTeam), arg0)
|
||||
}
|
||||
|
||||
// HasPermissionToTeam mocks base method.
|
||||
func (m *MockStore) HasPermissionToTeam(arg0, arg1 string) bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasPermissionToTeam", arg0, arg1)
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HasPermissionToTeam indicates an expected call of HasPermissionToTeam.
|
||||
func (mr *MockStoreMockRecorder) HasPermissionToTeam(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToTeam", reflect.TypeOf((*MockStore)(nil).HasPermissionToTeam), arg0, arg1)
|
||||
}
|
||||
|
||||
// HasWorkspaceAccess mocks base method.
|
||||
func (m *MockStore) HasWorkspaceAccess(arg0, arg1 string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -867,6 +941,21 @@ func (mr *MockStoreMockRecorder) IsErrNotFound(arg0 interface{}) *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsErrNotFound", reflect.TypeOf((*MockStore)(nil).IsErrNotFound), arg0)
|
||||
}
|
||||
|
||||
// IsUserGuest mocks base method.
|
||||
func (m *MockStore) IsUserGuest(arg0 string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsUserGuest", arg0)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// IsUserGuest indicates an expected call of IsUserGuest.
|
||||
func (mr *MockStoreMockRecorder) IsUserGuest(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUserGuest", reflect.TypeOf((*MockStore)(nil).IsUserGuest), arg0)
|
||||
}
|
||||
|
||||
// PatchBlock mocks base method.
|
||||
func (m *MockStore) PatchBlock(arg0 store.Container, arg1 string, arg2 *model.BlockPatch, arg3 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
177
server/services/store/sqlstore/board_insights.go
Normal file
177
server/services/store/sqlstore/board_insights.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
func (s *SQLStore) getTeamBoardsInsights(db sq.BaseRunner, channelIDs []string,
|
||||
since int64, offset int, limit int) (*model.BoardInsightsList, error) {
|
||||
/*
|
||||
Get top private, public boards, combine the list and filter the top 10. Note we can't limit 10 for subqueries.
|
||||
*/
|
||||
qb := s.getQueryBuilder(db)
|
||||
publicBoards := qb.Select(`blocks.id, blocks.title, blocks.workspace_id,
|
||||
count(blocks_history.id) as count, blocks_history.modified_by, blocks.created_by`).
|
||||
From(s.tablePrefix + "blocks_history as blocks_history").
|
||||
Join(s.tablePrefix + "blocks as blocks on blocks_history.root_id = blocks.id").
|
||||
Where(sq.Gt{"blocks_history.update_at": since}).
|
||||
Where(sq.Eq{"blocks_history.workspace_id": channelIDs}).
|
||||
Where(sq.NotEq{"blocks_history.modified_by": "system"}).
|
||||
Where(sq.Eq{"blocks.delete_at": 0}).
|
||||
GroupBy("blocks.title, blocks.workspace_id, blocks.created_by, blocks.id, blocks_history.modified_by")
|
||||
|
||||
insightsQuery := qb.Select(fmt.Sprintf("id, title, workspace_id, sum(count) as activity_count, %s as active_users, created_by",
|
||||
s.concatenationSelector("distinct modified_by", ","))).
|
||||
FromSelect(publicBoards, "boards_and_blocks_history").
|
||||
GroupBy("id, title, workspace_id, created_by").
|
||||
OrderBy("activity_count desc").
|
||||
Offset(uint64(offset)).
|
||||
Limit(uint64(limit + 1))
|
||||
|
||||
rows, err := insightsQuery.Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Debug(`Team insights query ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boardInsights, err := boardsInsightsFromRows(rows)
|
||||
if err != nil {
|
||||
s.logger.Debug(`Team insights parsing error`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
boardInsights, err = populateIcons(s, db, boardInsights)
|
||||
if err != nil {
|
||||
s.logger.Debug(`Team insights icon populate error`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardInsights, limit)
|
||||
|
||||
return boardInsightsPaginated, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getUserBoardsInsights(db sq.BaseRunner, userID string,
|
||||
channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error) {
|
||||
/**
|
||||
Get top 10 private, public boards, combine the list and filter the top 10
|
||||
*/
|
||||
qb := s.getQueryBuilder(db)
|
||||
publicBoards := qb.Select(`blocks.id, blocks.title, blocks.workspace_id,
|
||||
count(blocks_history.id) as count, blocks_history.modified_by, blocks.created_by`).
|
||||
From(s.tablePrefix + "blocks_history as blocks_history").
|
||||
Join(s.tablePrefix + "blocks as blocks on blocks_history.root_id = blocks.id").
|
||||
Where(sq.Gt{"blocks_history.update_at": since}).
|
||||
Where(sq.Eq{"blocks_history.workspace_id": channelIDs}).
|
||||
Where(sq.NotEq{"blocks_history.modified_by": "system"}).
|
||||
Where(sq.Eq{"blocks.delete_at": 0}).
|
||||
GroupBy("blocks.title, blocks.workspace_id, blocks.created_by, blocks.id, blocks_history.modified_by")
|
||||
|
||||
userInsightsQuery := qb.Select("*").FromSelect(qb.Select(
|
||||
fmt.Sprintf("id, title, workspace_id,sum(count) as activity_count, %s as active_users, created_by",
|
||||
s.concatenationSelector("distinct modified_by", ","))).
|
||||
FromSelect(publicBoards, "boards_and_blocks_history").
|
||||
GroupBy("id, title, workspace_id, created_by").
|
||||
OrderBy("activity_count desc"), "team_insights").
|
||||
Where(sq.Or{
|
||||
sq.Eq{
|
||||
"created_by": userID,
|
||||
},
|
||||
// due to lack of position operator, we have to hardcode arguments, and placeholder here
|
||||
sq.Expr(s.elementInColumn(5+len(channelIDs), "active_users")),
|
||||
}).
|
||||
Offset(uint64(offset)).
|
||||
Limit(uint64(limit + 1))
|
||||
userInsightsQueryStr, args, err := userInsightsQuery.ToSql()
|
||||
if err != nil {
|
||||
s.logger.Debug(`User insights query parsing ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// adding the 11th argument
|
||||
args = append(args, userID)
|
||||
|
||||
rows, err := db.Query(userInsightsQueryStr, args...)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Debug(`User insights query ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boardInsights, err := boardsInsightsFromRows(rows)
|
||||
if err != nil {
|
||||
s.logger.Debug(`User insights rows parsing error`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
boardInsights, err = populateIcons(s, db, boardInsights)
|
||||
if err != nil {
|
||||
s.logger.Debug(`User insights icon populate error`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardInsights, limit)
|
||||
|
||||
return boardInsightsPaginated, nil
|
||||
}
|
||||
|
||||
func boardsInsightsFromRows(rows *sql.Rows) ([]*model.BoardInsight, error) {
|
||||
boardsInsights := []*model.BoardInsight{}
|
||||
for rows.Next() {
|
||||
var boardInsight model.BoardInsight
|
||||
|
||||
err := rows.Scan(
|
||||
&boardInsight.BoardID,
|
||||
&boardInsight.Title,
|
||||
&boardInsight.WorkspaceID,
|
||||
&boardInsight.ActivityCount,
|
||||
&boardInsight.ActiveUsers,
|
||||
&boardInsight.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardsInsights = append(boardsInsights, &boardInsight)
|
||||
}
|
||||
return boardsInsights, nil
|
||||
}
|
||||
|
||||
func populateIcons(s *SQLStore, db sq.BaseRunner, boardsInsights []*model.BoardInsight) ([]*model.BoardInsight, error) {
|
||||
qb := s.getQueryBuilder(db)
|
||||
for _, boardInsight := range boardsInsights {
|
||||
// querying raw instead of calling store.GetBoardsFromSameID needs container, and this function has no context on channel ID
|
||||
// performance wise, this is better since 1) it's querying for only fields 2) it's querying for only one row.
|
||||
boardID := boardInsight.BoardID
|
||||
iconQuery := qb.Select("COALESCE(fields, '{}')").From(s.tablePrefix + "blocks").Where(sq.Eq{"id": boardID})
|
||||
iconQueryString, args, err := iconQuery.ToSql()
|
||||
if err != nil {
|
||||
s.logger.Error(`Query parsing error while getting icons`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
row := s.db.QueryRow(iconQueryString, args...)
|
||||
var fieldsJSON string
|
||||
var fields map[string]interface{}
|
||||
err = row.Scan(
|
||||
&fieldsJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(fieldsJSON), &fields)
|
||||
if err != nil {
|
||||
s.logger.Error(`ERROR unmarshalling populateIcons fields`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
boardInsight.Icon = fields["icon"].(string)
|
||||
}
|
||||
return boardsInsights, nil
|
||||
}
|
|
@ -20,6 +20,7 @@ import (
|
|||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
|
@ -252,11 +253,21 @@ func (s *SQLStore) GetSystemSettings() (map[string]string, error) {
|
|||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetTeamBoardsInsights(channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error) {
|
||||
return s.getTeamBoardsInsights(s.db, channelIDs, since, offset, limit)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUsedCardsCount() (int, error) {
|
||||
return s.getUsedCardsCount(s.db)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUserBoardsInsights(userID string, channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error) {
|
||||
return s.getUserBoardsInsights(s.db, userID, channelIDs, since, offset, limit)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUserByEmail(email string) (*model.User, error) {
|
||||
return s.getUserByEmail(s.db, email)
|
||||
|
||||
|
@ -272,11 +283,20 @@ func (s *SQLStore) GetUserByUsername(username string) (*model.User, error) {
|
|||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUserTimezone(userID string) (string, error) {
|
||||
return s.getUserTimezone(s.db, userID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUserWorkspaces(userID string) ([]model.UserWorkspace, error) {
|
||||
return s.getUserWorkspaces(s.db, userID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUserWorkspacesInTeam(userID string, teamID string) ([]model.UserWorkspace, error) {
|
||||
return s.getUserWorkspacesInTeam(s.db, userID, teamID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUsersByWorkspace(workspaceID string) ([]*model.User, error) {
|
||||
return s.getUsersByWorkspace(s.db, workspaceID)
|
||||
|
||||
|
@ -297,6 +317,11 @@ func (s *SQLStore) GetWorkspaceTeam(workspaceID string) (*mmModel.Team, error) {
|
|||
|
||||
}
|
||||
|
||||
func (s *SQLStore) HasPermissionToTeam(userID string, teamID string) bool {
|
||||
return s.hasPermissionToTeam(s.db, userID, teamID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) HasWorkspaceAccess(userID string, workspaceID string) (bool, error) {
|
||||
return s.hasWorkspaceAccess(s.db, userID, workspaceID)
|
||||
|
||||
|
@ -350,6 +375,11 @@ func (s *SQLStore) InsertBlocks(c store.Container, blocks []model.Block, userID
|
|||
|
||||
}
|
||||
|
||||
func (s *SQLStore) IsUserGuest(userID string) (bool, error) {
|
||||
return s.isUserGuest(s.db, userID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) PatchBlock(c store.Container, blockID string, blockPatch *model.BlockPatch, userID string) error {
|
||||
if s.dbType == sqliteDBType {
|
||||
return s.patchBlock(s.db, c, blockID, blockPatch, userID)
|
||||
|
@ -413,13 +443,14 @@ func (s *SQLStore) RemoveDefaultTemplates(blocks []model.Block) error {
|
|||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SendMessage(message string, postType string, receipts []string) error {
|
||||
return s.sendMessage(s.db, message, postType, receipts)
|
||||
}
|
||||
|
||||
//nolint:typecheck
|
||||
func (s *SQLStore) SaveFileInfo(fileInfo *mmModel.FileInfo) error {
|
||||
return s.saveFileInfo(s.db, fileInfo)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SendMessage(message string, postType string, receipts []string) error {
|
||||
return s.sendMessage(s.db, message, postType, receipts)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SetSystemSetting(key string, value string) error {
|
||||
|
|
|
@ -2,6 +2,7 @@ package sqlstore
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
|
@ -99,6 +100,39 @@ func (s *SQLStore) getLicense(db sq.BaseRunner) *mmModel.License {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) concatenationSelector(field string, delimiter string) string {
|
||||
if s.dbType == sqliteDBType {
|
||||
return fmt.Sprintf("group_concat(%s)", field)
|
||||
}
|
||||
if s.dbType == postgresDBType {
|
||||
return fmt.Sprintf("string_agg(%s, '%s')", field, delimiter)
|
||||
}
|
||||
if s.dbType == mysqlDBType {
|
||||
return fmt.Sprintf("GROUP_CONCAT(%s SEPARATOR '%s')", field, delimiter)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SQLStore) elementInColumn(parameterCount int, column string) string {
|
||||
if s.dbType == sqliteDBType || s.dbType == mysqlDBType {
|
||||
return fmt.Sprintf("instr(%s, %s) > 0", column, s.parameterPlaceholder(parameterCount))
|
||||
}
|
||||
if s.dbType == postgresDBType {
|
||||
return fmt.Sprintf("position(%s in %s) > 0", s.parameterPlaceholder(parameterCount), column)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SQLStore) parameterPlaceholder(count int) string {
|
||||
if s.dbType == postgresDBType || s.dbType == sqliteDBType {
|
||||
return fmt.Sprintf("$%v", count)
|
||||
}
|
||||
if s.dbType == mysqlDBType {
|
||||
return "?"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SQLStore) getCloudLimits(db sq.BaseRunner) (*mmModel.ProductLimits, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/store/storetests"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBlocksStore(t *testing.T) {
|
||||
|
@ -19,4 +20,53 @@ func TestBlocksStore(t *testing.T) {
|
|||
t.Run("SubscriptionStore", func(t *testing.T) { storetests.StoreTestSubscriptionsStore(t, SetupTests) })
|
||||
t.Run("NotificationHintStore", func(t *testing.T) { storetests.StoreTestNotificationHintsStore(t, SetupTests) })
|
||||
t.Run("CloudStore", func(t *testing.T) { storetests.StoreTestCloudStore(t, SetupTests) })
|
||||
t.Run("BoardsInsightsStore", func(t *testing.T) { storetests.StoreTestBoardsInsightsStore(t, SetupTests) })
|
||||
}
|
||||
|
||||
func TestConcatenationSelector(t *testing.T) {
|
||||
store, tearDown := SetupTests(t)
|
||||
sqlStore := store.(*SQLStore)
|
||||
defer tearDown()
|
||||
|
||||
concatenationString := sqlStore.concatenationSelector("a", ",")
|
||||
switch sqlStore.dbType {
|
||||
case sqliteDBType:
|
||||
require.Equal(t, concatenationString, "group_concat(a)")
|
||||
case mysqlDBType:
|
||||
require.Equal(t, concatenationString, "GROUP_CONCAT(a SEPARATOR ',')")
|
||||
case postgresDBType:
|
||||
require.Equal(t, concatenationString, "string_agg(a, ',')")
|
||||
}
|
||||
}
|
||||
|
||||
func TestElementInColumn(t *testing.T) {
|
||||
store, tearDown := SetupTests(t)
|
||||
sqlStore := store.(*SQLStore)
|
||||
defer tearDown()
|
||||
|
||||
inLiteral := sqlStore.elementInColumn(1, "test_column")
|
||||
switch sqlStore.dbType {
|
||||
case sqliteDBType:
|
||||
require.Equal(t, inLiteral, "instr(test_column, $1) > 0")
|
||||
case mysqlDBType:
|
||||
require.Equal(t, inLiteral, "instr(test_column, ?) > 0")
|
||||
case postgresDBType:
|
||||
require.Equal(t, inLiteral, "position($1 in test_column) > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParameterPlaceholder(t *testing.T) {
|
||||
store, tearDown := SetupTests(t)
|
||||
sqlStore := store.(*SQLStore)
|
||||
defer tearDown()
|
||||
|
||||
parameterPlaceholder := sqlStore.parameterPlaceholder(2)
|
||||
switch sqlStore.dbType {
|
||||
case sqliteDBType:
|
||||
require.Equal(t, parameterPlaceholder, "$2")
|
||||
case postgresDBType:
|
||||
require.Equal(t, parameterPlaceholder, "$2")
|
||||
case mysqlDBType:
|
||||
require.Equal(t, parameterPlaceholder, "?")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
|
@ -256,6 +257,22 @@ func (s *SQLStore) patchUserProps(db sq.BaseRunner, userID string, patch model.U
|
|||
return s.updateUser(db, user)
|
||||
}
|
||||
|
||||
func (s *SQLStore) hasPermissionToTeam(db sq.BaseRunner, userID string, teamID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *SQLStore) sendMessage(db sq.BaseRunner, message, postType string, receipts []string) error {
|
||||
return errUnsupportedOperation
|
||||
}
|
||||
|
||||
func (s *SQLStore) isUserGuest(_ sq.BaseRunner, userID string) (bool, error) {
|
||||
user, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.Contains(user.Roles, "guest"), nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getUserTimezone(_ sq.BaseRunner, userID string) (string, error) {
|
||||
return "", errUnsupportedOperation
|
||||
}
|
||||
|
|
|
@ -157,6 +157,10 @@ func (s *SQLStore) getUserWorkspaces(_ sq.BaseRunner, _ string) ([]model.UserWor
|
|||
return nil, fmt.Errorf("GetUserWorkspaces %w", errUnsupportedOperation)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getUserWorkspacesInTeam(_ sq.BaseRunner, _ string, _ string) ([]model.UserWorkspace, error) {
|
||||
return nil, fmt.Errorf("GetUserWorkspacesInTeam %w", errUnsupportedOperation)
|
||||
}
|
||||
|
||||
func (s *SQLStore) createPrivateWorkspace(_ sq.BaseRunner, _ string) (string, error) {
|
||||
// for personal server we always have only
|
||||
// a single workspace, with id "0".
|
||||
|
|
|
@ -68,6 +68,7 @@ type Store interface {
|
|||
UpdateUserPasswordByID(userID, password string) error
|
||||
GetUsersByWorkspace(workspaceID string) ([]*model.User, error)
|
||||
PatchUserProps(userID string, patch model.UserPropPatch) error
|
||||
HasPermissionToTeam(userID string, teamID string) bool
|
||||
|
||||
GetActiveUserCount(updatedSecondsAgo int64) (int, error)
|
||||
GetSession(token string, expireTime int64) (*model.Session, error)
|
||||
|
@ -86,6 +87,7 @@ type Store interface {
|
|||
HasWorkspaceAccess(userID string, workspaceID string) (bool, error)
|
||||
GetWorkspaceCount() (int64, error)
|
||||
GetUserWorkspaces(userID string) ([]model.UserWorkspace, error)
|
||||
GetUserWorkspacesInTeam(userID string, teamID string) ([]model.UserWorkspace, error)
|
||||
CreatePrivateWorkspace(userID string) (string, error)
|
||||
|
||||
CreateSubscription(c Container, sub *model.Subscription) (*model.Subscription, error)
|
||||
|
@ -117,7 +119,13 @@ type Store interface {
|
|||
GetFileInfo(id string) (*mmModel.FileInfo, error)
|
||||
SaveFileInfo(fileInfo *mmModel.FileInfo) error
|
||||
GetLicense() *mmModel.License
|
||||
|
||||
// Insights
|
||||
GetTeamBoardsInsights(channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error)
|
||||
GetUserBoardsInsights(userID string, channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error)
|
||||
IsUserGuest(userID string) (bool, error)
|
||||
GetCloudLimits() (*mmModel.ProductLimits, error)
|
||||
GetUserTimezone(userID string) (string, error)
|
||||
}
|
||||
|
||||
// ErrNotFound is an error type that can be returned by store APIs when a query unexpectedly fetches no records.
|
||||
|
|
129
server/services/store/storetests/board_insights.go
Normal file
129
server/services/store/storetests/board_insights.go
Normal file
|
@ -0,0 +1,129 @@
|
|||
package storetests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testInsightsUserID1 = "user-id-1"
|
||||
testInsightsUserID2 = "user-id-2"
|
||||
testInsightsChannelID1 = "channel-id-1"
|
||||
testInsightsChannelID2 = "channel-id-2"
|
||||
)
|
||||
|
||||
func StoreTestBoardsInsightsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) {
|
||||
container1 := store.Container{
|
||||
WorkspaceID: testInsightsChannelID1,
|
||||
}
|
||||
container2 := store.Container{
|
||||
WorkspaceID: testInsightsChannelID2,
|
||||
}
|
||||
t.Run("GetBoardsInsights", func(t *testing.T) {
|
||||
store, tearDown := setup(t)
|
||||
defer tearDown()
|
||||
getBoardsInsightsTest(t, store, container1, container2)
|
||||
})
|
||||
}
|
||||
|
||||
func getBoardsInsightsTest(t *testing.T, store store.Store, container1 store.Container, container2 store.Container) {
|
||||
// creating sample data
|
||||
userID := testUserID
|
||||
block1 := model.Block{
|
||||
ID: "insights-id-1",
|
||||
RootID: "insights-id-1",
|
||||
ModifiedBy: userID,
|
||||
WorkspaceID: testInsightsChannelID1,
|
||||
Fields: map[string]interface{}{"icon": "💬"},
|
||||
}
|
||||
|
||||
block2 := model.Block{
|
||||
ID: "insights-id-2",
|
||||
RootID: "insights-id-2",
|
||||
ModifiedBy: userID,
|
||||
WorkspaceID: testInsightsChannelID2,
|
||||
Fields: map[string]interface{}{"icon": "💬"},
|
||||
}
|
||||
|
||||
blockMember1 := model.Block{
|
||||
ID: "insights-id-3",
|
||||
RootID: "insights-id-1",
|
||||
Title: "Old Title 1",
|
||||
WorkspaceID: testInsightsChannelID1,
|
||||
}
|
||||
blockMember2 := model.Block{
|
||||
ID: "insights-id-4",
|
||||
RootID: "insights-id-1",
|
||||
Title: "Old Title 2",
|
||||
WorkspaceID: testInsightsChannelID1,
|
||||
}
|
||||
|
||||
blockMember3 := model.Block{
|
||||
ID: "insights-id-5",
|
||||
RootID: "insights-id-2",
|
||||
Title: "Old Title 1",
|
||||
WorkspaceID: testInsightsChannelID2,
|
||||
}
|
||||
blockMember4 := model.Block{
|
||||
ID: "insights-id-6",
|
||||
RootID: "insights-id-2",
|
||||
Title: "Old Title 2",
|
||||
WorkspaceID: testInsightsChannelID2,
|
||||
}
|
||||
|
||||
// container 1
|
||||
newBlocks1 := []model.Block{blockMember1, blockMember2}
|
||||
err := store.InsertBlock(container1, &block1, testInsightsUserID1)
|
||||
require.NoError(t, err)
|
||||
err = store.InsertBlocks(container1, newBlocks1, testInsightsUserID1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// container 2
|
||||
newBlocks2 := []model.Block{blockMember3, blockMember4}
|
||||
err = store.InsertBlock(container2, &block2, testInsightsUserID2)
|
||||
require.NoError(t, err)
|
||||
err = store.InsertBlocks(container2, newBlocks2, testInsightsUserID2)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, err)
|
||||
blocks1, err := store.GetAllBlocks(container1)
|
||||
require.NoError(t, err)
|
||||
blocks2, err := store.GetAllBlocks(container2)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("team insights", func(t *testing.T) {
|
||||
topTeamBoards, err := store.GetTeamBoardsInsights([]string{testInsightsChannelID1, testInsightsChannelID2},
|
||||
0, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, topTeamBoards.Items, 2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blocks1, 3)
|
||||
require.Len(t, blocks2, 3)
|
||||
|
||||
// validate board insight content
|
||||
require.Equal(t, topTeamBoards.Items[0].ActivityCount, 3)
|
||||
require.Equal(t, topTeamBoards.Items[1].ActivityCount, 3)
|
||||
|
||||
require.Equal(t, topTeamBoards.Items[0].BoardID, block1.ID)
|
||||
require.Equal(t, topTeamBoards.Items[1].BoardID, block2.ID)
|
||||
})
|
||||
|
||||
t.Run("user insights", func(t *testing.T) {
|
||||
topUser1Boards, err := store.GetUserBoardsInsights(testInsightsUserID1,
|
||||
[]string{testInsightsChannelID1, testInsightsChannelID2}, 0, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, topUser1Boards.Items, 1)
|
||||
require.Equal(t, topUser1Boards.Items[0].Icon, "💬")
|
||||
require.Equal(t, topUser1Boards.Items[0].BoardID, block1.ID)
|
||||
|
||||
topUser2Boards, err := store.GetUserBoardsInsights(testInsightsUserID2,
|
||||
[]string{testInsightsChannelID1, testInsightsChannelID2}, 0, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, topUser2Boards.Items, 1)
|
||||
require.Equal(t, topUser2Boards.Items[0].Icon, "💬")
|
||||
require.Equal(t, topUser2Boards.Items[0].BoardID, block2.ID)
|
||||
})
|
||||
}
|
16
webapp/src/insights/index.ts
Normal file
16
webapp/src/insights/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export type TopBoard = {
|
||||
boardID: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
activityCount: number;
|
||||
activeUsers: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type TopBoardResponse = {
|
||||
has_next: boolean;
|
||||
items: TopBoard[];
|
||||
}
|
|
@ -11,6 +11,7 @@ import {UserSettings} from './userSettings'
|
|||
import {Subscription} from './wsclient'
|
||||
import {PrepareOnboardingResponse} from './onboardingTour'
|
||||
import {BoardsCloudLimits} from './boardsCloudLimits'
|
||||
import {TopBoardResponse} from './insights'
|
||||
|
||||
//
|
||||
// OctoClient is the client interface to the server APIs
|
||||
|
@ -536,6 +537,27 @@ class OctoClient {
|
|||
Utils.log(`Cloud limits: cards=${limits.cards} views=${limits.views}`)
|
||||
return limits
|
||||
}
|
||||
|
||||
// insights
|
||||
async getMyTopBoards(timeRange: string, page: number, perPage: number, teamId: string): Promise<TopBoardResponse | undefined> {
|
||||
const path = `/api/v1/users/me/boards/insights?time_range=${timeRange}&page=${page}&per_page=${perPage}&team_id=${teamId}`
|
||||
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
|
||||
if (response.status !== 200) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (await this.getJson(response, {})) as TopBoardResponse
|
||||
}
|
||||
|
||||
async getTeamTopBoards(timeRange: string, page: number, perPage: number, teamId: string): Promise<TopBoardResponse | undefined> {
|
||||
const path = `/api/v1/teams/${teamId}/boards/insights?time_range=${timeRange}&page=${page}&per_page=${perPage}`
|
||||
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
|
||||
if (response.status !== 200) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (await this.getJson(response, {})) as TopBoardResponse
|
||||
}
|
||||
}
|
||||
|
||||
const octoClient = new OctoClient()
|
||||
|
|
Loading…
Reference in a new issue