Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
8
server/boards/admin-scripts/reset-password.sh
Исполняемый файл
8
server/boards/admin-scripts/reset-password.sh
Исполняемый файл
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ $# < 2 ]] ; then
|
||||
echo 'reset-password.sh <username> <new password>'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --unix-socket /var/tmp/focalboard_local.socket http://localhost/api/v2/admin/users/$1/password -X POST -H 'Content-Type: application/json' -d '{ "password": "'$2'" }'
|
||||
60
server/boards/api/admin.go
Обычный файл
60
server/boards/api/admin.go
Обычный файл
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type AdminSetPasswordData struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *API) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
username := vars["username"]
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var requestData AdminSetPasswordData
|
||||
err = json.Unmarshal(requestBody, &requestData)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "adminSetPassword", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelAuth, auditRec)
|
||||
auditRec.AddMeta("username", username)
|
||||
|
||||
if !strings.Contains(requestData.Password, "") {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("password is required"))
|
||||
return
|
||||
}
|
||||
|
||||
err = a.app.UpdateUserPassword(username, requestData.Password)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("AdminSetPassword, username: %s", mlog.String("username", username))
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
246
server/boards/api/api.go
Обычный файл
246
server/boards/api/api.go
Обычный файл
@@ -0,0 +1,246 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/app"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderRequestedWith = "X-Requested-With"
|
||||
HeaderRequestedWithXML = "XMLHttpRequest"
|
||||
UploadFormFileKey = "file"
|
||||
True = "true"
|
||||
|
||||
ErrorNoTeamCode = 1000
|
||||
ErrorNoTeamMessage = "No team"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHandlerPanic = errors.New("http handler panic")
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// REST APIs
|
||||
|
||||
type API struct {
|
||||
app *app.App
|
||||
authService string
|
||||
permissions permissions.PermissionsService
|
||||
singleUserToken string
|
||||
MattermostAuth bool
|
||||
logger mlog.LoggerIFace
|
||||
audit *audit.Audit
|
||||
isPlugin bool
|
||||
}
|
||||
|
||||
func NewAPI(
|
||||
app *app.App,
|
||||
singleUserToken string,
|
||||
authService string,
|
||||
permissions permissions.PermissionsService,
|
||||
logger mlog.LoggerIFace,
|
||||
audit *audit.Audit,
|
||||
isPlugin bool,
|
||||
) *API {
|
||||
return &API{
|
||||
app: app,
|
||||
singleUserToken: singleUserToken,
|
||||
authService: authService,
|
||||
permissions: permissions,
|
||||
logger: logger,
|
||||
audit: audit,
|
||||
isPlugin: isPlugin,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
apiv2 := r.PathPrefix("/api/v2").Subrouter()
|
||||
apiv2.Use(a.panicHandler)
|
||||
apiv2.Use(a.requireCSRFToken)
|
||||
|
||||
/* ToDo:
|
||||
apiv3 := r.PathPrefix("/api/v3").Subrouter()
|
||||
apiv3.Use(a.panicHandler)
|
||||
apiv3.Use(a.requireCSRFToken)
|
||||
*/
|
||||
|
||||
// V2 routes (ToDo: migrate these to V3 when ready to ship V3)
|
||||
a.registerUsersRoutes(apiv2)
|
||||
a.registerAuthRoutes(apiv2)
|
||||
a.registerMembersRoutes(apiv2)
|
||||
a.registerCategoriesRoutes(apiv2)
|
||||
a.registerSharingRoutes(apiv2)
|
||||
a.registerTeamsRoutes(apiv2)
|
||||
a.registerAchivesRoutes(apiv2)
|
||||
a.registerSubscriptionsRoutes(apiv2)
|
||||
a.registerFilesRoutes(apiv2)
|
||||
a.registerLimitsRoutes(apiv2)
|
||||
a.registerInsightsRoutes(apiv2)
|
||||
a.registerOnboardingRoutes(apiv2)
|
||||
a.registerSearchRoutes(apiv2)
|
||||
a.registerConfigRoutes(apiv2)
|
||||
a.registerBoardsAndBlocksRoutes(apiv2)
|
||||
a.registerChannelsRoutes(apiv2)
|
||||
a.registerTemplatesRoutes(apiv2)
|
||||
a.registerBoardsRoutes(apiv2)
|
||||
a.registerBlocksRoutes(apiv2)
|
||||
a.registerContentBlocksRoutes(apiv2)
|
||||
a.registerStatisticsRoutes(apiv2)
|
||||
a.registerComplianceRoutes(apiv2)
|
||||
|
||||
// V3 routes
|
||||
a.registerCardsRoutes(apiv2)
|
||||
|
||||
// System routes are outside the /api/v2 path
|
||||
a.registerSystemRoutes(r)
|
||||
}
|
||||
|
||||
func (a *API) RegisterAdminRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v2/admin/users/{username}/password", a.adminRequired(a.handleAdminSetPassword)).Methods("POST")
|
||||
}
|
||||
|
||||
func getUserID(r *http.Request) string {
|
||||
ctx := r.Context()
|
||||
session, ok := ctx.Value(sessionContextKey).(*model.Session)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return session.UserID
|
||||
}
|
||||
|
||||
func (a *API) panicHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
a.logger.Error("Http handler panic",
|
||||
mlog.Any("panic", p),
|
||||
mlog.String("stack", string(debug.Stack())),
|
||||
mlog.String("uri", r.URL.Path),
|
||||
)
|
||||
a.errorResponse(w, r, ErrHandlerPanic)
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) requireCSRFToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.checkCSRFToken(r) {
|
||||
a.logger.Error("checkCSRFToken FAILED")
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("checkCSRFToken FAILED"))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) checkCSRFToken(r *http.Request) bool {
|
||||
token := r.Header.Get(HeaderRequestedWith)
|
||||
return token == HeaderRequestedWithXML
|
||||
}
|
||||
|
||||
func (a *API) hasValidReadTokenForBoard(r *http.Request, boardID string) bool {
|
||||
query := r.URL.Query()
|
||||
readToken := query.Get("read_token")
|
||||
|
||||
if len(readToken) < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
isValid, err := a.app.IsValidReadToken(boardID, readToken)
|
||||
if err != nil {
|
||||
a.logger.Error("IsValidReadTokenForBoard ERROR", mlog.Err(err))
|
||||
return false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
func (a *API) userIsGuest(userID string) (bool, error) {
|
||||
if a.singleUserToken != "" {
|
||||
return false, nil
|
||||
}
|
||||
return a.app.UserIsGuest(userID)
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
func (a *API) errorResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
a.logger.Error(err.Error())
|
||||
errorResponse := model.ErrorResponse{Error: err.Error()}
|
||||
|
||||
switch {
|
||||
case model.IsErrBadRequest(err):
|
||||
errorResponse.ErrorCode = http.StatusBadRequest
|
||||
case model.IsErrUnauthorized(err):
|
||||
errorResponse.ErrorCode = http.StatusUnauthorized
|
||||
case model.IsErrForbidden(err):
|
||||
errorResponse.ErrorCode = http.StatusForbidden
|
||||
case model.IsErrNotFound(err):
|
||||
errorResponse.ErrorCode = http.StatusNotFound
|
||||
case model.IsErrRequestEntityTooLarge(err):
|
||||
errorResponse.ErrorCode = http.StatusRequestEntityTooLarge
|
||||
case model.IsErrNotImplemented(err):
|
||||
errorResponse.ErrorCode = http.StatusNotImplemented
|
||||
default:
|
||||
a.logger.Error("API ERROR",
|
||||
mlog.Int("code", http.StatusInternalServerError),
|
||||
mlog.Err(err),
|
||||
mlog.String("api", r.URL.Path),
|
||||
)
|
||||
errorResponse.Error = "internal server error"
|
||||
errorResponse.ErrorCode = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
setResponseHeader(w, "Content-Type", "application/json")
|
||||
data, err := json.Marshal(errorResponse)
|
||||
if err != nil {
|
||||
data = []byte("{}")
|
||||
}
|
||||
|
||||
w.WriteHeader(errorResponse.ErrorCode)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func stringResponse(w http.ResponseWriter, message string) {
|
||||
setResponseHeader(w, "Content-Type", "text/plain")
|
||||
_, _ = fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func jsonStringResponse(w http.ResponseWriter, code int, message string) { //nolint:unparam
|
||||
setResponseHeader(w, "Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) { //nolint:unparam
|
||||
setResponseHeader(w, "Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(json)
|
||||
}
|
||||
|
||||
func setResponseHeader(w http.ResponseWriter, key string, value string) { //nolint:unparam
|
||||
header := w.Header()
|
||||
if header == nil {
|
||||
return
|
||||
}
|
||||
header.Set(key, value)
|
||||
}
|
||||
79
server/boards/api/api_test.go
Обычный файл
79
server/boards/api/api_test.go
Обычный файл
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func TestErrorResponse(t *testing.T) {
|
||||
testAPI := API{logger: mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)}
|
||||
|
||||
testCases := []struct {
|
||||
Name string
|
||||
Error error
|
||||
ResponseCode int
|
||||
ResponseBody string
|
||||
}{
|
||||
// bad request
|
||||
{"ErrBadRequest", model.NewErrBadRequest("bad field"), http.StatusBadRequest, "bad field"},
|
||||
{"ErrViewsLimitReached", model.ErrViewsLimitReached, http.StatusBadRequest, "limit reached"},
|
||||
{"ErrAuthParam", model.NewErrAuthParam("password is required"), http.StatusBadRequest, "password is required"},
|
||||
{"ErrInvalidCategory", model.NewErrInvalidCategory("open"), http.StatusBadRequest, "open"},
|
||||
{"ErrBoardMemberIsLastAdmin", model.ErrBoardMemberIsLastAdmin, http.StatusBadRequest, "no admins"},
|
||||
{"ErrBoardIDMismatch", model.ErrBoardIDMismatch, http.StatusBadRequest, "Board IDs do not match"},
|
||||
|
||||
// unauthorized
|
||||
{"ErrUnauthorized", model.NewErrUnauthorized("not enough permissions"), http.StatusUnauthorized, "not enough permissions"},
|
||||
|
||||
// forbidden
|
||||
{"ErrForbidden", model.NewErrForbidden("not enough permissions"), http.StatusForbidden, "not enough permissions"},
|
||||
{"ErrPermission", model.NewErrPermission("not enough permissions"), http.StatusForbidden, "not enough permissions"},
|
||||
{"ErrPatchUpdatesLimitedCards", model.ErrPatchUpdatesLimitedCards, http.StatusForbidden, "cards that are limited"},
|
||||
{"ErrCategoryPermissionDenied", model.ErrCategoryPermissionDenied, http.StatusForbidden, "doesn't belong to user"},
|
||||
|
||||
// not found
|
||||
{"ErrNotFound", model.NewErrNotFound("board"), http.StatusNotFound, "board"},
|
||||
{"ErrNotAllFound", model.NewErrNotAllFound("block", []string{"1", "2"}), http.StatusNotFound, "not all instances of {block} in {1, 2} found"},
|
||||
{"sql.ErrNoRows", sql.ErrNoRows, http.StatusNotFound, "rows"},
|
||||
{"ErrNotFound", model.ErrCategoryDeleted, http.StatusNotFound, "category is deleted"},
|
||||
|
||||
// request entity too large
|
||||
{"ErrRequestEntityTooLarge", model.ErrRequestEntityTooLarge, http.StatusRequestEntityTooLarge, "entity too large"},
|
||||
|
||||
// not implemented
|
||||
{"ErrNotFound", model.ErrInsufficientLicense, http.StatusNotImplemented, "appropriate license required"},
|
||||
{"ErrNotImplemented", model.NewErrNotImplemented("not implemented in plugin mode"), http.StatusNotImplemented, "plugin mode"},
|
||||
|
||||
// internal server error
|
||||
{"Any other error", ErrHandlerPanic, http.StatusInternalServerError, "internal server error"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%s should be a %d code", tc.Name, tc.ResponseCode), func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
testAPI.errorResponse(w, r, tc.Error)
|
||||
res := w.Result()
|
||||
|
||||
require.Equal(t, tc.ResponseCode, res.StatusCode)
|
||||
require.Equal(t, "application/json", res.Header.Get("Content-Type"))
|
||||
b, rErr := io.ReadAll(res.Body)
|
||||
require.NoError(t, rErr)
|
||||
res.Body.Close()
|
||||
require.Contains(t, string(b), tc.ResponseBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
258
server/boards/api/archive.go
Обычный файл
258
server/boards/api/archive.go
Обычный файл
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
archiveExtension = ".boardarchive"
|
||||
)
|
||||
|
||||
func (a *API) registerAchivesRoutes(r *mux.Router) {
|
||||
// Archive APIs
|
||||
r.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST")
|
||||
r.HandleFunc("/teams/{teamID}/archive/export", a.sessionRequired(a.handleArchiveExportTeam)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleArchiveExportBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/archive/export archiveExportBoard
|
||||
//
|
||||
// Exports an archive of all blocks for one boards.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Id of board to export
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// content:
|
||||
// application-octet-stream:
|
||||
// type: string
|
||||
// format: binary
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
// check user has permission to board
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
// if this user has `manage_system` permission and there is a license with the compliance
|
||||
// feature enabled, then we will allow the export.
|
||||
license := a.app.GetLicense()
|
||||
if !a.permissions.HasPermissionTo(userID, mm_model.PermissionManageSystem) || license == nil || !(*license.Features.Compliance) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "archiveExportBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("BoardID", boardID)
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.ExportArchiveOptions{
|
||||
TeamID: board.TeamID,
|
||||
BoardIDs: []string{board.ID},
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("archive-%s%s", time.Now().Format("2006-01-02"), archiveExtension)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||
w.Header().Set("Content-Transfer-Encoding", "binary")
|
||||
|
||||
if err := a.app.ExportArchive(w, opts); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleArchiveImport(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/archive/import archiveImport
|
||||
//
|
||||
// Import an archive of boards.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// consumes:
|
||||
// - multipart/form-data
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: file
|
||||
// in: formData
|
||||
// description: archive file to import
|
||||
// required: true
|
||||
// type: file
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
ctx := r.Context()
|
||||
session, _ := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create board"))
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create board"))
|
||||
return
|
||||
}
|
||||
|
||||
file, handle, err := r.FormFile(UploadFormFileKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "import", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("filename", handle.Filename)
|
||||
auditRec.AddMeta("size", handle.Size)
|
||||
|
||||
opt := model.ImportArchiveOptions{
|
||||
TeamID: teamID,
|
||||
ModifiedBy: userID,
|
||||
}
|
||||
|
||||
if err := a.app.ImportArchive(file, opt); err != nil {
|
||||
a.logger.Debug("Error importing archive",
|
||||
mlog.String("team_id", teamID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleArchiveExportTeam(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/archive/export archiveExportTeam
|
||||
//
|
||||
// Exports an archive of all blocks for all the boards in a team.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Id of team
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// content:
|
||||
// application-octet-stream:
|
||||
// type: string
|
||||
// format: binary
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
if a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode"))
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
ctx := r.Context()
|
||||
session, _ := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "archiveExportTeam", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("TeamID", teamID)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
ids := []string{}
|
||||
for _, board := range boards {
|
||||
ids = append(ids, board.ID)
|
||||
}
|
||||
|
||||
opts := model.ExportArchiveOptions{
|
||||
TeamID: teamID,
|
||||
BoardIDs: ids,
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("archive-%s%s", time.Now().Format("2006-01-02"), archiveExtension)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||
w.Header().Set("Content-Transfer-Encoding", "binary")
|
||||
|
||||
if err := a.app.ExportArchive(w, opts); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
36
server/boards/api/audit.go
Обычный файл
36
server/boards/api/audit.go
Обычный файл
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
)
|
||||
|
||||
// makeAuditRecord creates an audit record pre-populated with data from the request.
|
||||
func (a *API) makeAuditRecord(r *http.Request, event string, initialStatus string) *audit.Record { //nolint:unparam
|
||||
ctx := r.Context()
|
||||
var sessionID string
|
||||
var userID string
|
||||
if session, ok := ctx.Value(sessionContextKey).(*model.Session); ok {
|
||||
sessionID = session.ID
|
||||
userID = session.UserID
|
||||
}
|
||||
|
||||
teamID := "unknown"
|
||||
rec := &audit.Record{
|
||||
APIPath: r.URL.Path,
|
||||
Event: event,
|
||||
Status: initialStatus,
|
||||
UserID: userID,
|
||||
SessionID: sessionID,
|
||||
Client: r.UserAgent(),
|
||||
IPAddress: r.RemoteAddr,
|
||||
Meta: []audit.Meta{{K: audit.KeyTeamID, V: teamID}},
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
416
server/boards/api/auth.go
Обычный файл
416
server/boards/api/auth.go
Обычный файл
@@ -0,0 +1,416 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerAuthRoutes(r *mux.Router) {
|
||||
// personal-server specific routes. These are not needed in plugin mode.
|
||||
if !a.isPlugin {
|
||||
r.HandleFunc("/login", a.handleLogin).Methods("POST")
|
||||
r.HandleFunc("/logout", a.sessionRequired(a.handleLogout)).Methods("POST")
|
||||
r.HandleFunc("/register", a.handleRegister).Methods("POST")
|
||||
r.HandleFunc("/teams/{teamID}/regenerate_signup_token", a.sessionRequired(a.handlePostTeamRegenerateSignupToken)).Methods("POST")
|
||||
r.HandleFunc("/users/{userID}/changepassword", a.sessionRequired(a.handleChangePassword)).Methods("POST")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /login login
|
||||
//
|
||||
// Login user
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// description: Login request
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/LoginRequest"
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/LoginResponse"
|
||||
// '401':
|
||||
// description: invalid login
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
// '500':
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
if a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode"))
|
||||
return
|
||||
}
|
||||
|
||||
if a.singleUserToken != "" {
|
||||
// Not permitted in single-user mode
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode"))
|
||||
return
|
||||
}
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var loginData model.LoginRequest
|
||||
err = json.Unmarshal(requestBody, &loginData)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "login", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelAuth, auditRec)
|
||||
auditRec.AddMeta("username", loginData.Username)
|
||||
auditRec.AddMeta("type", loginData.Type)
|
||||
|
||||
if loginData.Type == "normal" {
|
||||
token, err := a.app.Login(loginData.Username, loginData.Email, loginData.Password, loginData.MfaToken)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("incorrect login"))
|
||||
return
|
||||
}
|
||||
json, err := json.Marshal(model.LoginResponse{Token: token})
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
auditRec.Success()
|
||||
return
|
||||
}
|
||||
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("invalid login type"))
|
||||
}
|
||||
|
||||
func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /logout logout
|
||||
//
|
||||
// Logout user
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '500':
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
if a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode"))
|
||||
return
|
||||
}
|
||||
|
||||
if a.singleUserToken != "" {
|
||||
// Not permitted in single-user mode
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "logout", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelAuth, auditRec)
|
||||
auditRec.AddMeta("userID", session.UserID)
|
||||
|
||||
if err := a.app.Logout(session.ID); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("incorrect logout"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.AddMeta("sessionID", session.ID)
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /register register
|
||||
//
|
||||
// Register new user
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// description: Register request
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/RegisterRequest"
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '401':
|
||||
// description: invalid registration token
|
||||
// '500':
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
if a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode"))
|
||||
return
|
||||
}
|
||||
|
||||
if a.singleUserToken != "" {
|
||||
// Not permitted in single-user mode
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode"))
|
||||
return
|
||||
}
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var registerData model.RegisterRequest
|
||||
err = json.Unmarshal(requestBody, ®isterData)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
registerData.Email = strings.TrimSpace(registerData.Email)
|
||||
registerData.Username = strings.TrimSpace(registerData.Username)
|
||||
|
||||
// Validate token
|
||||
if registerData.Token != "" {
|
||||
team, err2 := a.app.GetRootTeam()
|
||||
if err2 != nil {
|
||||
a.errorResponse(w, r, err2)
|
||||
return
|
||||
}
|
||||
|
||||
if registerData.Token != team.SignupToken {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("invalid token"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// No signup token, check if no active users
|
||||
userCount, err2 := a.app.GetRegisteredUserCount()
|
||||
if err2 != nil {
|
||||
a.errorResponse(w, r, err2)
|
||||
return
|
||||
}
|
||||
if userCount > 0 {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("no sign-up token and user(s) already exist"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = registerData.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "register", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelAuth, auditRec)
|
||||
auditRec.AddMeta("username", registerData.Username)
|
||||
|
||||
err = a.app.RegisterUser(registerData.Username, registerData.Email, registerData.Password)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /users/{userID}/changepassword changePassword
|
||||
//
|
||||
// Change a user's password
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: userID
|
||||
// in: path
|
||||
// description: User ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: body
|
||||
// in: body
|
||||
// description: Change password request
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ChangePasswordRequest"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '400':
|
||||
// description: invalid request
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
// '500':
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
if a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode"))
|
||||
return
|
||||
}
|
||||
|
||||
if a.singleUserToken != "" {
|
||||
// Not permitted in single-user mode
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("not permitted in single-user mode"))
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["userID"]
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var requestData model.ChangePasswordRequest
|
||||
if err = json.Unmarshal(requestBody, &requestData); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = requestData.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "changePassword", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelAuth, auditRec)
|
||||
|
||||
if err = a.app.ChangePassword(userID, requestData.OldPassword, requestData.NewPassword); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) sessionRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
||||
return a.attachSession(handler, true)
|
||||
}
|
||||
|
||||
func (a *API) attachSession(handler func(w http.ResponseWriter, r *http.Request), required bool) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token, _ := auth.ParseAuthTokenFromRequest(r)
|
||||
|
||||
a.logger.Debug(`attachSession`, mlog.Bool("single_user", a.singleUserToken != ""))
|
||||
if a.singleUserToken != "" {
|
||||
if required && (token != a.singleUserToken) {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("invalid single user token"))
|
||||
return
|
||||
}
|
||||
|
||||
now := utils.GetMillis()
|
||||
session := &model.Session{
|
||||
ID: model.SingleUser,
|
||||
Token: token,
|
||||
UserID: model.SingleUser,
|
||||
AuthService: a.authService,
|
||||
Props: map[string]interface{}{},
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
||||
handler(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
if a.MattermostAuth && r.Header.Get("Mattermost-User-Id") != "" {
|
||||
userID := r.Header.Get("Mattermost-User-Id")
|
||||
now := utils.GetMillis()
|
||||
session := &model.Session{
|
||||
ID: userID,
|
||||
Token: userID,
|
||||
UserID: userID,
|
||||
AuthService: a.authService,
|
||||
Props: map[string]interface{}{},
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
||||
handler(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
session, err := a.app.GetSession(token)
|
||||
if err != nil {
|
||||
if required {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authService := session.AuthService
|
||||
if authService != a.authService {
|
||||
msg := `Session authService mismatch`
|
||||
a.logger.Error(msg,
|
||||
mlog.String("sessionID", session.ID),
|
||||
mlog.String("want", a.authService),
|
||||
mlog.String("got", authService),
|
||||
)
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized(msg))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
||||
handler(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Currently, admin APIs require local unix connections
|
||||
conn := GetContextConn(r)
|
||||
if _, isUnix := conn.(*net.UnixConn); !isUnix {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("not a local unix connection"))
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
788
server/boards/api/blocks.go
Обычный файл
788
server/boards/api/blocks.go
Обычный файл
@@ -0,0 +1,788 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerBlocksRoutes(r *mux.Router) {
|
||||
// Blocks APIs
|
||||
r.HandleFunc("/boards/{boardID}/blocks", a.attachSession(a.handleGetBlocks, false)).Methods("GET")
|
||||
r.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePostBlocks)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePatchBlocks)).Methods("PATCH")
|
||||
r.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handleDeleteBlock)).Methods("DELETE")
|
||||
r.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handlePatchBlock)).Methods("PATCH")
|
||||
r.HandleFunc("/boards/{boardID}/blocks/{blockID}/undelete", a.sessionRequired(a.handleUndeleteBlock)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/blocks/{blockID}/duplicate", a.sessionRequired(a.handleDuplicateBlock)).Methods("POST")
|
||||
}
|
||||
|
||||
func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/blocks getBlocks
|
||||
//
|
||||
// Returns blocks
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: parent_id
|
||||
// in: query
|
||||
// description: ID of parent block, omit to specify all blocks
|
||||
// required: false
|
||||
// type: string
|
||||
// - name: type
|
||||
// in: query
|
||||
// description: Type of blocks to return, omit to specify all types
|
||||
// required: false
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Block"
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
query := r.URL.Query()
|
||||
parentID := query.Get("parent_id")
|
||||
blockType := query.Get("type")
|
||||
all := query.Get("all")
|
||||
blockID := query.Get("block_id")
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID)
|
||||
if userID == "" && !hasValidReadToken {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasValidReadToken {
|
||||
if board.IsTemplate && board.Type == model.BoardTypeOpen {
|
||||
if board.TeamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board template"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if board.IsTemplate {
|
||||
var isGuest bool
|
||||
isGuest, err = a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("guest are not allowed to get board templates"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("parentID", parentID)
|
||||
auditRec.AddMeta("blockType", blockType)
|
||||
auditRec.AddMeta("all", all)
|
||||
auditRec.AddMeta("blockID", blockID)
|
||||
|
||||
var blocks []*model.Block
|
||||
var block *model.Block
|
||||
switch {
|
||||
case all != "":
|
||||
blocks, err = a.app.GetBlocksForBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
case blockID != "":
|
||||
block, err = a.app.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if block.BoardID != boardID {
|
||||
message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, boardID)
|
||||
a.errorResponse(w, r, model.NewErrNotFound(message))
|
||||
return
|
||||
}
|
||||
|
||||
blocks = append(blocks, block)
|
||||
default:
|
||||
blocks, err = a.app.GetBlocks(boardID, parentID, blockType)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Debug("GetBlocks",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("parentID", parentID),
|
||||
mlog.String("blockType", blockType),
|
||||
mlog.String("blockID", blockID),
|
||||
mlog.Int("block_count", len(blocks)),
|
||||
)
|
||||
|
||||
var bErr error
|
||||
blocks, bErr = a.app.ApplyCloudLimits(blocks)
|
||||
if bErr != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
|
||||
auditRec.AddMeta("blockCount", len(blocks))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/blocks updateBlocks
|
||||
//
|
||||
// Insert blocks. The specified IDs will only be used to link
|
||||
// blocks with existing ones, the rest will be replaced by server
|
||||
// generated IDs
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: disable_notify
|
||||
// in: query
|
||||
// description: Disables notifications (for bulk inserting)
|
||||
// required: false
|
||||
// type: bool
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: array of blocks to insert or update
|
||||
// required: true
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Block"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// items:
|
||||
// $ref: '#/definitions/Block'
|
||||
// type: array
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
val := r.URL.Query().Get("disable_notify")
|
||||
disableNotify := val == True
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var blocks []*model.Block
|
||||
|
||||
err = json.Unmarshal(requestBody, &blocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
hasComments := false
|
||||
hasContents := false
|
||||
for _, block := range blocks {
|
||||
// Error checking
|
||||
if len(block.Type) < 1 {
|
||||
message := fmt.Sprintf("missing type for block id %s", block.ID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if block.Type == model.TypeComment {
|
||||
hasComments = true
|
||||
} else {
|
||||
hasContents = true
|
||||
}
|
||||
|
||||
if block.CreateAt < 1 {
|
||||
message := fmt.Sprintf("invalid createAt for block id %s", block.ID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if block.UpdateAt < 1 {
|
||||
message := fmt.Sprintf("invalid UpdateAt for block id %s", block.ID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if block.BoardID != boardID {
|
||||
message := fmt.Sprintf("invalid BoardID for block id %s", block.ID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if hasContents {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasComments {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionCommentBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to post card comments"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
blocks = model.GenerateBlockIDs(blocks, a.logger)
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("disable_notify", disableNotify)
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
model.StampModificationMetadata(userID, blocks, auditRec)
|
||||
|
||||
// this query param exists when creating template from board, or board from template
|
||||
sourceBoardID := r.URL.Query().Get("sourceBoardID")
|
||||
if sourceBoardID != "" {
|
||||
if updateFileIDsErr := a.app.CopyCardFiles(sourceBoardID, blocks); updateFileIDsErr != nil {
|
||||
a.errorResponse(w, r, updateFileIDsErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newBlocks, err := a.app.InsertBlocksAndNotify(blocks, session.UserID, disableNotify)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("POST Blocks",
|
||||
mlog.Int("block_count", len(blocks)),
|
||||
mlog.Bool("disable_notify", disableNotify),
|
||||
)
|
||||
|
||||
json, err := json.Marshal(newBlocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
|
||||
auditRec.AddMeta("blockCount", len(blocks))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation DELETE /boards/{boardID}/blocks/{blockID} deleteBlock
|
||||
//
|
||||
// Deletes a block
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: blockID
|
||||
// in: path
|
||||
// description: ID of block to delete
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: disable_notify
|
||||
// in: query
|
||||
// description: Disables notifications (for bulk deletion)
|
||||
// required: false
|
||||
// type: bool
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '404':
|
||||
// description: block not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
blockID := vars["blockID"]
|
||||
|
||||
val := r.URL.Query().Get("disable_notify")
|
||||
disableNotify := val == True
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes"))
|
||||
return
|
||||
}
|
||||
|
||||
block, err := a.app.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if block.BoardID != boardID {
|
||||
message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, boardID)
|
||||
a.errorResponse(w, r, model.NewErrNotFound(message))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "deleteBlock", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("blockID", blockID)
|
||||
|
||||
err = a.app.DeleteBlockAndNotify(blockID, userID, disableNotify)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("DELETE Block", mlog.String("boardID", boardID), mlog.String("blockID", blockID))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUndeleteBlock(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/blocks/{blockID}/undelete undeleteBlock
|
||||
//
|
||||
// Undeletes a block
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: blockID
|
||||
// in: path
|
||||
// description: ID of block to undelete
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BlockPatch"
|
||||
// '404':
|
||||
// description: block not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
boardID := vars["boardID"]
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
block, err := a.app.GetLastBlockHistoryEntry(blockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if board.ID != block.BoardID {
|
||||
message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, board.ID)
|
||||
a.errorResponse(w, r, model.NewErrNotFound(message))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "undeleteBlock", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("blockID", blockID)
|
||||
|
||||
undeletedBlock, err := a.app.UndeleteBlock(blockID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
undeletedBlockData, err := json.Marshal(undeletedBlock)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("UNDELETE Block", mlog.String("blockID", blockID))
|
||||
jsonBytesResponse(w, http.StatusOK, undeletedBlockData)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePatchBlock(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PATCH /boards/{boardID}/blocks/{blockID} patchBlock
|
||||
//
|
||||
// Partially updates a block
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: blockID
|
||||
// in: path
|
||||
// description: ID of block to patch
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: disable_notify
|
||||
// in: query
|
||||
// description: Disables notifications (for bulk patching)
|
||||
// required: false
|
||||
// type: bool
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: block patch to apply
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BlockPatch"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '404':
|
||||
// description: block not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
blockID := vars["blockID"]
|
||||
|
||||
val := r.URL.Query().Get("disable_notify")
|
||||
disableNotify := val == True
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes"))
|
||||
return
|
||||
}
|
||||
|
||||
block, err := a.app.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if block.BoardID != boardID {
|
||||
message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, boardID)
|
||||
a.errorResponse(w, r, model.NewErrNotFound(message))
|
||||
return
|
||||
}
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var patch *model.BlockPatch
|
||||
err = json.Unmarshal(requestBody, &patch)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchBlock", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("blockID", blockID)
|
||||
|
||||
if _, err = a.app.PatchBlockAndNotify(blockID, patch, userID, disableNotify); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("PATCH Block", mlog.String("boardID", boardID), mlog.String("blockID", blockID))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PATCH /boards/{boardID}/blocks/ patchBlocks
|
||||
//
|
||||
// Partially updates batch of blocks
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Workspace ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: disable_notify
|
||||
// in: query
|
||||
// description: Disables notifications (for bulk patching)
|
||||
// required: false
|
||||
// type: bool
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: block Ids and block patches to apply
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BlockPatchBatch"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
val := r.URL.Query().Get("disable_notify")
|
||||
disableNotify := val == True
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var patches *model.BlockPatchBatch
|
||||
err = json.Unmarshal(requestBody, &patches)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
for i := range patches.BlockIDs {
|
||||
auditRec.AddMeta("block_"+strconv.FormatInt(int64(i), 10), patches.BlockIDs[i])
|
||||
}
|
||||
|
||||
for _, blockID := range patches.BlockIDs {
|
||||
var block *model.Block
|
||||
block, err = a.app.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrForbidden("access denied to make board changes"))
|
||||
return
|
||||
}
|
||||
if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to make board changesa"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = a.app.PatchBlocksAndNotify(teamID, patches, userID, disableNotify)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("PATCH Blocks", mlog.String("patches", strconv.Itoa(len(patches.BlockIDs))))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDuplicateBlock(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/blocks/{blockID}/duplicate duplicateBlock
|
||||
//
|
||||
// Returns the new created blocks
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: blockID
|
||||
// in: path
|
||||
// description: Block ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Block"
|
||||
// '404':
|
||||
// description: board or block not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
blockID := mux.Vars(r)["blockID"]
|
||||
userID := getUserID(r)
|
||||
query := r.URL.Query()
|
||||
asTemplate := query.Get("asTemplate")
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
block, err := a.app.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if board.ID != block.BoardID {
|
||||
message := fmt.Sprintf("block ID=%s on BoardID=%s", block.ID, board.ID)
|
||||
a.errorResponse(w, r, model.NewErrNotFound(message))
|
||||
return
|
||||
}
|
||||
|
||||
if block.Type == model.TypeComment {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionCommentBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to comment on board cards"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modify board cards"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "duplicateBlock", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("blockID", blockID)
|
||||
|
||||
a.logger.Debug("DuplicateBlock",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("blockID", blockID),
|
||||
)
|
||||
|
||||
blocks, err := a.app.DuplicateBlock(boardID, blockID, userID, asTemplate == True)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
679
server/boards/api/boards.go
Обычный файл
679
server/boards/api/boards.go
Обычный файл
@@ -0,0 +1,679 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerBoardsRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/teams/{teamID}/boards", a.sessionRequired(a.handleGetBoards)).Methods("GET")
|
||||
r.HandleFunc("/boards", a.sessionRequired(a.handleCreateBoard)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}", a.attachSession(a.handleGetBoard, false)).Methods("GET")
|
||||
r.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH")
|
||||
r.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE")
|
||||
r.HandleFunc("/boards/{boardID}/duplicate", a.sessionRequired(a.handleDuplicateBoard)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/undelete", a.sessionRequired(a.handleUndeleteBoard)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/metadata", a.sessionRequired(a.handleGetBoardMetadata)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleGetBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards getBoards
|
||||
//
|
||||
// Returns team boards
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetBoards",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("boardsCount", len(boards)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(boards)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("boardsCount", len(boards))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleCreateBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards createBoard
|
||||
//
|
||||
// Creates a new board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: the board to create
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/Board'
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var newBoard *model.Board
|
||||
if err = json.Unmarshal(requestBody, &newBoard); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if newBoard.Type == model.BoardTypeOpen {
|
||||
if !a.permissions.HasPermissionToTeam(userID, newBoard.TeamID, model.PermissionCreatePublicChannel) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create public boards"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !a.permissions.HasPermissionToTeam(userID, newBoard.TeamID, model.PermissionCreatePrivateChannel) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create private boards"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create board"))
|
||||
return
|
||||
}
|
||||
|
||||
if err = newBoard.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "createBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("teamID", newBoard.TeamID)
|
||||
auditRec.AddMeta("boardType", newBoard.Type)
|
||||
|
||||
// create board
|
||||
board, err := a.app.CreateBoard(newBoard, userID, true)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("CreateBoard",
|
||||
mlog.String("teamID", board.TeamID),
|
||||
mlog.String("boardID", board.ID),
|
||||
mlog.String("boardType", string(board.Type)),
|
||||
mlog.String("userID", userID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(board)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID} getBoard
|
||||
//
|
||||
// Returns a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID)
|
||||
if userID == "" && !hasValidReadToken {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasValidReadToken {
|
||||
if board.Type == model.BoardTypePrivate {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var isGuest bool
|
||||
isGuest, err = a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
a.logger.Debug("GetBoard",
|
||||
mlog.String("boardID", boardID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(board)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePatchBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PATCH /boards/{boardID} patchBoard
|
||||
//
|
||||
// Partially updates a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: board patch to apply
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardPatch"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/Board'
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
if _, err := a.app.GetBoard(boardID); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var patch *model.BoardPatch
|
||||
if err = json.Unmarshal(requestBody, &patch); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err = patch.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardProperties) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board properties"))
|
||||
return
|
||||
}
|
||||
|
||||
if patch.Type != nil || patch.MinimumRole != nil {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board type"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if patch.ChannelID != nil {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board access"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("userID", userID)
|
||||
|
||||
// patch board
|
||||
updatedBoard, err := a.app.PatchBoard(patch, boardID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("PatchBoard",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("userID", userID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(updatedBoard)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation DELETE /boards/{boardID} deleteBoard
|
||||
//
|
||||
// Removes a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
// Check if board exists
|
||||
if _, err := a.app.GetBoard(boardID); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionDeleteBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to delete board"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "deleteBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
if err := a.app.DeleteBoard(boardID, userID); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("DELETE Board", mlog.String("boardID", boardID))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/duplicate duplicateBoard
|
||||
//
|
||||
// Returns the new created board and all the blocks
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/BoardsAndBlocks'
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
query := r.URL.Query()
|
||||
asTemplate := query.Get("asTemplate")
|
||||
toTeam := query.Get("toTeam")
|
||||
|
||||
if userID == "" {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if toTeam == "" {
|
||||
toTeam = board.TeamID
|
||||
}
|
||||
|
||||
if toTeam == "" && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
if toTeam != "" && !a.permissions.HasPermissionToTeam(userID, toTeam, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
if board.IsTemplate && board.Type == model.BoardTypeOpen {
|
||||
if board.TeamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create board"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "duplicateBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
a.logger.Debug("DuplicateBoard",
|
||||
mlog.String("boardID", boardID),
|
||||
)
|
||||
|
||||
boardsAndBlocks, _, err := a.app.DuplicateBoard(boardID, userID, toTeam, asTemplate == True)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(boardsAndBlocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUndeleteBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/undelete undeleteBoard
|
||||
//
|
||||
// Undeletes a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: ID of board to undelete
|
||||
// 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)
|
||||
userID := session.UserID
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "undeleteBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionDeleteBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to undelete board"))
|
||||
return
|
||||
}
|
||||
|
||||
err := a.app.UndeleteBoard(boardID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("UNDELETE Board", mlog.String("boardID", boardID))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetBoardMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/metadata getBoardMetadata
|
||||
//
|
||||
// Returns a board's metadata
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardMetadata"
|
||||
// '404':
|
||||
// description: board not found
|
||||
// '501':
|
||||
// description: required license not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
board, boardMetadata, err := a.app.GetBoardMetadata(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if board == nil || boardMetadata == nil {
|
||||
a.errorResponse(w, r, model.NewErrNotFound("board metadata BoardID="+boardID))
|
||||
return
|
||||
}
|
||||
|
||||
if board.Type == model.BoardTypePrivate {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getBoardMetadata", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
data, err := json.Marshal(boardMetadata)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
414
server/boards/api/boards_and_blocks.go
Обычный файл
414
server/boards/api/boards_and_blocks.go
Обычный файл
@@ -0,0 +1,414 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerBoardsAndBlocksRoutes(r *mux.Router) {
|
||||
// BoardsAndBlocks APIs
|
||||
r.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handleCreateBoardsAndBlocks)).Methods("POST")
|
||||
r.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handlePatchBoardsAndBlocks)).Methods("PATCH")
|
||||
r.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handleDeleteBoardsAndBlocks)).Methods("DELETE")
|
||||
}
|
||||
|
||||
func (a *API) handleCreateBoardsAndBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards-and-blocks insertBoardsAndBlocks
|
||||
//
|
||||
// Creates new boards and blocks
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: the boards and blocks to create
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardsAndBlocks"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/BoardsAndBlocks'
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var newBab *model.BoardsAndBlocks
|
||||
if err = json.Unmarshal(requestBody, &newBab); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(newBab.Boards) == 0 {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("at least one board is required"))
|
||||
return
|
||||
}
|
||||
|
||||
teamID := ""
|
||||
boardIDs := map[string]bool{}
|
||||
for _, board := range newBab.Boards {
|
||||
boardIDs[board.ID] = true
|
||||
|
||||
if teamID == "" {
|
||||
teamID = board.TeamID
|
||||
continue
|
||||
}
|
||||
|
||||
if teamID != board.TeamID {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("cannot create boards for multiple teams"))
|
||||
return
|
||||
}
|
||||
|
||||
if board.ID == "" {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("boards need an ID to be referenced from the blocks"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board template"))
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create board"))
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range newBab.Blocks {
|
||||
// Error checking
|
||||
if len(block.Type) < 1 {
|
||||
message := fmt.Sprintf("missing type for block id %s", block.ID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if block.CreateAt < 1 {
|
||||
message := fmt.Sprintf("invalid createAt for block id %s", block.ID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if block.UpdateAt < 1 {
|
||||
message := fmt.Sprintf("invalid UpdateAt for block id %s", block.ID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if !boardIDs[block.BoardID] {
|
||||
message := fmt.Sprintf("invalid BoardID %s (not exists in the created boards)", block.BoardID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// IDs of boards and blocks are used to confirm that they're
|
||||
// linked and then regenerated by the server
|
||||
newBab, err = model.GenerateBoardsAndBlocksIDs(newBab, a.logger)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "createBoardsAndBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
auditRec.AddMeta("userID", userID)
|
||||
auditRec.AddMeta("boardsCount", len(newBab.Boards))
|
||||
auditRec.AddMeta("blocksCount", len(newBab.Blocks))
|
||||
|
||||
// create boards and blocks
|
||||
bab, err := a.app.CreateBoardsAndBlocks(newBab, userID, true)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("CreateBoardsAndBlocks",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.String("userID", userID),
|
||||
mlog.Int("boardCount", len(bab.Boards)),
|
||||
mlog.Int("blockCount", len(bab.Blocks)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(bab)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PATCH /boards-and-blocks patchBoardsAndBlocks
|
||||
//
|
||||
// Patches a set of related boards and blocks
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: the patches for the boards and blocks
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/PatchBoardsAndBlocks"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/BoardsAndBlocks'
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var pbab *model.PatchBoardsAndBlocks
|
||||
if err = json.Unmarshal(requestBody, &pbab); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = pbab.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
teamID := ""
|
||||
boardIDMap := map[string]bool{}
|
||||
for i, boardID := range pbab.BoardIDs {
|
||||
boardIDMap[boardID] = true
|
||||
patch := pbab.BoardPatches[i]
|
||||
|
||||
if err = patch.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardProperties) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board properties"))
|
||||
return
|
||||
}
|
||||
|
||||
if patch.Type != nil || patch.MinimumRole != nil {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modifying board type"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
board, err2 := a.app.GetBoard(boardID)
|
||||
if err2 != nil {
|
||||
a.errorResponse(w, r, err2)
|
||||
return
|
||||
}
|
||||
|
||||
if teamID == "" {
|
||||
teamID = board.TeamID
|
||||
}
|
||||
if teamID != board.TeamID {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("mismatched team ID"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, blockID := range pbab.BlockIDs {
|
||||
block, err2 := a.app.GetBlockByID(blockID)
|
||||
if err2 != nil {
|
||||
a.errorResponse(w, r, err2)
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := boardIDMap[block.BoardID]; !ok {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("missing BoardID="+block.BoardID))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modifying cards"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchBoardsAndBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardsCount", len(pbab.BoardIDs))
|
||||
auditRec.AddMeta("blocksCount", len(pbab.BlockIDs))
|
||||
|
||||
bab, err := a.app.PatchBoardsAndBlocks(pbab, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("PATCH BoardsAndBlocks",
|
||||
mlog.Int("boardsCount", len(pbab.BoardIDs)),
|
||||
mlog.Int("blocksCount", len(pbab.BlockIDs)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(bab)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteBoardsAndBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation DELETE /boards-and-blocks deleteBoardsAndBlocks
|
||||
//
|
||||
// Deletes boards and blocks
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: the boards and blocks to delete
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/DeleteBoardsAndBlocks"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var dbab *model.DeleteBoardsAndBlocks
|
||||
if err = json.Unmarshal(requestBody, &dbab); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// user must have permission to delete all the boards, and that
|
||||
// would include the permission to manage their blocks
|
||||
teamID := ""
|
||||
boardIDMap := map[string]bool{}
|
||||
for _, boardID := range dbab.Boards {
|
||||
boardIDMap[boardID] = true
|
||||
// all boards in the request should belong to the same team
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if teamID == "" {
|
||||
teamID = board.TeamID
|
||||
}
|
||||
if teamID != board.TeamID {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("all boards should belong to the same team"))
|
||||
return
|
||||
}
|
||||
|
||||
// permission check
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionDeleteBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to delete board"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, blockID := range dbab.Blocks {
|
||||
block, err2 := a.app.GetBlockByID(blockID)
|
||||
if err2 != nil {
|
||||
a.errorResponse(w, r, err2)
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := boardIDMap[block.BoardID]; !ok {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("missing BoardID="+block.BoardID))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modifying cards"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := dbab.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "deleteBoardsAndBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardsCount", len(dbab.Boards))
|
||||
auditRec.AddMeta("blocksCount", len(dbab.Blocks))
|
||||
|
||||
if err := a.app.DeleteBoardsAndBlocks(dbab, userID); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("DELETE BoardsAndBlocks",
|
||||
mlog.Int("boardsCount", len(dbab.Boards)),
|
||||
mlog.Int("blocksCount", len(dbab.Blocks)),
|
||||
)
|
||||
|
||||
// response
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
393
server/boards/api/cards.go
Обычный файл
393
server/boards/api/cards.go
Обычный файл
@@ -0,0 +1,393 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPage = "0"
|
||||
defaultPerPage = "100"
|
||||
)
|
||||
|
||||
func (a *API) registerCardsRoutes(r *mux.Router) {
|
||||
// Cards APIs
|
||||
r.HandleFunc("/boards/{boardID}/cards", a.sessionRequired(a.handleCreateCard)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/cards", a.sessionRequired(a.handleGetCards)).Methods("GET")
|
||||
r.HandleFunc("/cards/{cardID}", a.sessionRequired(a.handlePatchCard)).Methods("PATCH")
|
||||
r.HandleFunc("/cards/{cardID}", a.sessionRequired(a.handleGetCard)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleCreateCard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/cards createCard
|
||||
//
|
||||
// Creates a new card for the specified board.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: the card to create
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Card"
|
||||
// - name: disable_notify
|
||||
// in: query
|
||||
// description: Disables notifications (for bulk data inserting)
|
||||
// required: false
|
||||
// type: bool
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/Card'
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
|
||||
val := r.URL.Query().Get("disable_notify")
|
||||
disableNotify := val == True
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var newCard *model.Card
|
||||
if err = json.Unmarshal(requestBody, &newCard); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create card"))
|
||||
return
|
||||
}
|
||||
|
||||
if newCard.BoardID != "" && newCard.BoardID != boardID {
|
||||
a.errorResponse(w, r, model.ErrBoardIDMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
newCard.PopulateWithBoardID(boardID)
|
||||
if err = newCard.CheckValid(); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "createCard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
// create card
|
||||
card, err := a.app.CreateCard(newCard, boardID, userID, disableNotify)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("CreateCard",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("cardID", card.ID),
|
||||
mlog.String("userID", userID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetCards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/cards getCards
|
||||
//
|
||||
// Fetches cards for the specified board.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: The page to select (default=0)
|
||||
// required: false
|
||||
// type: integer
|
||||
// - name: per_page
|
||||
// in: query
|
||||
// description: Number of cards to return per page(default=100)
|
||||
// required: false
|
||||
// type: integer
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Card"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
userID := getUserID(r)
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
|
||||
query := r.URL.Query()
|
||||
strPage := query.Get("page")
|
||||
strPerPage := query.Get("per_page")
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to fetch cards"))
|
||||
return
|
||||
}
|
||||
|
||||
if strPage == "" {
|
||||
strPage = defaultPage
|
||||
}
|
||||
if strPerPage == "" {
|
||||
strPerPage = defaultPerPage
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(strPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
}
|
||||
|
||||
perPage, err := strconv.Atoi(strPerPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `per_page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getCards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("page", page)
|
||||
auditRec.AddMeta("per_page", perPage)
|
||||
|
||||
cards, err := a.app.GetCardsForBoard(boardID, page, perPage)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetCards",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("userID", userID),
|
||||
mlog.Int("page", page),
|
||||
mlog.Int("per_page", perPage),
|
||||
mlog.Int("count", len(cards)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(cards)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePatchCard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PATCH /cards/{cardID}/cards patchCard
|
||||
//
|
||||
// Patches the specified card.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: cardID
|
||||
// in: path
|
||||
// description: Card ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: the card patch
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CardPatch"
|
||||
// - name: disable_notify
|
||||
// in: query
|
||||
// description: Disables notifications (for bulk data patching)
|
||||
// required: false
|
||||
// type: bool
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/Card'
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
cardID := mux.Vars(r)["cardID"]
|
||||
|
||||
val := r.URL.Query().Get("disable_notify")
|
||||
disableNotify := val == True
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
card, err := a.app.GetCardByID(cardID)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("could not fetch card %s: %s", cardID, err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, card.BoardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to patch card"))
|
||||
return
|
||||
}
|
||||
|
||||
var patch *model.CardPatch
|
||||
if err = json.Unmarshal(requestBody, &patch); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchCard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", card.BoardID)
|
||||
auditRec.AddMeta("cardID", card.ID)
|
||||
|
||||
// patch card
|
||||
cardPatched, err := a.app.PatchCard(patch, card.ID, userID, disableNotify)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("PatchCard",
|
||||
mlog.String("boardID", cardPatched.BoardID),
|
||||
mlog.String("cardID", cardPatched.ID),
|
||||
mlog.String("userID", userID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(cardPatched)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetCard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /cards/{cardID} getCard
|
||||
//
|
||||
// Fetches the specified card.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: cardID
|
||||
// in: path
|
||||
// description: Card ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/Card'
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
cardID := mux.Vars(r)["cardID"]
|
||||
|
||||
card, err := a.app.GetCardByID(cardID)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("could not fetch card %s: %s", cardID, err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, card.BoardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to fetch card"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getCard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", card.BoardID)
|
||||
auditRec.AddMeta("cardID", card.ID)
|
||||
|
||||
a.logger.Debug("GetCard",
|
||||
mlog.String("boardID", card.BoardID),
|
||||
mlog.String("cardID", card.ID),
|
||||
mlog.String("userID", userID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
676
server/boards/api/categories.go
Обычный файл
676
server/boards/api/categories.go
Обычный файл
@@ -0,0 +1,676 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
)
|
||||
|
||||
func (a *API) registerCategoriesRoutes(r *mux.Router) {
|
||||
// Category APIs
|
||||
r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleCreateCategory)).Methods(http.MethodPost)
|
||||
r.HandleFunc("/teams/{teamID}/categories/reorder", a.sessionRequired(a.handleReorderCategories)).Methods(http.MethodPut)
|
||||
r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleUpdateCategory)).Methods(http.MethodPut)
|
||||
r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleDeleteCategory)).Methods(http.MethodDelete)
|
||||
r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleGetUserCategoryBoards)).Methods(http.MethodGet)
|
||||
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/reorder", a.sessionRequired(a.handleReorderCategoryBoards)).Methods(http.MethodPut)
|
||||
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}", a.sessionRequired(a.handleUpdateCategoryBoard)).Methods(http.MethodPost)
|
||||
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide", a.sessionRequired(a.handleHideBoard)).Methods(http.MethodPut)
|
||||
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}/unhide", a.sessionRequired(a.handleUnhideBoard)).Methods(http.MethodPut)
|
||||
}
|
||||
|
||||
func (a *API) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/categories createCategory
|
||||
//
|
||||
// Create a category for boards
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: category to create
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Category"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Category"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var category model.Category
|
||||
|
||||
err = json.Unmarshal(requestBody, &category)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "createCategory", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
// user can only create category for themselves
|
||||
if category.UserID != session.UserID {
|
||||
message := fmt.Sprintf("userID %s and category userID %s mismatch", session.UserID, category.UserID)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
if category.TeamID != teamID {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("teamID mismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
createdCategory, err := a.app.CreateCategory(&category)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(createdCategory)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.AddMeta("categoryID", createdCategory.ID)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PUT /teams/{teamID}/categories/{categoryID} updateCategory
|
||||
//
|
||||
// Create a category for boards
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: categoryID
|
||||
// in: path
|
||||
// description: Category ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: category to update
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Category"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Category"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
categoryID := vars["categoryID"]
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var category model.Category
|
||||
err = json.Unmarshal(requestBody, &category)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "updateCategory", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
if categoryID != category.ID {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("categoryID mismatch in patch and body"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
// user can only update category for themselves
|
||||
if category.UserID != session.UserID {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("user ID mismatch in session and category"))
|
||||
return
|
||||
}
|
||||
|
||||
teamID := vars["teamID"]
|
||||
if category.TeamID != teamID {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("teamID mismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
updatedCategory, err := a.app.UpdateCategory(&category)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(updatedCategory)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation DELETE /teams/{teamID}/categories/{categoryID} deleteCategory
|
||||
//
|
||||
// Delete a category
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: categoryID
|
||||
// in: path
|
||||
// description: Category 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)
|
||||
|
||||
userID := session.UserID
|
||||
teamID := vars["teamID"]
|
||||
categoryID := vars["categoryID"]
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "deleteCategory", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
deletedCategory, err := a.app.DeleteCategory(categoryID, userID, teamID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(deletedCategory)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetUserCategoryBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/categories getUserCategoryBoards
|
||||
//
|
||||
// Gets the user's board categories
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// items:
|
||||
// "$ref": "#/definitions/CategoryBoards"
|
||||
// type: array
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getUserCategoryBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
categoryBlocks, err := a.app.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(categoryBlocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateCategoryBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID} updateCategoryBoard
|
||||
//
|
||||
// Set the category of a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: categoryID
|
||||
// in: path
|
||||
// description: Category ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "updateCategoryBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
categoryID := vars["categoryID"]
|
||||
boardID := vars["boardID"]
|
||||
teamID := vars["teamID"]
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(session.UserID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Check the category and the team matches
|
||||
err := a.app.AddUpdateUserCategoryBoard(teamID, userID, categoryID, []string{boardID})
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, []byte("ok"))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleReorderCategories(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PUT /teams/{teamID}/categories/reorder handleReorderCategories
|
||||
//
|
||||
// Updated sidebar category order
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
|
||||
return
|
||||
}
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var newCategoryOrder []string
|
||||
|
||||
err = json.Unmarshal(requestBody, &newCategoryOrder)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "reorderCategories", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
auditRec.AddMeta("TeamID", teamID)
|
||||
auditRec.AddMeta("CategoryCount", len(newCategoryOrder))
|
||||
|
||||
updatedCategoryOrder, err := a.app.ReorderCategories(userID, teamID, newCategoryOrder)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(updatedCategoryOrder)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleReorderCategoryBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PUT /teams/{teamID}/categories/{categoryID}/boards/reorder handleReorderCategoryBoards
|
||||
//
|
||||
// Updates order of boards inside a sidebar category
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: categoryID
|
||||
// in: path
|
||||
// description: Category ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
categoryID := vars["categoryID"]
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
|
||||
return
|
||||
}
|
||||
|
||||
category, err := a.app.GetCategory(categoryID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category.UserID != userID {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
|
||||
return
|
||||
}
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var newBoardsOrder []string
|
||||
err = json.Unmarshal(requestBody, &newBoardsOrder)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "reorderCategoryBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
updatedBoardsOrder, err := a.app.ReorderCategoryBoards(userID, teamID, categoryID, newBoardsOrder)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(updatedBoardsOrder)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleHideBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide hideBoard
|
||||
//
|
||||
// Hide the specified board for the user
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: categoryID
|
||||
// in: path
|
||||
// description: Category ID to which the board to be hidden belongs to
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: ID of board to be hidden
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Category"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
boardID := vars["boardID"]
|
||||
categoryID := vars["categoryID"]
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "hideBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("board_id", boardID)
|
||||
auditRec.AddMeta("team_id", teamID)
|
||||
auditRec.AddMeta("category_id", categoryID)
|
||||
|
||||
if err := a.app.SetBoardVisibility(teamID, userID, categoryID, boardID, false); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUnhideBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide unhideBoard
|
||||
//
|
||||
// Unhides the specified board for the user
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: categoryID
|
||||
// in: path
|
||||
// description: Category ID to which the board to be unhidden belongs to
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: ID of board to be unhidden
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Category"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
boardID := vars["boardID"]
|
||||
categoryID := vars["categoryID"]
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "unhideBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
if err := a.app.SetBoardVisibility(teamID, userID, categoryID, boardID, true); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
110
server/boards/api/channels.go
Обычный файл
110
server/boards/api/channels.go
Обычный файл
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerChannelsRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/teams/{teamID}/channels/{channelID}", a.sessionRequired(a.handleGetChannel)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleGetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/channels/{channelID} getChannel
|
||||
//
|
||||
// Returns the requested channel
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: channelID
|
||||
// in: path
|
||||
// description: Channel ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Channel"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
|
||||
return
|
||||
}
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
channelID := mux.Vars(r)["channelID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to channel"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getChannel", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
auditRec.AddMeta("channelID", teamID)
|
||||
|
||||
channel, err := a.app.GetChannel(teamID, channelID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetChannel",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.String("channelID", channelID),
|
||||
)
|
||||
|
||||
if channel.TeamId != teamID {
|
||||
if channel.Type != mm_model.ChannelTypeDirect && channel.Type != mm_model.ChannelTypeGroup {
|
||||
message := fmt.Sprintf("channel ID=%s on TeamID=%s", channel.Id, teamID)
|
||||
a.errorResponse(w, r, model.NewErrNotFound(message))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(channel)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
451
server/boards/api/compliance.go
Обычный файл
451
server/boards/api/compliance.go
Обычный файл
@@ -0,0 +1,451 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
complianceDefaultPage = "0"
|
||||
complianceDefaultPerPage = "60"
|
||||
)
|
||||
|
||||
func (a *API) registerComplianceRoutes(r *mux.Router) {
|
||||
// Compliance APIs
|
||||
r.HandleFunc("/admin/boards", a.sessionRequired(a.handleGetBoardsForCompliance)).Methods("GET")
|
||||
r.HandleFunc("/admin/boards_history", a.sessionRequired(a.handleGetBoardsComplianceHistory)).Methods("GET")
|
||||
r.HandleFunc("/admin/blocks_history", a.sessionRequired(a.handleGetBlocksComplianceHistory)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleGetBoardsForCompliance(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /admin/boards getBoardsForCompliance
|
||||
//
|
||||
// Returns boards for a specific team, or all teams.
|
||||
//
|
||||
// Requires a license that includes Compliance feature. Caller must have `manage_system` permissions.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: team_id
|
||||
// in: query
|
||||
// description: Team ID. If empty then boards across all teams are included.
|
||||
// required: false
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: The page to select (default=0)
|
||||
// required: false
|
||||
// type: integer
|
||||
// - name: per_page
|
||||
// in: query
|
||||
// description: Number of boards to return per page(default=60)
|
||||
// required: false
|
||||
// type: integer
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: object
|
||||
// items:
|
||||
// "$ref": "#/definitions/BoardsComplianceResponse"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
query := r.URL.Query()
|
||||
teamID := query.Get("team_id")
|
||||
strPage := query.Get("page")
|
||||
strPerPage := query.Get("per_page")
|
||||
|
||||
// check for permission `manage_system`
|
||||
userID := getUserID(r)
|
||||
if !a.permissions.HasPermissionTo(userID, mm_model.PermissionManageSystem) {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied Compliance Export getAllBoards"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for valid license feature: compliance
|
||||
license := a.app.GetLicense()
|
||||
if license == nil || !(*license.Features.Compliance) {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("insufficient license Compliance Export getAllBoards"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for valid team if specified
|
||||
if teamID != "" {
|
||||
_, err := a.app.GetTeam(teamID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("invalid team id: "+teamID))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if strPage == "" {
|
||||
strPage = complianceDefaultPage
|
||||
}
|
||||
if strPerPage == "" {
|
||||
strPerPage = complianceDefaultPerPage
|
||||
}
|
||||
page, err := strconv.Atoi(strPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
perPage, err := strconv.Atoi(strPerPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `per_page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.QueryBoardsForComplianceOptions{
|
||||
TeamID: teamID,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
boards, more, err := a.app.GetBoardsForCompliance(opts)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetBoardsForCompliance",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("boardsCount", len(boards)),
|
||||
mlog.Bool("hasNext", more),
|
||||
)
|
||||
|
||||
response := model.BoardsComplianceResponse{
|
||||
HasNext: more,
|
||||
Results: boards,
|
||||
}
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (a *API) handleGetBoardsComplianceHistory(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /admin/boards_history getBoardsComplianceHistory
|
||||
//
|
||||
// Returns boards histories for a specific team, or all teams.
|
||||
//
|
||||
// Requires a license that includes Compliance feature. Caller must have `manage_system` permissions.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: modified_since
|
||||
// in: query
|
||||
// description: Filters for boards modified since timestamp; Unix time in milliseconds
|
||||
// required: true
|
||||
// type: integer
|
||||
// - name: include_deleted
|
||||
// in: query
|
||||
// description: When true then deleted boards are included. Default=false
|
||||
// required: false
|
||||
// type: boolean
|
||||
// - name: team_id
|
||||
// in: query
|
||||
// description: Team ID. If empty then board histories across all teams are included
|
||||
// required: false
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: The page to select (default=0)
|
||||
// required: false
|
||||
// type: integer
|
||||
// - name: per_page
|
||||
// in: query
|
||||
// description: Number of board histories to return per page (default=60)
|
||||
// required: false
|
||||
// type: integer
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: object
|
||||
// items:
|
||||
// "$ref": "#/definitions/BoardsComplianceHistoryResponse"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
query := r.URL.Query()
|
||||
strModifiedSince := query.Get("modified_since") // required, everything else optional
|
||||
includeDeleted := query.Get("include_deleted") == "true"
|
||||
strPage := query.Get("page")
|
||||
strPerPage := query.Get("per_page")
|
||||
teamID := query.Get("team_id")
|
||||
|
||||
if strModifiedSince == "" {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("`modified_since` parameter required"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for permission `manage_system`
|
||||
userID := getUserID(r)
|
||||
if !a.permissions.HasPermissionTo(userID, mm_model.PermissionManageSystem) {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied Compliance Export getBoardsHistory"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for valid license feature: compliance
|
||||
license := a.app.GetLicense()
|
||||
if license == nil || !(*license.Features.Compliance) {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("insufficient license Compliance Export getBoardsHistory"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for valid team if specified
|
||||
if teamID != "" {
|
||||
_, err := a.app.GetTeam(teamID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("invalid team id: "+teamID))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if strPage == "" {
|
||||
strPage = complianceDefaultPage
|
||||
}
|
||||
if strPerPage == "" {
|
||||
strPerPage = complianceDefaultPerPage
|
||||
}
|
||||
page, err := strconv.Atoi(strPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
perPage, err := strconv.Atoi(strPerPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `per_page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
modifiedSince, err := strconv.ParseInt(strModifiedSince, 10, 64)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `modified_since` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.QueryBoardsComplianceHistoryOptions{
|
||||
ModifiedSince: modifiedSince,
|
||||
IncludeDeleted: includeDeleted,
|
||||
TeamID: teamID,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
boards, more, err := a.app.GetBoardsComplianceHistory(opts)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetBoardsComplianceHistory",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("boardsCount", len(boards)),
|
||||
mlog.Bool("hasNext", more),
|
||||
)
|
||||
|
||||
response := model.BoardsComplianceHistoryResponse{
|
||||
HasNext: more,
|
||||
Results: boards,
|
||||
}
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (a *API) handleGetBlocksComplianceHistory(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /admin/blocks_history getBlocksComplianceHistory
|
||||
//
|
||||
// Returns block histories for a specific team, specific board, or all teams and boards.
|
||||
//
|
||||
// Requires a license that includes Compliance feature. Caller must have `manage_system` permissions.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: modified_since
|
||||
// in: query
|
||||
// description: Filters for boards modified since timestamp; Unix time in milliseconds
|
||||
// required: true
|
||||
// type: integer
|
||||
// - name: include_deleted
|
||||
// in: query
|
||||
// description: When true then deleted boards are included. Default=false
|
||||
// required: false
|
||||
// type: boolean
|
||||
// - name: team_id
|
||||
// in: query
|
||||
// description: Team ID. If empty then block histories across all teams are included
|
||||
// required: false
|
||||
// type: string
|
||||
// - name: board_id
|
||||
// in: query
|
||||
// description: Board ID. If empty then block histories for all boards are included
|
||||
// required: false
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: The page to select (default=0)
|
||||
// required: false
|
||||
// type: integer
|
||||
// - name: per_page
|
||||
// in: query
|
||||
// description: Number of block histories to return per page (default=60)
|
||||
// required: false
|
||||
// type: integer
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: object
|
||||
// items:
|
||||
// "$ref": "#/definitions/BlocksComplianceHistoryResponse"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
query := r.URL.Query()
|
||||
strModifiedSince := query.Get("modified_since") // required, everything else optional
|
||||
includeDeleted := query.Get("include_deleted") == "true"
|
||||
strPage := query.Get("page")
|
||||
strPerPage := query.Get("per_page")
|
||||
teamID := query.Get("team_id")
|
||||
boardID := query.Get("board_id")
|
||||
|
||||
if strModifiedSince == "" {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("`modified_since` parameter required"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for permission `manage_system`
|
||||
userID := getUserID(r)
|
||||
if !a.permissions.HasPermissionTo(userID, mm_model.PermissionManageSystem) {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied Compliance Export getBlocksHistory"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for valid license feature: compliance
|
||||
license := a.app.GetLicense()
|
||||
if license == nil || !(*license.Features.Compliance) {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("insufficient license Compliance Export getBlocksHistory"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for valid team if specified
|
||||
if teamID != "" {
|
||||
_, err := a.app.GetTeam(teamID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("invalid team id: "+teamID))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// check for valid board if specified
|
||||
if boardID != "" {
|
||||
_, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("invalid board id: "+boardID))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if strPage == "" {
|
||||
strPage = complianceDefaultPage
|
||||
}
|
||||
if strPerPage == "" {
|
||||
strPerPage = complianceDefaultPerPage
|
||||
}
|
||||
page, err := strconv.Atoi(strPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
perPage, err := strconv.Atoi(strPerPage)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `per_page` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
modifiedSince, err := strconv.ParseInt(strModifiedSince, 10, 64)
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("invalid `modified_since` parameter: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.QueryBlocksComplianceHistoryOptions{
|
||||
ModifiedSince: modifiedSince,
|
||||
IncludeDeleted: includeDeleted,
|
||||
TeamID: teamID,
|
||||
BoardID: boardID,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
blocks, more, err := a.app.GetBlocksComplianceHistory(opts)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetBlocksComplianceHistory",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.Int("blocksCount", len(blocks)),
|
||||
mlog.Bool("hasNext", more),
|
||||
)
|
||||
|
||||
response := model.BlocksComplianceHistoryResponse{
|
||||
HasNext: more,
|
||||
Results: blocks,
|
||||
}
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
44
server/boards/api/config.go
Обычный файл
44
server/boards/api/config.go
Обычный файл
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (a *API) registerConfigRoutes(r *mux.Router) {
|
||||
// Config APIs
|
||||
r.HandleFunc("/clientConfig", a.getClientConfig).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) getClientConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /clientConfig getClientConfig
|
||||
//
|
||||
// Returns the client configuration
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ClientConfig"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
clientConfig := a.app.GetClientConfig()
|
||||
|
||||
configData, err := json.Marshal(clientConfig)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
jsonBytesResponse(w, http.StatusOK, configData)
|
||||
}
|
||||
107
server/boards/api/content_blocks.go
Обычный файл
107
server/boards/api/content_blocks.go
Обычный файл
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
)
|
||||
|
||||
func (a *API) registerContentBlocksRoutes(r *mux.Router) {
|
||||
// Blocks APIs
|
||||
r.HandleFunc("/content-blocks/{blockID}/moveto/{where}/{dstBlockID}", a.sessionRequired(a.handleMoveBlockTo)).Methods("POST")
|
||||
}
|
||||
|
||||
func (a *API) handleMoveBlockTo(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /content-blocks/{blockID}/move/{where}/{dstBlockID} moveBlockTo
|
||||
//
|
||||
// Move a block after another block in the parent card
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: blockID
|
||||
// in: path
|
||||
// description: Block ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: where
|
||||
// in: path
|
||||
// description: Relative location respect destination block (after or before)
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: dstBlockID
|
||||
// in: path
|
||||
// description: Destination Block ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Block"
|
||||
// '404':
|
||||
// description: board or block not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
blockID := mux.Vars(r)["blockID"]
|
||||
dstBlockID := mux.Vars(r)["dstBlockID"]
|
||||
where := mux.Vars(r)["where"]
|
||||
userID := getUserID(r)
|
||||
|
||||
block, err := a.app.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
dstBlock, err := a.app.GetBlockByID(dstBlockID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if where != "after" && where != "before" {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("invalid where parameter, use before or after"))
|
||||
return
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modify board cards"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "moveBlockTo", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("blockID", blockID)
|
||||
auditRec.AddMeta("dstBlockID", dstBlockID)
|
||||
|
||||
err = a.app.MoveContentBlock(block, dstBlock, where, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
32
server/boards/api/context.go
Обычный файл
32
server/boards/api/context.go
Обычный файл
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
httpConnContextKey contextKey = iota
|
||||
sessionContextKey
|
||||
)
|
||||
|
||||
// SetContextConn stores the connection in the request context.
|
||||
func SetContextConn(ctx context.Context, c net.Conn) context.Context {
|
||||
return context.WithValue(ctx, httpConnContextKey, c)
|
||||
}
|
||||
|
||||
// GetContextConn gets the stored connection from the request context.
|
||||
func GetContextConn(r *http.Request) net.Conn {
|
||||
value := r.Context().Value(httpConnContextKey)
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value.(net.Conn)
|
||||
}
|
||||
329
server/boards/api/files.go
Обычный файл
329
server/boards/api/files.go
Обычный файл
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/app"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/web"
|
||||
)
|
||||
|
||||
// FileUploadResponse is the response to a file upload
|
||||
// swagger:model
|
||||
type FileUploadResponse struct {
|
||||
// The FileID to retrieve the uploaded file
|
||||
// required: true
|
||||
FileID string `json:"fileId"`
|
||||
}
|
||||
|
||||
func FileUploadResponseFromJSON(data io.Reader) (*FileUploadResponse, error) {
|
||||
var fileUploadResponse FileUploadResponse
|
||||
|
||||
if err := json.NewDecoder(data).Decode(&fileUploadResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileUploadResponse, nil
|
||||
}
|
||||
|
||||
func FileInfoResponseFromJSON(data io.Reader) (*mm_model.FileInfo, error) {
|
||||
var fileInfo mm_model.FileInfo
|
||||
|
||||
if err := json.NewDecoder(data).Decode(&fileInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileInfo, nil
|
||||
}
|
||||
|
||||
func (a *API) registerFilesRoutes(r *mux.Router) {
|
||||
// Files API
|
||||
r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}", a.attachSession(a.handleServeFile, false)).Methods("GET")
|
||||
r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}/info", a.attachSession(a.getFileInfo, false)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}/{boardID}/files", a.sessionRequired(a.handleUploadFile)).Methods("POST")
|
||||
}
|
||||
|
||||
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /files/teams/{teamID}/{boardID}/{filename} getFile
|
||||
//
|
||||
// Returns the contents of an uploaded file
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// - image/jpg
|
||||
// - image/png
|
||||
// - image/gif
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: filename
|
||||
// in: path
|
||||
// description: name of the file
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '404':
|
||||
// description: file not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
filename := vars["filename"]
|
||||
userID := getUserID(r)
|
||||
|
||||
hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID)
|
||||
if userID == "" && !hasValidReadToken {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
if !hasValidReadToken && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getFile", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("teamID", board.TeamID)
|
||||
auditRec.AddMeta("filename", filename)
|
||||
|
||||
fileInfo, fileReader, err := a.app.GetFile(board.TeamID, boardID, filename)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, app.ErrFileNotFound) && board.ChannelID != "" {
|
||||
// prior to moving from workspaces to teams, the filepath was constructed from
|
||||
// workspaceID, which is the channel ID in plugin mode.
|
||||
// If a file is not found from team ID as we tried above, try looking for it via
|
||||
// channel ID.
|
||||
fileReader, err = a.app.GetFileReader(board.ChannelID, boardID, filename)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
// move file to team location
|
||||
// nothing to do if there is an error
|
||||
_ = a.app.MoveFile(board.ChannelID, board.TeamID, boardID, filename)
|
||||
}
|
||||
|
||||
defer fileReader.Close()
|
||||
|
||||
mimeType := ""
|
||||
var fileSize int64
|
||||
if fileInfo != nil {
|
||||
mimeType = fileInfo.MimeType
|
||||
fileSize = fileInfo.Size
|
||||
}
|
||||
web.WriteFileResponse(filename, mimeType, fileSize, time.Now(), "", fileReader, false, w, r)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) getFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /files/teams/{teamID}/{boardID}/{filename}/info getFile
|
||||
//
|
||||
// Returns the metadata of an uploaded file
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: filename
|
||||
// in: path
|
||||
// description: name of the file
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '404':
|
||||
// description: file not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
teamID := vars["teamID"]
|
||||
filename := vars["filename"]
|
||||
userID := getUserID(r)
|
||||
|
||||
hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID)
|
||||
if userID == "" && !hasValidReadToken {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
if !hasValidReadToken && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getFile", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
auditRec.AddMeta("filename", filename)
|
||||
|
||||
fileInfo, err := a.app.GetFileInfo(filename)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(fileInfo)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/boards/{boardID}/files uploadFile
|
||||
//
|
||||
// Upload a binary file, attached to a root block
|
||||
//
|
||||
// ---
|
||||
// consumes:
|
||||
// - multipart/form-data
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: ID of the team
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: uploaded file
|
||||
// in: formData
|
||||
// type: file
|
||||
// description: The file to upload
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/FileUploadResponse"
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to make board changes"))
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if a.app.GetConfig().MaxFileSize > 0 {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.app.GetConfig().MaxFileSize)
|
||||
}
|
||||
|
||||
file, handle, err := r.FormFile(UploadFormFileKey)
|
||||
if err != nil {
|
||||
if strings.HasSuffix(err.Error(), "http: request body too large") {
|
||||
a.errorResponse(w, r, model.ErrRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "uploadFile", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("teamID", board.TeamID)
|
||||
auditRec.AddMeta("filename", handle.Filename)
|
||||
|
||||
fileID, err := a.app.SaveFile(file, board.TeamID, boardID, handle.Filename)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("uploadFile",
|
||||
mlog.String("filename", handle.Filename),
|
||||
mlog.String("fileID", fileID),
|
||||
)
|
||||
data, err := json.Marshal(FileUploadResponse{FileID: fileID})
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("fileID", fileID)
|
||||
auditRec.Success()
|
||||
}
|
||||
262
server/boards/api/insights.go
Обычный файл
262
server/boards/api/insights.go
Обычный файл
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func (a *API) registerInsightsRoutes(r *mux.Router) {
|
||||
// Insights APIs
|
||||
r.HandleFunc("/teams/{teamID}/boards/insights", a.sessionRequired(a.handleTeamBoardsInsights)).Methods("GET")
|
||||
r.HandleFunc("/users/me/boards/insights", a.sessionRequired(a.handleUserBoardsInsights)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleTeamBoardsInsights(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards/insights handleTeamBoardsInsights
|
||||
//
|
||||
// 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"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
userID := getUserID(r)
|
||||
query := r.URL.Query()
|
||||
timeRange := query.Get("time_range")
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("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 {
|
||||
message := fmt.Sprintf("error converting page parameter to integer: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
if page < 0 {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("Invalid page parameter"))
|
||||
}
|
||||
|
||||
perPage, err := strconv.Atoi(query.Get("per_page"))
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("error converting per_page parameter to integer: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
if perPage < 0 {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("Invalid page parameter"))
|
||||
}
|
||||
|
||||
userTimezone, aErr := a.app.GetUserTimezone(userID)
|
||||
if aErr != nil {
|
||||
message := fmt.Sprintf("Error getting time zone of user: %s", aErr)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
userLocation, _ := time.LoadLocation(userTimezone)
|
||||
if userLocation == nil {
|
||||
userLocation = time.Now().UTC().Location()
|
||||
}
|
||||
// get unix time for duration
|
||||
startTime, appErr := mm_model.GetStartOfDayForTimeRange(timeRange, userLocation)
|
||||
if appErr != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(appErr.Message))
|
||||
return
|
||||
}
|
||||
|
||||
boardsInsights, err := a.app.GetTeamBoardsInsights(userID, teamID, &mm_model.InsightsOpts{
|
||||
StartUnixMilli: mm_model.GetMillisForTime(*startTime),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(boardsInsights)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("teamBoardsInsightCount", len(boardsInsights.Items))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUserBoardsInsights(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /users/me/boards/insights getUserBoardsInsights
|
||||
//
|
||||
// Returns user 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"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
query := r.URL.Query()
|
||||
teamID := query.Get("team_id")
|
||||
timeRange := query.Get("time_range")
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("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, model.NewErrBadRequest("error converting page parameter to integer"))
|
||||
return
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("Invalid page parameter"))
|
||||
}
|
||||
perPage, err := strconv.Atoi(query.Get("per_page"))
|
||||
if err != nil {
|
||||
message := fmt.Sprintf("error converting per_page parameter to integer: %s", err)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
if perPage < 0 {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("Invalid page parameter"))
|
||||
}
|
||||
userTimezone, aErr := a.app.GetUserTimezone(userID)
|
||||
if aErr != nil {
|
||||
message := fmt.Sprintf("Error getting time zone of user: %s", aErr)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
userLocation, _ := time.LoadLocation(userTimezone)
|
||||
if userLocation == nil {
|
||||
userLocation = time.Now().UTC().Location()
|
||||
}
|
||||
// get unix time for duration
|
||||
startTime, appErr := mm_model.GetStartOfDayForTimeRange(timeRange, userLocation)
|
||||
if appErr != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(appErr.Message))
|
||||
return
|
||||
}
|
||||
|
||||
boardsInsights, err := a.app.GetUserBoardsInsights(userID, teamID, &mm_model.InsightsOpts{
|
||||
StartUnixMilli: mm_model.GetMillisForTime(*startTime),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(boardsInsights)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("userBoardInsightCount", len(boardsInsights.Items))
|
||||
auditRec.Success()
|
||||
}
|
||||
91
server/boards/api/limits.go
Обычный файл
91
server/boards/api/limits.go
Обычный файл
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *API) registerLimitsRoutes(r *mux.Router) {
|
||||
// limits
|
||||
r.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}/notifyadminupgrade", a.sessionRequired(a.handleNotifyAdminUpgrade)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
func (a *API) handleCloudLimits(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /limits cloudLimits
|
||||
//
|
||||
// Fetches the cloud limits of the server.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardsCloudLimits"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardsCloudLimits, err := a.app.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(boardsCloudLimits)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (a *API) handleNotifyAdminUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /api/v2/teams/{teamID}/notifyadminupgrade handleNotifyAdminUpgrade
|
||||
//
|
||||
// Notifies admins for upgrade request.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
if err := a.app.NotifyPortalAdminsUpgradeRequest(teamID); err != nil {
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
}
|
||||
549
server/boards/api/members.go
Обычный файл
549
server/boards/api/members.go
Обычный файл
@@ -0,0 +1,549 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerMembersRoutes(r *mux.Router) {
|
||||
// Member APIs
|
||||
r.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleGetMembersForBoard)).Methods("GET")
|
||||
r.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleAddMember)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/members/{userID}", a.sessionRequired(a.handleUpdateMember)).Methods("PUT")
|
||||
r.HandleFunc("/boards/{boardID}/members/{userID}", a.sessionRequired(a.handleDeleteMember)).Methods("DELETE")
|
||||
r.HandleFunc("/boards/{boardID}/join", a.sessionRequired(a.handleJoinBoard)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/leave", a.sessionRequired(a.handleLeaveBoard)).Methods("POST")
|
||||
}
|
||||
|
||||
func (a *API) handleGetMembersForBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/members getMembersForBoard
|
||||
//
|
||||
// Returns the members of the board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/BoardMember"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board members"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getMembersForBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
members, err := a.app.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetMembersForBoard",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.Int("membersCount", len(members)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleAddMember(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/members addMember
|
||||
//
|
||||
// Adds a new member to a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: membership to replace the current one with
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardMember"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/BoardMember'
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) &&
|
||||
!(board.Type == model.BoardTypeOpen && a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardProperties)) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members"))
|
||||
return
|
||||
}
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBoardMember *model.BoardMember
|
||||
if err = json.Unmarshal(requestBody, &reqBoardMember); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if reqBoardMember.UserID == "" {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("empty userID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(reqBoardMember.UserID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
newBoardMember := &model.BoardMember{
|
||||
UserID: reqBoardMember.UserID,
|
||||
BoardID: boardID,
|
||||
SchemeEditor: reqBoardMember.SchemeEditor,
|
||||
SchemeAdmin: reqBoardMember.SchemeAdmin,
|
||||
SchemeViewer: reqBoardMember.SchemeViewer,
|
||||
SchemeCommenter: reqBoardMember.SchemeCommenter,
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "addMember", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("addedUserID", reqBoardMember.UserID)
|
||||
|
||||
member, err := a.app.AddMemberToBoard(newBoardMember)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("AddMember",
|
||||
mlog.String("boardID", board.ID),
|
||||
mlog.String("addedUserID", reqBoardMember.UserID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(member)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/join joinBoard
|
||||
//
|
||||
// Become a member of a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: allow_admin
|
||||
// in: path
|
||||
// description: allows admin users to join private boards
|
||||
// required: false
|
||||
// type: boolean
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/BoardMember'
|
||||
// '404':
|
||||
// description: board not found
|
||||
// '403':
|
||||
// description: access denied
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
query := r.URL.Query()
|
||||
allowAdmin := query.Has("allow_admin")
|
||||
|
||||
userID := getUserID(r)
|
||||
if userID == "" {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("missing user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if board.Type != model.BoardTypeOpen {
|
||||
if !allowAdmin || !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionManageTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("cannot join a non Open board"))
|
||||
return
|
||||
}
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("guests not allowed to join boards"))
|
||||
return
|
||||
}
|
||||
|
||||
newBoardMember := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeAdmin: board.MinimumRole == model.BoardRoleAdmin || isAdmin,
|
||||
SchemeEditor: board.MinimumRole == model.BoardRoleNone || board.MinimumRole == model.BoardRoleEditor,
|
||||
SchemeCommenter: board.MinimumRole == model.BoardRoleCommenter,
|
||||
SchemeViewer: board.MinimumRole == model.BoardRoleViewer,
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "joinBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("addedUserID", userID)
|
||||
|
||||
member, err := a.app.AddMemberToBoard(newBoardMember)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("JoinBoard",
|
||||
mlog.String("boardID", board.ID),
|
||||
mlog.String("addedUserID", userID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(member)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleLeaveBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/leave leaveBoard
|
||||
//
|
||||
// Remove your own membership from a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '404':
|
||||
// description: board not found
|
||||
// '403':
|
||||
// description: access denied
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
if userID == "" {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("invalid session"))
|
||||
return
|
||||
}
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to board"))
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.app.GetBoard(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "leaveBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("addedUserID", userID)
|
||||
|
||||
err = a.app.DeleteBoardMember(boardID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("LeaveBoard",
|
||||
mlog.String("boardID", board.ID),
|
||||
mlog.String("addedUserID", userID),
|
||||
)
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateMember(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PUT /boards/{boardID}/members/{userID} updateMember
|
||||
//
|
||||
// Updates a board member
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: userID
|
||||
// in: path
|
||||
// description: User ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: membership to replace the current one with
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardMember"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// $ref: '#/definitions/BoardMember'
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
paramsUserID := mux.Vars(r)["userID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBoardMember *model.BoardMember
|
||||
if err = json.Unmarshal(requestBody, &reqBoardMember); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
newBoardMember := &model.BoardMember{
|
||||
UserID: paramsUserID,
|
||||
BoardID: boardID,
|
||||
SchemeAdmin: reqBoardMember.SchemeAdmin,
|
||||
SchemeEditor: reqBoardMember.SchemeEditor,
|
||||
SchemeCommenter: reqBoardMember.SchemeCommenter,
|
||||
SchemeViewer: reqBoardMember.SchemeViewer,
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(paramsUserID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if isGuest {
|
||||
newBoardMember.SchemeAdmin = false
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchMember", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("patchedUserID", paramsUserID)
|
||||
|
||||
member, err := a.app.UpdateBoardMember(newBoardMember)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("PatchMember",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("patchedUserID", paramsUserID),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(member)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteMember(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation DELETE /boards/{boardID}/members/{userID} deleteMember
|
||||
//
|
||||
// Deletes a member from a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: userID
|
||||
// in: path
|
||||
// description: User ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
paramsUserID := mux.Vars(r)["userID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if _, err := a.app.GetBoard(boardID); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to modify board members"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "deleteMember", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
auditRec.AddMeta("addedUserID", paramsUserID)
|
||||
|
||||
deleteErr := a.app.DeleteBoardMember(boardID, paramsUserID)
|
||||
if deleteErr != nil {
|
||||
a.errorResponse(w, r, deleteErr)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("DeleteMember",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("addedUserID", paramsUserID),
|
||||
)
|
||||
|
||||
// response
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
87
server/boards/api/onboarding.go
Обычный файл
87
server/boards/api/onboarding.go
Обычный файл
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *API) registerOnboardingRoutes(r *mux.Router) {
|
||||
// Onboarding tour endpoints APIs
|
||||
r.HandleFunc("/teams/{teamID}/onboard", a.sessionRequired(a.handleOnboard)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
func (a *API) handleOnboard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /team/{teamID}/onboard onboard
|
||||
//
|
||||
// Onboards a user on Boards.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// teamID:
|
||||
// type: string
|
||||
// description: Team ID
|
||||
// boardID:
|
||||
// type: string
|
||||
// description: Board ID
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create board"))
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to create board"))
|
||||
return
|
||||
}
|
||||
|
||||
teamID, boardID, err := a.app.PrepareOnboardingTour(userID, teamID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]string{
|
||||
"teamID": teamID,
|
||||
"boardID": boardID,
|
||||
}
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
355
server/boards/api/search.go
Обычный файл
355
server/boards/api/search.go
Обычный файл
@@ -0,0 +1,355 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerSearchRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/teams/{teamID}/channels", a.sessionRequired(a.handleSearchMyChannels)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}/boards/search/linkable", a.sessionRequired(a.handleSearchLinkableBoards)).Methods("GET")
|
||||
r.HandleFunc("/boards/search", a.sessionRequired(a.handleSearchAllBoards)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleSearchMyChannels(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/channels searchMyChannels
|
||||
//
|
||||
// Returns the user available channels
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: search
|
||||
// in: query
|
||||
// description: string to filter channels list
|
||||
// required: false
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Channel"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
searchQuery := query.Get("search")
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchMyChannels", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
channels, err := a.app.SearchUserChannels(teamID, userID, searchQuery)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetUserChannels",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("channelsCount", len(channels)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(channels)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("channelsCount", len(channels))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards/search searchBoards
|
||||
//
|
||||
// Returns the boards that match with a search term in the team
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: The search term. Must have at least one character
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: field
|
||||
// in: query
|
||||
// description: The field to search on for search term. Can be `title`, `property_name`. Defaults to `title`
|
||||
// required: false
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
var err error
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
term := r.URL.Query().Get("q")
|
||||
searchFieldText := r.URL.Query().Get("field")
|
||||
searchField := model.BoardSearchFieldTitle
|
||||
if searchFieldText != "" {
|
||||
searchField, err = model.BoardSearchFieldFromString(searchFieldText)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
if term == "" {
|
||||
jsonStringResponse(w, http.StatusOK, "[]")
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, searchField, userID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("SearchBoards",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("boardsCount", len(boards)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(boards)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("boardsCount", len(boards))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleSearchLinkableBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards/search/linkable searchLinkableBoards
|
||||
//
|
||||
// Returns the boards that match with a search term in the team and the
|
||||
// user has permission to manage members
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: The search term. Must have at least one character
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
|
||||
return
|
||||
}
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
term := r.URL.Query().Get("q")
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
if term == "" {
|
||||
jsonStringResponse(w, http.StatusOK, "[]")
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchLinkableBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
linkableBoards := []*model.Board{}
|
||||
for _, board := range boards {
|
||||
if a.permissions.HasPermissionToBoard(userID, board.ID, model.PermissionManageBoardRoles) {
|
||||
linkableBoards = append(linkableBoards, board)
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Debug("SearchLinkableBoards",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("boardsCount", len(linkableBoards)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(linkableBoards)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("boardsCount", len(linkableBoards))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/search searchAllBoards
|
||||
//
|
||||
// Returns the boards that match with a search term
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: The search term. Must have at least one character
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
term := r.URL.Query().Get("q")
|
||||
userID := getUserID(r)
|
||||
|
||||
if term == "" {
|
||||
jsonStringResponse(w, http.StatusOK, "[]")
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchAllBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, model.BoardSearchFieldTitle, userID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("SearchAllBoards",
|
||||
mlog.Int("boardsCount", len(boards)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(boards)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("boardsCount", len(boards))
|
||||
auditRec.Success()
|
||||
}
|
||||
184
server/boards/api/sharing.go
Обычный файл
184
server/boards/api/sharing.go
Обычный файл
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var ErrTurningOnSharing = errors.New("turning on sharing for board failed, see log for details")
|
||||
|
||||
func (a *API) registerSharingRoutes(r *mux.Router) {
|
||||
// Sharing APIs
|
||||
r.HandleFunc("/boards/{boardID}/sharing", a.sessionRequired(a.handlePostSharing)).Methods("POST")
|
||||
r.HandleFunc("/boards/{boardID}/sharing", a.sessionRequired(a.handleGetSharing)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/sharing getSharing
|
||||
//
|
||||
// Returns sharing information for a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Sharing"
|
||||
// '404':
|
||||
// description: board not found
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
|
||||
userID := getUserID(r)
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionShareBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to sharing the board"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getSharing", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
sharing, err := a.app.GetSharing(boardID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sharingData, err := json.Marshal(sharing)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, sharingData)
|
||||
|
||||
a.logger.Debug("GET sharing",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("shareID", sharing.ID),
|
||||
mlog.Bool("enabled", sharing.Enabled),
|
||||
)
|
||||
auditRec.AddMeta("shareID", sharing.ID)
|
||||
auditRec.AddMeta("enabled", sharing.Enabled)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePostSharing(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /boards/{boardID}/sharing postSharing
|
||||
//
|
||||
// Sets sharing information for a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: sharing information for a root block
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Sharing"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
|
||||
userID := getUserID(r)
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionShareBoard) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to sharing the board"))
|
||||
return
|
||||
}
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var sharing model.Sharing
|
||||
err = json.Unmarshal(requestBody, &sharing)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Stamp boardID from the URL
|
||||
sharing.ID = boardID
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "postSharing", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("shareID", sharing.ID)
|
||||
auditRec.AddMeta("enabled", sharing.Enabled)
|
||||
|
||||
// Stamp ModifiedBy
|
||||
modifiedBy := userID
|
||||
if userID == model.SingleUser {
|
||||
modifiedBy = ""
|
||||
}
|
||||
sharing.ModifiedBy = modifiedBy
|
||||
|
||||
if userID == model.SingleUser {
|
||||
userID = ""
|
||||
}
|
||||
|
||||
if !a.app.GetClientConfig().EnablePublicSharedBoards {
|
||||
a.logger.Warn(
|
||||
"Attempt to turn on sharing for board via API failed, sharing off in configuration.",
|
||||
mlog.String("boardID", sharing.ID),
|
||||
mlog.String("userID", userID))
|
||||
a.errorResponse(w, r, ErrTurningOnSharing)
|
||||
return
|
||||
}
|
||||
|
||||
sharing.ModifiedBy = userID
|
||||
|
||||
err = a.app.UpsertSharing(sharing)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
a.logger.Debug("POST sharing", mlog.String("sharingID", sharing.ID))
|
||||
auditRec.Success()
|
||||
}
|
||||
74
server/boards/api/statistics.go
Обычный файл
74
server/boards/api/statistics.go
Обычный файл
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *API) registerStatisticsRoutes(r *mux.Router) {
|
||||
// statistics
|
||||
r.HandleFunc("/statistics", a.sessionRequired(a.handleStatistics)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleStatistics(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /statistics handleStatistics
|
||||
//
|
||||
// Fetches the statistic of the server.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardStatistics"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in standalone mode"))
|
||||
return
|
||||
}
|
||||
|
||||
// user must have right to access analytics
|
||||
userID := getUserID(r)
|
||||
if !a.permissions.HasPermissionTo(userID, mm_model.PermissionGetAnalytics) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied System Statistics"))
|
||||
return
|
||||
}
|
||||
|
||||
boardCount, err := a.app.GetBoardCount()
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
cardCount, err := a.app.GetUsedCardsCount()
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
stats := model.BoardsStatistics{
|
||||
Boards: int(boardCount),
|
||||
Cards: cardCount,
|
||||
}
|
||||
data, err := json.Marshal(stats)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
241
server/boards/api/subscriptions.go
Обычный файл
241
server/boards/api/subscriptions.go
Обычный файл
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerSubscriptionsRoutes(r *mux.Router) {
|
||||
// Subscription APIs
|
||||
r.HandleFunc("/subscriptions", a.sessionRequired(a.handleCreateSubscription)).Methods("POST")
|
||||
r.HandleFunc("/subscriptions/{blockID}/{subscriberID}", a.sessionRequired(a.handleDeleteSubscription)).Methods("DELETE")
|
||||
r.HandleFunc("/subscriptions/{subscriberID}", a.sessionRequired(a.handleGetSubscriptions)).Methods("GET")
|
||||
}
|
||||
|
||||
// subscriptions
|
||||
|
||||
func (a *API) handleCreateSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /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: 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"
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var sub model.Subscription
|
||||
|
||||
if err = json.Unmarshal(requestBody, &sub); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = sub.IsValid(); err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
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, model.NewErrBadRequest("userID and subscriberID mismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
// check for valid block
|
||||
_, bErr := a.app.GetBlockByID(sub.BlockID)
|
||||
if bErr != nil {
|
||||
message := fmt.Sprintf("invalid blockID: %s", bErr)
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(message))
|
||||
return
|
||||
}
|
||||
|
||||
subNew, err := a.app.CreateSubscription(&sub)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, 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, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation DELETE /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: 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"]
|
||||
|
||||
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, model.NewErrPermission("access denied"))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.app.DeleteSubscription(blockID, subscriberID); err != nil {
|
||||
a.errorResponse(w, r, 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 /subscriptions/{subscriberID} getSubscriptions
|
||||
//
|
||||
// Gets subscriptions for a user.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - 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"]
|
||||
|
||||
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, model.NewErrPermission("access denied"))
|
||||
return
|
||||
}
|
||||
|
||||
subs, err := a.app.GetSubscriptions(subscriberID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, 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, err)
|
||||
return
|
||||
}
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
|
||||
auditRec.AddMeta("subscription_count", len(subs))
|
||||
auditRec.Success()
|
||||
}
|
||||
60
server/boards/api/system.go
Обычный файл
60
server/boards/api/system.go
Обычный файл
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (a *API) registerSystemRoutes(r *mux.Router) {
|
||||
// System APIs
|
||||
r.HandleFunc("/hello", a.handleHello).Methods("GET")
|
||||
r.HandleFunc("/ping", a.handlePing).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleHello(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /hello hello
|
||||
//
|
||||
// Responds with `Hello` if the web service is running.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - text/plain
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
stringResponse(w, "Hello")
|
||||
}
|
||||
|
||||
func (a *API) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /ping ping
|
||||
//
|
||||
// Responds with server metadata if the web service is running.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
serverMetadata := a.app.GetServerMetadata()
|
||||
|
||||
if a.singleUserToken != "" {
|
||||
serverMetadata.SKU = "personal_desktop"
|
||||
}
|
||||
|
||||
if serverMetadata.Edition == "plugin" {
|
||||
serverMetadata.SKU = "suite"
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(serverMetadata)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
}
|
||||
|
||||
jsonStringResponse(w, 200, string(bytes))
|
||||
}
|
||||
146
server/boards/api/system_test.go
Обычный файл
146
server/boards/api/system_test.go
Обычный файл
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/app"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func TestHello(t *testing.T) {
|
||||
testAPI := API{logger: mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)}
|
||||
|
||||
t.Run("Returns 'Hello' on success", func(t *testing.T) {
|
||||
request, _ := http.NewRequest(http.MethodGet, "/hello", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handleHello(response, request)
|
||||
|
||||
got := response.Body.String()
|
||||
want := "Hello"
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
testAPI := API{logger: mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)}
|
||||
|
||||
t.Run("Returns metadata on success", func(t *testing.T) {
|
||||
request, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handlePing(response, request)
|
||||
|
||||
var got app.ServerMetadata
|
||||
err := json.NewDecoder(response.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to JSON decode response body %q", response.Body)
|
||||
}
|
||||
|
||||
want := app.ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: "",
|
||||
DBVersion: "",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sets SKU to 'personal_desktop' when in single-user mode", func(t *testing.T) {
|
||||
testAPI.singleUserToken = "abc-123-xyz-456"
|
||||
request, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handlePing(response, request)
|
||||
|
||||
var got app.ServerMetadata
|
||||
err := json.NewDecoder(response.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to JSON decode response body %q", response.Body)
|
||||
}
|
||||
|
||||
want := app.ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: "",
|
||||
DBVersion: "",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_desktop",
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sets SKU to 'suite' when in plugin mode", func(t *testing.T) {
|
||||
model.Edition = "plugin"
|
||||
request, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handlePing(response, request)
|
||||
|
||||
var got app.ServerMetadata
|
||||
err := json.NewDecoder(response.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to JSON decode response body %q", response.Body)
|
||||
}
|
||||
|
||||
want := app.ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: "plugin",
|
||||
DBType: "",
|
||||
DBVersion: "",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "suite",
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
368
server/boards/api/teams.go
Обычный файл
368
server/boards/api/teams.go
Обычный файл
@@ -0,0 +1,368 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func (a *API) registerTeamsRoutes(r *mux.Router) {
|
||||
// Team APIs
|
||||
r.HandleFunc("/teams", a.sessionRequired(a.handleGetTeams)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}", a.sessionRequired(a.handleGetTeam)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsers)).Methods("GET")
|
||||
r.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsersByID)).Methods("POST")
|
||||
r.HandleFunc("/teams/{teamID}/archive/export", a.sessionRequired(a.handleArchiveExportTeam)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleGetTeams(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams getTeams
|
||||
//
|
||||
// Returns information of all the teams
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Team"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
teams, err := a.app.GetTeamsForUser(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getTeams", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamCount", len(teams))
|
||||
|
||||
data, err := json.Marshal(teams)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetTeam(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID} getTeam
|
||||
//
|
||||
// Returns information of the root team
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Team"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
var team *model.Team
|
||||
var err error
|
||||
|
||||
if a.MattermostAuth {
|
||||
team, err = a.app.GetTeam(teamID)
|
||||
if model.IsErrNotFound(err) {
|
||||
a.errorResponse(w, r, model.NewErrUnauthorized("invalid team"))
|
||||
}
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
}
|
||||
} else {
|
||||
team, err = a.app.GetRootTeam()
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getTeam", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("resultTeamID", team.ID)
|
||||
|
||||
data, err := json.Marshal(team)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/regenerate_signup_token regenerateSignupToken
|
||||
//
|
||||
// Regenerates the signup token for the root team
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
if a.MattermostAuth {
|
||||
a.errorResponse(w, r, model.NewErrNotImplemented("not permitted in plugin mode"))
|
||||
return
|
||||
}
|
||||
|
||||
team, err := a.app.GetRootTeam()
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "regenerateSignupToken", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
team.SignupToken = utils.NewID(utils.IDTypeToken)
|
||||
|
||||
if err = a.app.UpsertTeamSignupToken(*team); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/users getTeamUsers
|
||||
//
|
||||
// Returns team users
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: search
|
||||
// in: query
|
||||
// description: string to filter users list
|
||||
// required: false
|
||||
// type: string
|
||||
// - name: exclude_bots
|
||||
// in: query
|
||||
// description: exclude bot users
|
||||
// required: false
|
||||
// type: boolean
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/User"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
userID := getUserID(r)
|
||||
query := r.URL.Query()
|
||||
searchQuery := query.Get("search")
|
||||
excludeBots := r.URL.Query().Get("exclude_bots") == True
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getUsers", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
asGuestUser := ""
|
||||
if isGuest {
|
||||
asGuestUser = userID
|
||||
}
|
||||
|
||||
users, err := a.app.SearchTeamUsers(teamID, searchQuery, asGuestUser, excludeBots)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(users)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("userCount", len(users))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetTeamUsersByID(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /teams/{teamID}/users getTeamUsersByID
|
||||
//
|
||||
// Returns a user[]
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: []UserIDs to return
|
||||
// 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"
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var userIDs []string
|
||||
if err = json.Unmarshal(requestBody, &userIDs); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getTeamUsersByID", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
var users []*model.User
|
||||
var error error
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("User IDs are empty"))
|
||||
return
|
||||
}
|
||||
|
||||
if userIDs[0] == model.SingleUser {
|
||||
ws, _ := a.app.GetRootTeam()
|
||||
now := utils.GetMillis()
|
||||
user := &model.User{
|
||||
ID: model.SingleUser,
|
||||
Username: model.SingleUser,
|
||||
Email: model.SingleUser,
|
||||
CreateAt: ws.UpdateAt,
|
||||
UpdateAt: now,
|
||||
}
|
||||
users = append(users, user)
|
||||
} else {
|
||||
users, error = a.app.GetUsersList(userIDs)
|
||||
if error != nil {
|
||||
a.errorResponse(w, r, error)
|
||||
return
|
||||
}
|
||||
|
||||
for i, u := range users {
|
||||
if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) {
|
||||
users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id)
|
||||
}
|
||||
if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) {
|
||||
users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usersList, err := json.Marshal(users)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, string(usersList))
|
||||
auditRec.Success()
|
||||
}
|
||||
104
server/boards/api/templates.go
Обычный файл
104
server/boards/api/templates.go
Обычный файл
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *API) registerTemplatesRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/templates getTemplates
|
||||
//
|
||||
// Returns team templates
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if teamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
|
||||
return
|
||||
}
|
||||
|
||||
isGuest, err := a.userIsGuest(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if isGuest {
|
||||
a.errorResponse(w, r, model.NewErrPermission("access denied to templates"))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getTemplates", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.GetTemplateBoards(teamID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
results := []*model.Board{}
|
||||
for _, board := range boards {
|
||||
if board.Type == model.BoardTypeOpen {
|
||||
results = append(results, board)
|
||||
} else if a.permissions.HasPermissionToBoard(userID, board.ID, model.PermissionViewBoard) {
|
||||
results = append(results, board)
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Debug("GetTemplates",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("boardsCount", len(results)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("templatesCount", len(results))
|
||||
auditRec.Success()
|
||||
}
|
||||
408
server/boards/api/users.go
Обычный файл
408
server/boards/api/users.go
Обычный файл
@@ -0,0 +1,408 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/audit"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func (a *API) registerUsersRoutes(r *mux.Router) {
|
||||
// Users APIs
|
||||
r.HandleFunc("/users", a.sessionRequired(a.handleGetUsersList)).Methods("POST")
|
||||
r.HandleFunc("/users/me", a.sessionRequired(a.handleGetMe)).Methods("GET")
|
||||
r.HandleFunc("/users/me/memberships", a.sessionRequired(a.handleGetMyMemberships)).Methods("GET")
|
||||
r.HandleFunc("/users/{userID}", a.sessionRequired(a.handleGetUser)).Methods("GET")
|
||||
r.HandleFunc("/users/{userID}/config", a.sessionRequired(a.handleUpdateUserConfig)).Methods(http.MethodPut)
|
||||
r.HandleFunc("/users/me/config", a.sessionRequired(a.handleGetUserPreferences)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (a *API) handleGetUsersList(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /users getUsersList
|
||||
//
|
||||
// Returns a user[]
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: userID
|
||||
// in: path
|
||||
// description: User ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/User"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var userIDs []string
|
||||
if err = json.Unmarshal(requestBody, &userIDs); err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getUsersList", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelAuth, auditRec)
|
||||
|
||||
var users []*model.User
|
||||
var error error
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest("User IDs are empty"))
|
||||
return
|
||||
}
|
||||
|
||||
if userIDs[0] == model.SingleUser {
|
||||
ws, _ := a.app.GetRootTeam()
|
||||
now := utils.GetMillis()
|
||||
user := &model.User{
|
||||
ID: model.SingleUser,
|
||||
Username: model.SingleUser,
|
||||
Email: model.SingleUser,
|
||||
CreateAt: ws.UpdateAt,
|
||||
UpdateAt: now,
|
||||
}
|
||||
users = append(users, user)
|
||||
} else {
|
||||
users, error = a.app.GetUsersList(userIDs)
|
||||
if error != nil {
|
||||
a.errorResponse(w, r, error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
usersList, err := json.Marshal(users)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonStringResponse(w, http.StatusOK, string(usersList))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /users/me getMe
|
||||
//
|
||||
// Returns the currently logged-in user
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: false
|
||||
// type: string
|
||||
// - name: channelID
|
||||
// in: path
|
||||
// description: Channel ID
|
||||
// required: false
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/User"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
query := r.URL.Query()
|
||||
teamID := query.Get("teamID")
|
||||
channelID := query.Get("channelID")
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
var user *model.User
|
||||
var err error
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getMe", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
if userID == model.SingleUser {
|
||||
ws, _ := a.app.GetRootTeam()
|
||||
now := utils.GetMillis()
|
||||
user = &model.User{
|
||||
ID: model.SingleUser,
|
||||
Username: model.SingleUser,
|
||||
Email: model.SingleUser,
|
||||
CreateAt: ws.UpdateAt,
|
||||
UpdateAt: now,
|
||||
}
|
||||
} else {
|
||||
user, err = a.app.GetUser(userID)
|
||||
if err != nil {
|
||||
// ToDo: wrap with an invalid token error
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if teamID != "" && a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionManageTeam) {
|
||||
user.Permissions = append(user.Permissions, model.PermissionManageTeam.Id)
|
||||
}
|
||||
if a.permissions.HasPermissionTo(userID, model.PermissionManageSystem) {
|
||||
user.Permissions = append(user.Permissions, model.PermissionManageSystem.Id)
|
||||
}
|
||||
if channelID != "" && a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionCreatePost) {
|
||||
user.Permissions = append(user.Permissions, model.PermissionCreatePost.Id)
|
||||
}
|
||||
|
||||
userData, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
jsonBytesResponse(w, http.StatusOK, userData)
|
||||
|
||||
auditRec.AddMeta("userID", user.ID)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetMyMemberships(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /users/me/memberships getMyMemberships
|
||||
//
|
||||
// Returns the currently users board memberships
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/BoardMember"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getMyBoardMemberships", audit.Fail)
|
||||
auditRec.AddMeta("userID", userID)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
members, err := a.app.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
membersData, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, membersData)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /users/{userID} getUser
|
||||
//
|
||||
// Returns a user
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: userID
|
||||
// in: path
|
||||
// description: User ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/User"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["userID"]
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("userID", userID)
|
||||
|
||||
user, err := a.app.GetUser(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
canSeeUser, err := a.app.CanSeeUser(session.UserID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if !canSeeUser {
|
||||
a.errorResponse(w, r, model.NewErrNotFound("user ID="+userID))
|
||||
return
|
||||
}
|
||||
|
||||
userData, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, userData)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateUserConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PATCH /users/{userID}/config updateUserConfig
|
||||
//
|
||||
// Updates user config
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: userID
|
||||
// in: path
|
||||
// description: User ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: User config patch to apply
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UserPreferencesPatch"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var patch *model.UserPreferencesPatch
|
||||
err = json.Unmarshal(requestBody, &patch)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["userID"]
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "updateUserConfig", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
// a user can update only own config
|
||||
if userID != session.UserID {
|
||||
a.errorResponse(w, r, model.NewErrForbidden(""))
|
||||
return
|
||||
}
|
||||
|
||||
updatedConfig, err := a.app.UpdateUserConfig(userID, *patch)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(updatedConfig)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetUserPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /users/me/config getUserConfig
|
||||
//
|
||||
// Returns an array of user preferences
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/Preferences"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
userID := getUserID(r)
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getUserConfig", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
preferences, err := a.app.GetUserPreferences(userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(preferences)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
auditRec.Success()
|
||||
}
|
||||
119
server/boards/app/app.go
Обычный файл
119
server/boards/app/app.go
Обычный файл
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/metrics"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/store"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/webhook"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/ws"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
blockChangeNotifierQueueSize = 1000
|
||||
blockChangeNotifierPoolSize = 10
|
||||
blockChangeNotifierShutdownTimeout = time.Second * 10
|
||||
)
|
||||
|
||||
type servicesAPI interface {
|
||||
GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error)
|
||||
}
|
||||
|
||||
type ReadCloseSeeker = filestore.ReadCloseSeeker
|
||||
|
||||
type fileBackend interface {
|
||||
Reader(path string) (ReadCloseSeeker, error)
|
||||
FileExists(path string) (bool, error)
|
||||
CopyFile(oldPath, newPath string) error
|
||||
MoveFile(oldPath, newPath string) error
|
||||
WriteFile(fr io.Reader, path string) (int64, error)
|
||||
RemoveFile(path string) error
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
Auth *auth.Auth
|
||||
Store store.Store
|
||||
FilesBackend fileBackend
|
||||
Webhook *webhook.Client
|
||||
Metrics *metrics.Metrics
|
||||
Notifications *notify.Service
|
||||
Logger mlog.LoggerIFace
|
||||
Permissions permissions.PermissionsService
|
||||
SkipTemplateInit bool
|
||||
ServicesAPI servicesAPI
|
||||
}
|
||||
|
||||
type App struct {
|
||||
config *config.Configuration
|
||||
store store.Store
|
||||
auth *auth.Auth
|
||||
wsAdapter ws.Adapter
|
||||
filesBackend fileBackend
|
||||
webhook *webhook.Client
|
||||
metrics *metrics.Metrics
|
||||
notifications *notify.Service
|
||||
logger mlog.LoggerIFace
|
||||
permissions permissions.PermissionsService
|
||||
blockChangeNotifier *utils.CallbackQueue
|
||||
servicesAPI servicesAPI
|
||||
|
||||
cardLimitMux sync.RWMutex
|
||||
cardLimit int
|
||||
}
|
||||
|
||||
func (a *App) SetConfig(config *config.Configuration) {
|
||||
a.config = config
|
||||
}
|
||||
|
||||
func (a *App) GetConfig() *config.Configuration {
|
||||
return a.config
|
||||
}
|
||||
|
||||
func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App {
|
||||
app := &App{
|
||||
config: config,
|
||||
store: services.Store,
|
||||
auth: services.Auth,
|
||||
wsAdapter: wsAdapter,
|
||||
filesBackend: services.FilesBackend,
|
||||
webhook: services.Webhook,
|
||||
metrics: services.Metrics,
|
||||
notifications: services.Notifications,
|
||||
logger: services.Logger,
|
||||
permissions: services.Permissions,
|
||||
blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger),
|
||||
servicesAPI: services.ServicesAPI,
|
||||
}
|
||||
app.initialize(services.SkipTemplateInit)
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) CardLimit() int {
|
||||
a.cardLimitMux.RLock()
|
||||
defer a.cardLimitMux.RUnlock()
|
||||
return a.cardLimit
|
||||
}
|
||||
|
||||
func (a *App) SetCardLimit(cardLimit int) {
|
||||
a.cardLimitMux.Lock()
|
||||
defer a.cardLimitMux.Unlock()
|
||||
a.cardLimit = cardLimit
|
||||
}
|
||||
|
||||
func (a *App) GetLicense() *mm_model.License {
|
||||
return a.store.GetLicense()
|
||||
}
|
||||
26
server/boards/app/app_test.go
Обычный файл
26
server/boards/app/app_test.go
Обычный файл
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
)
|
||||
|
||||
func TestSetConfig(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("Test Update Config", func(t *testing.T) {
|
||||
require.False(t, th.App.config.EnablePublicSharedBoards)
|
||||
newConfiguration := config.Configuration{}
|
||||
newConfiguration.EnablePublicSharedBoards = true
|
||||
th.App.SetConfig(&newConfiguration)
|
||||
|
||||
require.True(t, th.App.config.EnablePublicSharedBoards)
|
||||
})
|
||||
}
|
||||
234
server/boards/app/auth.go
Обычный файл
234
server/boards/app/auth.go
Обычный файл
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
DaysPerMonth = 30
|
||||
DaysPerWeek = 7
|
||||
HoursPerDay = 24
|
||||
MinutesPerHour = 60
|
||||
SecondsPerMinute = 60
|
||||
)
|
||||
|
||||
// GetSession Get a user active session and refresh the session if is needed.
|
||||
func (a *App) GetSession(token string) (*model.Session, error) {
|
||||
return a.auth.GetSession(token)
|
||||
}
|
||||
|
||||
// IsValidReadToken validates the read token for a block.
|
||||
func (a *App) IsValidReadToken(boardID string, readToken string) (bool, error) {
|
||||
return a.auth.IsValidReadToken(boardID, readToken)
|
||||
}
|
||||
|
||||
// GetRegisteredUserCount returns the number of registered users.
|
||||
func (a *App) GetRegisteredUserCount() (int, error) {
|
||||
return a.store.GetRegisteredUserCount()
|
||||
}
|
||||
|
||||
// GetDailyActiveUsers returns the number of daily active users.
|
||||
func (a *App) GetDailyActiveUsers() (int, error) {
|
||||
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay)
|
||||
return a.store.GetActiveUserCount(secondsAgo)
|
||||
}
|
||||
|
||||
// GetWeeklyActiveUsers returns the number of weekly active users.
|
||||
func (a *App) GetWeeklyActiveUsers() (int, error) {
|
||||
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerWeek)
|
||||
return a.store.GetActiveUserCount(secondsAgo)
|
||||
}
|
||||
|
||||
// GetMonthlyActiveUsers returns the number of monthly active users.
|
||||
func (a *App) GetMonthlyActiveUsers() (int, error) {
|
||||
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerMonth)
|
||||
return a.store.GetActiveUserCount(secondsAgo)
|
||||
}
|
||||
|
||||
// GetUser gets an existing active user by id.
|
||||
func (a *App) GetUser(id string) (*model.User, error) {
|
||||
if len(id) < 1 {
|
||||
return nil, errors.New("no user ID")
|
||||
}
|
||||
|
||||
user, err := a.store.GetUserByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to find user")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUsersList(userIDs []string) ([]*model.User, error) {
|
||||
if len(userIDs) == 0 {
|
||||
return nil, errors.New("No User IDs")
|
||||
}
|
||||
|
||||
users, err := a.store.GetUsersList(userIDs, a.config.ShowEmailAddress, a.config.ShowFullName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to find users")
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Login create a new user session if the authentication data is valid.
|
||||
func (a *App) Login(username, email, password, mfaToken string) (string, error) {
|
||||
var user *model.User
|
||||
if username != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByUsername(username)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
return "", errors.Wrap(err, "invalid username or password")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil && email != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByEmail(email)
|
||||
if err != nil && model.IsErrNotFound(err) {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
return "", errors.Wrap(err, "invalid username or password")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
if !auth.ComparePassword(user.Password, password) {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID))
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
authService := user.AuthService
|
||||
if authService == "" {
|
||||
authService = "native"
|
||||
}
|
||||
|
||||
session := model.Session{
|
||||
ID: utils.NewID(utils.IDTypeSession),
|
||||
Token: utils.NewID(utils.IDTypeToken),
|
||||
UserID: user.ID,
|
||||
AuthService: authService,
|
||||
Props: map[string]interface{}{},
|
||||
}
|
||||
err := a.store.CreateSession(&session)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "unable to create session")
|
||||
}
|
||||
|
||||
a.metrics.IncrementLoginCount(1)
|
||||
|
||||
// TODO: MFA verification
|
||||
return session.Token, nil
|
||||
}
|
||||
|
||||
// Logout invalidates the user session.
|
||||
func (a *App) Logout(sessionID string) error {
|
||||
err := a.store.DeleteSession(sessionID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to delete the session")
|
||||
}
|
||||
|
||||
a.metrics.IncrementLogoutCount(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterUser creates a new user if the provided data is valid.
|
||||
func (a *App) RegisterUser(username, email, password string) error {
|
||||
var user *model.User
|
||||
if username != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByUsername(username)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if user != nil {
|
||||
return errors.New("The username already exists")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil && email != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByEmail(email)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if user != nil {
|
||||
return errors.New("The email already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move this into the config
|
||||
passwordSettings := auth.PasswordSettings{
|
||||
MinimumLength: 6,
|
||||
}
|
||||
|
||||
err := auth.IsPasswordValid(password, passwordSettings)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid password")
|
||||
}
|
||||
|
||||
_, err = a.store.CreateUser(&model.User{
|
||||
ID: utils.NewID(utils.IDTypeUser),
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: auth.HashPassword(password),
|
||||
MfaSecret: "",
|
||||
AuthService: a.config.AuthMode,
|
||||
AuthData: "",
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to create the new user")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateUserPassword(username, password string) error {
|
||||
err := a.store.UpdateUserPassword(username, auth.HashPassword(password))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ChangePassword(userID, oldPassword, newPassword string) error {
|
||||
var user *model.User
|
||||
if userID != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "invalid username or password")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
if !auth.ComparePassword(user.Password, oldPassword) {
|
||||
a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID))
|
||||
return errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
err := a.store.UpdateUserPasswordByID(userID, auth.HashPassword(newPassword))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to update password")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
192
server/boards/app/auth_test.go
Обычный файл
192
server/boards/app/auth_test.go
Обычный файл
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
var mockUser = &model.User{
|
||||
ID: utils.NewID(utils.IDTypeUser),
|
||||
Username: "testUsername",
|
||||
Email: "testEmail",
|
||||
Password: auth.HashPassword("testPassword"),
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
email string
|
||||
password string
|
||||
mfa string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", "", "", true},
|
||||
{"fail, invalid username", "badUsername", "", "", "", true},
|
||||
{"fail, invalid email", "", "badEmail", "", "", true},
|
||||
{"fail, invalid password", "testUsername", "", "badPassword", "", true},
|
||||
{"success, using username", "testUsername", "", "testPassword", "", false},
|
||||
{"success, using email", "", "testEmail", "testPassword", "", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByUsername("badUsername").Return(nil, errors.New("Bad Username"))
|
||||
th.Store.EXPECT().GetUserByEmail("badEmail").Return(nil, errors.New("Bad Email"))
|
||||
th.Store.EXPECT().GetUserByUsername("testUsername").Return(mockUser, nil).Times(2)
|
||||
th.Store.EXPECT().GetUserByEmail("testEmail").Return(mockUser, nil)
|
||||
th.Store.EXPECT().CreateSession(gomock.Any()).Return(nil).Times(2)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
token, err := th.App.Login(test.userName, test.email, test.password, test.mfa)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
id string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing id", "", true},
|
||||
{"fail, invalid id", "badID", true},
|
||||
{"success", "goodID", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("Bad Id"))
|
||||
th.Store.EXPECT().GetUserByID("goodID").Return(mockUser, nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
token, err := th.App.GetUser(test.id)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUser(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
email string
|
||||
password string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", "", true},
|
||||
{"fail, username exists", "existingUsername", "", "", true},
|
||||
{"fail, email exists", "", "existingEmail", "", true},
|
||||
{"fail, invalid password", "newUsername", "", "test", true},
|
||||
{"success, using email", "", "newEmail", "testPassword", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByUsername("existingUsername").Return(mockUser, nil)
|
||||
th.Store.EXPECT().GetUserByUsername("newUsername").Return(mockUser, errors.New("user not found"))
|
||||
th.Store.EXPECT().GetUserByEmail("existingEmail").Return(mockUser, nil)
|
||||
th.Store.EXPECT().GetUserByEmail("newEmail").Return(nil, model.NewErrNotFound("user"))
|
||||
th.Store.EXPECT().CreateUser(gomock.Any()).Return(nil, nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
err := th.App.RegisterUser(test.userName, test.email, test.password)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPassword(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
password string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", true},
|
||||
{"fail, invalid username", "badUsername", "", true},
|
||||
{"success, username", "testUsername", "testPassword", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().UpdateUserPassword("", gomock.Any()).Return(errors.New("user not found"))
|
||||
th.Store.EXPECT().UpdateUserPassword("badUsername", gomock.Any()).Return(errors.New("user not found"))
|
||||
th.Store.EXPECT().UpdateUserPassword("testUsername", gomock.Any()).Return(nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
err := th.App.UpdateUserPassword(test.userName, test.password)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
oldPassword string
|
||||
password string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", "", true},
|
||||
{"fail, invalid userId", "badID", "", "", true},
|
||||
{"fail, invalid password", mockUser.ID, "wrongPassword", "newPassword", true},
|
||||
{"success, using username", mockUser.ID, "testPassword", "newPassword", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("userID not found"))
|
||||
th.Store.EXPECT().GetUserByID(mockUser.ID).Return(mockUser, nil).Times(2)
|
||||
th.Store.EXPECT().UpdateUserPasswordByID(mockUser.ID, gomock.Any()).Return(nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
err := th.App.ChangePassword(test.userName, test.oldPassword, test.password)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
588
server/boards/app/blocks.go
Обычный файл
588
server/boards/app/blocks.go
Обычный файл
@@ -0,0 +1,588 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards")
|
||||
|
||||
func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]*model.Block, error) {
|
||||
if boardID == "" {
|
||||
return []*model.Block{}, nil
|
||||
}
|
||||
|
||||
if blockType != "" && parentID != "" {
|
||||
return a.store.GetBlocksWithParentAndType(boardID, parentID, blockType)
|
||||
}
|
||||
|
||||
if blockType != "" {
|
||||
return a.store.GetBlocksWithType(boardID, blockType)
|
||||
}
|
||||
|
||||
return a.store.GetBlocksWithParent(boardID, parentID)
|
||||
}
|
||||
|
||||
func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTemplate bool) ([]*model.Block, error) {
|
||||
board, err := a.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if board == nil {
|
||||
return nil, fmt.Errorf("cannot fetch board %s for DuplicateBlock: %w", boardID, err)
|
||||
}
|
||||
|
||||
blocks, err := a.store.DuplicateBlock(boardID, blockID, userID, asTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
for _, block := range blocks {
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed duplicating a block",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return blocks, err
|
||||
}
|
||||
|
||||
func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) (*model.Block, error) {
|
||||
return a.PatchBlockAndNotify(blockID, blockPatch, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) PatchBlockAndNotify(blockID string, blockPatch *model.BlockPatch, modifiedByID string, disableNotify bool) (*model.Block, error) {
|
||||
oldBlock, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, lErr := a.ContainsLimitedBlocks([]*model.Block{oldBlock})
|
||||
if lErr != nil {
|
||||
return nil, lErr
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return nil, model.ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(oldBlock.BoardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = a.store.PatchBlock(blockID, blockPatch, modifiedByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.metrics.IncrementBlocksPatched(1)
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
// broadcast on websocket
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
|
||||
// broadcast on webhooks
|
||||
a.webhook.NotifyUpdate(block)
|
||||
|
||||
// send notifications
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Update, block, oldBlock, modifiedByID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error {
|
||||
return a.PatchBlocksAndNotify(teamID, blockPatches, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) PatchBlocksAndNotify(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string, disableNotify bool) error {
|
||||
oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, err := a.ContainsLimitedBlocks(oldBlocks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return model.ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.metrics.IncrementBlocksPatched(len(oldBlocks))
|
||||
for i, blockID := range blockPatches.BlockIDs {
|
||||
newBlock, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, newBlock)
|
||||
a.webhook.NotifyUpdate(newBlock)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Update, newBlock, oldBlocks[i], modifiedByID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlock(block *model.Block, modifiedByID string) error {
|
||||
return a.InsertBlockAndNotify(block, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) InsertBlockAndNotify(block *model.Block, modifiedByID string, disableNotify bool) error {
|
||||
board, bErr := a.store.GetBoard(block.BoardID)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
|
||||
err := a.store.InsertBlock(block, modifiedByID)
|
||||
if err == nil {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(block)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Add, block, nil, modifiedByID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after inserting a block",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) isWithinViewsLimit(boardID string, block *model.Block) (bool, error) {
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
|
||||
/*
|
||||
limits, err := a.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if limits.Views == model.LimitUnlimited {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// < rather than <= because we'll be creating new view if this
|
||||
// check passes. When that view is created, the limit will be reached.
|
||||
// That's why we need to check for if existing + the being-created
|
||||
// view doesn't exceed the limit.
|
||||
return len(views) < limits.Views, nil
|
||||
*/
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(blocks []*model.Block, modifiedByID string) ([]*model.Block, error) {
|
||||
return a.InsertBlocksAndNotify(blocks, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocksAndNotify(blocks []*model.Block, modifiedByID string, disableNotify bool) ([]*model.Block, error) {
|
||||
if len(blocks) == 0 {
|
||||
return []*model.Block{}, nil
|
||||
}
|
||||
|
||||
// all blocks must belong to the same board
|
||||
boardID := blocks[0].BoardID
|
||||
for _, block := range blocks {
|
||||
if block.BoardID != boardID {
|
||||
return nil, ErrBlocksFromMultipleBoards
|
||||
}
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
needsNotify := make([]*model.Block, 0, len(blocks))
|
||||
for i := range blocks {
|
||||
// this check is needed to whitelist inbuilt template
|
||||
// initialization. They do contain more than 5 views per board.
|
||||
if boardID != "0" && blocks[i].Type == model.TypeView {
|
||||
withinLimit, err := a.isWithinViewsLimit(board.ID, blocks[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !withinLimit {
|
||||
a.logger.Info("views limit reached on board", mlog.String("board_id", blocks[i].ParentID), mlog.String("team_id", board.TeamID))
|
||||
return nil, model.ErrViewsLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
err := a.store.InsertBlock(blocks[i], modifiedByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
needsNotify = append(needsNotify, blocks[i])
|
||||
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, blocks[i])
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
for _, b := range needsNotify {
|
||||
block := b
|
||||
a.webhook.NotifyUpdate(block)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Add, block, nil, modifiedByID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after inserting blocks",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block) error {
|
||||
// Images attached in cards have a path comprising the card's board ID.
|
||||
// When we create a template from this board, we need to copy the files
|
||||
// with the new board ID in path.
|
||||
// Not doing so causing images in templates (and boards created from this
|
||||
// template) to fail to load.
|
||||
|
||||
// look up ID of source sourceBoard, which may be different than the blocks.
|
||||
sourceBoard, err := a.GetBoard(sourceBoardID)
|
||||
if err != nil || sourceBoard == nil {
|
||||
return fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
|
||||
var destTeamID string
|
||||
var destBoardID string
|
||||
|
||||
for i := range copiedBlocks {
|
||||
block := copiedBlocks[i]
|
||||
fileName := ""
|
||||
isOk := false
|
||||
|
||||
switch block.Type {
|
||||
case model.TypeImage:
|
||||
fileName, isOk = block.Fields["fileId"].(string)
|
||||
if !isOk || fileName == "" {
|
||||
continue
|
||||
}
|
||||
case model.TypeAttachment:
|
||||
fileName, isOk = block.Fields["attachmentId"].(string)
|
||||
if !isOk || fileName == "" {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// create unique filename in case we are copying cards within the same board.
|
||||
ext := filepath.Ext(fileName)
|
||||
destFilename := utils.NewID(utils.IDTypeNone) + ext
|
||||
|
||||
if destBoardID == "" || block.BoardID != destBoardID {
|
||||
destBoardID = block.BoardID
|
||||
destBoard, err := a.GetBoard(destBoardID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
destTeamID = destBoard.TeamID
|
||||
}
|
||||
|
||||
sourceFilePath := filepath.Join(sourceBoard.TeamID, sourceBoard.ID, fileName)
|
||||
destinationFilePath := filepath.Join(destTeamID, block.BoardID, destFilename)
|
||||
|
||||
a.logger.Debug(
|
||||
"Copying card file",
|
||||
mlog.String("sourceFilePath", sourceFilePath),
|
||||
mlog.String("destinationFilePath", destinationFilePath),
|
||||
)
|
||||
|
||||
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
|
||||
a.logger.Error(
|
||||
"CopyCardFiles failed to copy file",
|
||||
mlog.String("sourceFilePath", sourceFilePath),
|
||||
mlog.String("destinationFilePath", destinationFilePath),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
if block.Type == model.TypeAttachment {
|
||||
block.Fields["attachmentId"] = destFilename
|
||||
parts := strings.Split(fileName, ".")
|
||||
fileInfoID := parts[0][1:]
|
||||
fileInfo, err := a.store.GetFileInfo(fileInfoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CopyCardFiles: cannot retrieve original fileinfo: %w", err)
|
||||
}
|
||||
newParts := strings.Split(destFilename, ".")
|
||||
newFileID := newParts[0][1:]
|
||||
fileInfo.Id = newFileID
|
||||
err = a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err)
|
||||
}
|
||||
} else {
|
||||
block.Fields["fileId"] = destFilename
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetBlockByID(blockID string) (*model.Block, error) {
|
||||
return a.store.GetBlock(blockID)
|
||||
}
|
||||
|
||||
func (a *App) DeleteBlock(blockID string, modifiedBy string) error {
|
||||
return a.DeleteBlockAndNotify(blockID, modifiedBy, false)
|
||||
}
|
||||
|
||||
func (a *App) DeleteBlockAndNotify(blockID string, modifiedBy string, disableNotify bool) error {
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if block == nil {
|
||||
// deleting non-existing block not considered an error
|
||||
return nil
|
||||
}
|
||||
|
||||
err = a.store.DeleteBlock(blockID, modifiedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if block.Type == model.TypeImage {
|
||||
fileName, fileIDExists := block.Fields["fileId"]
|
||||
if fileName, fileIDIsString := fileName.(string); fileIDExists && fileIDIsString {
|
||||
filePath := filepath.Join(block.BoardID, fileName)
|
||||
err = a.filesBackend.RemoveFile(filePath)
|
||||
|
||||
if err != nil {
|
||||
a.logger.Error("Error deleting image file",
|
||||
mlog.String("FilePath", filePath),
|
||||
mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID)
|
||||
a.metrics.IncrementBlocksDeleted(1)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Delete, block, block, modifiedBy)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting a block",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetLastBlockHistoryEntry(blockID string) (*model.Block, error) {
|
||||
blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return blocks[0], nil
|
||||
}
|
||||
|
||||
func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, error) {
|
||||
blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
// undeleting non-existing block not considered an error
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err = a.store.UndeleteBlock(blockID, modifiedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if model.IsErrNotFound(err) {
|
||||
a.logger.Error("Error loading the block after a successful undelete, not propagating through websockets or notifications", mlog.String("blockID", blockID))
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(block)
|
||||
a.notifyBlockChanged(notify.Add, block, nil, modifiedBy)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after undeleting a block",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (a *App) GetBlockCountsByType() (map[string]int64, error) {
|
||||
return a.store.GetBlockCountsByType()
|
||||
}
|
||||
|
||||
func (a *App) GetBlocksForBoard(boardID string) ([]*model.Block, error) {
|
||||
return a.store.GetBlocksForBoard(boardID)
|
||||
}
|
||||
|
||||
func (a *App) notifyBlockChanged(action notify.Action, block *model.Block, oldBlock *model.Block, modifiedByID string) {
|
||||
// don't notify if notifications service disabled, or block change is generated via system user.
|
||||
if a.notifications == nil || modifiedByID == model.SystemUserID {
|
||||
return
|
||||
}
|
||||
|
||||
// find card and board for the changed block.
|
||||
board, card, err := a.getBoardAndCard(block)
|
||||
if err != nil {
|
||||
a.logger.Error("Error notifying for block change; cannot determine board or card", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
boardMember, _ := a.GetMemberForBoard(board.ID, modifiedByID)
|
||||
if boardMember == nil {
|
||||
// create temporary guest board member
|
||||
boardMember = &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: modifiedByID,
|
||||
}
|
||||
}
|
||||
|
||||
evt := notify.BlockChangeEvent{
|
||||
Action: action,
|
||||
TeamID: board.TeamID,
|
||||
Board: board,
|
||||
Card: card,
|
||||
BlockChanged: block,
|
||||
BlockOld: oldBlock,
|
||||
ModifiedBy: boardMember,
|
||||
}
|
||||
a.notifications.BlockChanged(evt)
|
||||
}
|
||||
|
||||
const (
|
||||
maxSearchDepth = 50
|
||||
)
|
||||
|
||||
// getBoardAndCard returns the first parent of type `card` its 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(block *model.Block) (board *model.Board, card *model.Block, err error) {
|
||||
board, err = a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return board, card, err
|
||||
}
|
||||
|
||||
var count int // don't let invalid blocks hierarchy cause infinite loop.
|
||||
iter := block
|
||||
for {
|
||||
count++
|
||||
if card == nil && iter.Type == model.TypeCard {
|
||||
card = iter
|
||||
}
|
||||
|
||||
if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth {
|
||||
break
|
||||
}
|
||||
|
||||
iter, err = a.store.GetBlock(iter.ParentID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return board, card, nil
|
||||
}
|
||||
if err != nil {
|
||||
return board, card, err
|
||||
}
|
||||
}
|
||||
return board, card, nil
|
||||
}
|
||||
417
server/boards/app/blocks_test.go
Обычный файл
417
server/boards/app/blocks_test.go
Обычный файл
@@ -0,0 +1,417 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type blockError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (be blockError) Error() string {
|
||||
return be.msg
|
||||
}
|
||||
|
||||
func TestInsertBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
err := th.App.InsertBlock(block, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"})
|
||||
err := th.App.InsertBlock(block, "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("patchBlocks success scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{
|
||||
BlockIDs: []string{"block1"},
|
||||
BlockPatches: []model.BlockPatch{
|
||||
{Title: mm_model.NewString("new title")},
|
||||
},
|
||||
}
|
||||
|
||||
block1 := &model.Block{ID: "block1"}
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil)
|
||||
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil)
|
||||
th.Store.EXPECT().GetBlock("block1").Return(block1, nil)
|
||||
// this call comes from the WS server notification
|
||||
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("patchBlocks error scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{BlockIDs: []string{}}
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
|
||||
t.Run("cloud limit error scenario", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
blockPatches := model.BlockPatchBatch{
|
||||
BlockIDs: []string{"block1"},
|
||||
BlockPatches: []model.BlockPatch{
|
||||
{Title: mm_model.NewString("new title")},
|
||||
},
|
||||
}
|
||||
|
||||
block1 := &model.Block{
|
||||
ID: "block1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board-id",
|
||||
BoardID: "board-id",
|
||||
UpdateAt: 100,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board-id",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil)
|
||||
th.Store.EXPECT().GetBoard("board-id").Return(board1, nil)
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.ErrorIs(t, err, model.ErrPatchUpdatesLimitedCards)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
BoardID: board.ID,
|
||||
}
|
||||
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
|
||||
th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil)
|
||||
th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
err := th.App.DeleteBlock("block-id", "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
BoardID: board.ID,
|
||||
}
|
||||
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
|
||||
th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
|
||||
th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil)
|
||||
err := th.App.DeleteBlock("block-id", "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUndeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
BoardID: board.ID,
|
||||
}
|
||||
th.Store.EXPECT().GetBlockHistory(
|
||||
gomock.Eq("block-id"),
|
||||
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
|
||||
).Return([]*model.Block{block}, nil)
|
||||
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil)
|
||||
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
}
|
||||
th.Store.EXPECT().GetBlockHistory(
|
||||
gomock.Eq("block-id"),
|
||||
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
|
||||
).Return([]*model.Block{block}, nil)
|
||||
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
|
||||
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsWithinViewsLimit(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
t.Run("within views limit", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit exactly reached", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(1),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit already exceeded", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}, {}, {}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("creating first view", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("is not a cloud SKU so limits don't apply", func(t *testing.T) {
|
||||
nonCloudLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(false)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(nonCloudLicense)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInsertBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"})
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
|
||||
t.Run("create view within limits", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
boardID := testBoardID
|
||||
block := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("create view exceeding limits", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
boardID := testBoardID
|
||||
block := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}, {}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("creating multiple views, reaching limit in the process", func(t *testing.T) {
|
||||
t.Skipf("Will be fixed soon")
|
||||
|
||||
boardID := testBoardID
|
||||
view1 := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
view2 := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(view1, "user-id-1").Return(nil).Times(2)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).Times(2)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil).Times(2)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil).Times(2)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil).Times(2)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil).Times(2)
|
||||
|
||||
_, err := th.App.InsertBlocks([]*model.Block{view1, view2}, "user-id-1")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
759
server/boards/app/boards.go
Обычный файл
759
server/boards/app/boards.go
Обычный файл
@@ -0,0 +1,759 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNewBoardCannotHaveID = errors.New("new board cannot have an ID")
|
||||
)
|
||||
|
||||
const linkBoardMessage = "@%s linked the board [%s](%s) with this channel"
|
||||
const unlinkBoardMessage = "@%s unlinked the board [%s](%s) with this channel"
|
||||
|
||||
var errNoDefaultCategoryFound = errors.New("no default category found for user")
|
||||
|
||||
func (a *App) GetBoard(boardID string) (*model.Board, error) {
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func (a *App) GetBoardCount() (int64, error) {
|
||||
return a.store.GetBoardCount()
|
||||
}
|
||||
|
||||
func (a *App) GetBoardMetadata(boardID string) (*model.Board, *model.BoardMetadata, error) {
|
||||
license := a.store.GetLicense()
|
||||
if license == nil || !(*license.Features.Compliance) {
|
||||
return nil, nil, model.ErrInsufficientLicense
|
||||
}
|
||||
|
||||
board, err := a.GetBoard(boardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
// Board may have been deleted, retrieve most recent history instead
|
||||
board, err = a.getBoardHistory(boardID, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
earliestTime, _, err := a.getBoardDescendantModifiedInfo(boardID, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
latestTime, lastModifiedBy, err := a.getBoardDescendantModifiedInfo(boardID, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
boardMetadata := model.BoardMetadata{
|
||||
BoardID: boardID,
|
||||
DescendantFirstUpdateAt: earliestTime,
|
||||
DescendantLastUpdateAt: latestTime,
|
||||
CreatedBy: board.CreatedBy,
|
||||
LastModifiedBy: lastModifiedBy,
|
||||
}
|
||||
return board, &boardMetadata, nil
|
||||
}
|
||||
|
||||
// getBoardForBlock returns the board that owns the specified block.
|
||||
func (a *App) getBoardForBlock(blockID string) (*model.Board, error) {
|
||||
block, err := a.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get block %s: %w", blockID, err)
|
||||
}
|
||||
|
||||
board, err := a.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get board %s: %w", block.BoardID, err)
|
||||
}
|
||||
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func (a *App) getBoardHistory(boardID string, latest bool) (*model.Board, error) {
|
||||
opts := model.QueryBoardHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: latest,
|
||||
}
|
||||
boards, err := a.store.GetBoardHistory(boardID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get history for board: %w", err)
|
||||
}
|
||||
if len(boards) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return boards[0], nil
|
||||
}
|
||||
|
||||
func (a *App) getBoardDescendantModifiedInfo(boardID string, latest bool) (int64, string, error) {
|
||||
board, err := a.getBoardHistory(boardID, latest)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if board == nil {
|
||||
return 0, "", fmt.Errorf("history not found for board: %w", err)
|
||||
}
|
||||
|
||||
var timestamp int64
|
||||
modifiedBy := board.ModifiedBy
|
||||
if latest {
|
||||
timestamp = board.UpdateAt
|
||||
} else {
|
||||
timestamp = board.CreateAt
|
||||
}
|
||||
|
||||
// use block_history to fetch blocks in case they were deleted and no longer exist in blocks table.
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: latest,
|
||||
}
|
||||
blocks, err := a.store.GetBlockHistoryDescendants(boardID, opts)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("could not get blocks history descendants for board: %w", err)
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
// Compare the board history info with the descendant block info, if it exists
|
||||
block := blocks[0]
|
||||
if latest && block.UpdateAt > timestamp {
|
||||
timestamp = block.UpdateAt
|
||||
modifiedBy = block.ModifiedBy
|
||||
} else if !latest && block.CreateAt < timestamp {
|
||||
timestamp = block.CreateAt
|
||||
modifiedBy = block.ModifiedBy
|
||||
}
|
||||
}
|
||||
return timestamp, modifiedBy, nil
|
||||
}
|
||||
|
||||
func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, userID, teamID string, asTemplate bool) error {
|
||||
// find source board's category ID for the user
|
||||
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var destinationCategoryID string
|
||||
|
||||
for _, categoryBoard := range userCategoryBoards {
|
||||
for _, metadata := range categoryBoard.BoardMetadata {
|
||||
if metadata.BoardID == sourceBoardID {
|
||||
// category found!
|
||||
destinationCategoryID = categoryBoard.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if destinationCategoryID == "" {
|
||||
// if source board is not mapped to a category for this user,
|
||||
// then move new board to default category
|
||||
if !asTemplate {
|
||||
return a.addBoardsToDefaultCategory(userID, teamID, []*model.Board{{ID: destinationBoardID}})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// now that we have source board's category,
|
||||
// we send destination board to the same category
|
||||
return a.AddUpdateUserCategoryBoard(teamID, userID, destinationCategoryID, []string{destinationBoardID})
|
||||
}
|
||||
|
||||
func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
|
||||
bab, members, err := a.store.DuplicateBoard(boardID, userID, toTeam, asTemplate)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// copy any file attachments from the duplicated blocks.
|
||||
if err = a.CopyCardFiles(boardID, bab.Blocks); err != nil {
|
||||
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
|
||||
}
|
||||
|
||||
if !asTemplate {
|
||||
for _, board := range bab.Boards {
|
||||
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil {
|
||||
return nil, nil, categoryErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bab.Blocks now has updated file ids for any blocks containing files. We need to store them.
|
||||
blockIDs := make([]string, 0)
|
||||
blockPatches := make([]model.BlockPatch, 0)
|
||||
|
||||
for _, block := range bab.Blocks {
|
||||
fieldName := ""
|
||||
if block.Type == model.TypeImage {
|
||||
fieldName = "fileId"
|
||||
} else if block.Type == model.TypeAttachment {
|
||||
fieldName = "attachmentId"
|
||||
}
|
||||
if fieldName != "" {
|
||||
if fieldID, ok := block.Fields[fieldName]; ok {
|
||||
blockIDs = append(blockIDs, block.ID)
|
||||
blockPatches = append(blockPatches, model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
fieldName: fieldID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs)))
|
||||
|
||||
if len(blockIDs) != 0 {
|
||||
patches := &model.BlockPatchBatch{
|
||||
BlockIDs: blockIDs,
|
||||
BlockPatches: blockPatches,
|
||||
}
|
||||
if err = a.store.PatchBlocks(patches, userID); err != nil {
|
||||
dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab)
|
||||
if err = a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
|
||||
a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(err))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err)
|
||||
}
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
teamID := ""
|
||||
for _, board := range bab.Boards {
|
||||
teamID = board.TeamID
|
||||
a.wsAdapter.BroadcastBoardChange(teamID, board)
|
||||
}
|
||||
for _, block := range bab.Blocks {
|
||||
blk := block
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, blk)
|
||||
a.notifyBlockChanged(notify.Add, blk, nil, userID)
|
||||
}
|
||||
for _, member := range members {
|
||||
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(bab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after duplicating a board",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return bab, members, err
|
||||
}
|
||||
|
||||
func (a *App) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.GetBoardsForUserAndTeam(userID, teamID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) {
|
||||
return a.store.GetTemplateBoards(teamID, userID)
|
||||
}
|
||||
|
||||
func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) {
|
||||
if board.ID != "" {
|
||||
return nil, ErrNewBoardCannotHaveID
|
||||
}
|
||||
board.ID = utils.NewID(utils.IDTypeBoard)
|
||||
|
||||
var newBoard *model.Board
|
||||
var member *model.BoardMember
|
||||
var err error
|
||||
if addMember {
|
||||
newBoard, member, err = a.store.InsertBoardWithAdmin(board, userID)
|
||||
} else {
|
||||
newBoard, err = a.store.InsertBoard(board, userID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(newBoard.TeamID, newBoard)
|
||||
|
||||
if newBoard.ChannelID != "" {
|
||||
members, err := a.GetMembersForBoard(board.ID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
for _, member := range members {
|
||||
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, member.BoardID, member)
|
||||
}
|
||||
} else if addMember {
|
||||
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, newBoard.ID, member)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if !board.IsTemplate {
|
||||
if err := a.addBoardsToDefaultCategory(userID, newBoard.TeamID, []*model.Board{newBoard}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return newBoard, nil
|
||||
}
|
||||
|
||||
func (a *App) addBoardsToDefaultCategory(userID, teamID string, boards []*model.Board) error {
|
||||
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaultCategoryID := ""
|
||||
for _, categoryBoard := range userCategoryBoards {
|
||||
if categoryBoard.Name == defaultCategoryBoards {
|
||||
defaultCategoryID = categoryBoard.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if defaultCategoryID == "" {
|
||||
return fmt.Errorf("%w userID: %s", errNoDefaultCategoryFound, userID)
|
||||
}
|
||||
|
||||
boardIDs := make([]string, len(boards))
|
||||
for i := range boards {
|
||||
boardIDs[i] = boards[i].ID
|
||||
}
|
||||
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*model.Board, error) {
|
||||
var oldChannelID string
|
||||
var isTemplate bool
|
||||
var oldMembers []*model.BoardMember
|
||||
|
||||
if patch.Type != nil || patch.ChannelID != nil {
|
||||
testChannel := ""
|
||||
if patch.ChannelID != nil && *patch.ChannelID == "" {
|
||||
var err error
|
||||
oldMembers, err = a.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
} else if patch.ChannelID != nil && *patch.ChannelID != "" {
|
||||
testChannel = *patch.ChannelID
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, model.NewErrNotFound("board ID=" + boardID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldChannelID = board.ChannelID
|
||||
isTemplate = board.IsTemplate
|
||||
if testChannel == "" {
|
||||
testChannel = oldChannelID
|
||||
}
|
||||
|
||||
if testChannel != "" {
|
||||
if !a.permissions.HasPermissionToChannel(userID, testChannel, model.PermissionCreatePost) {
|
||||
return nil, model.NewErrPermission("access denied to channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
updatedBoard, err := a.store.PatchBoard(boardID, patch, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post message to channel if linked/unlinked
|
||||
if patch.ChannelID != nil {
|
||||
var username string
|
||||
|
||||
user, err := a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board updater", mlog.Err(err))
|
||||
username = "unknown"
|
||||
} else {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
boardLink := utils.MakeBoardLink(a.config.ServerRoot, updatedBoard.TeamID, updatedBoard.ID)
|
||||
title := updatedBoard.Title
|
||||
if title == "" {
|
||||
title = "Untitled board" // todo: localize this when server has i18n
|
||||
}
|
||||
if *patch.ChannelID != "" {
|
||||
a.postChannelMessage(fmt.Sprintf(linkBoardMessage, username, title, boardLink), updatedBoard.ChannelID)
|
||||
} else if *patch.ChannelID == "" {
|
||||
a.postChannelMessage(fmt.Sprintf(unlinkBoardMessage, username, title, boardLink), oldChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast Messages to affected users
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(updatedBoard.TeamID, updatedBoard)
|
||||
|
||||
if patch.ChannelID != nil {
|
||||
if *patch.ChannelID != "" {
|
||||
members, err := a.GetMembersForBoard(updatedBoard.ID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
for _, member := range members {
|
||||
if member.Synthetic {
|
||||
a.wsAdapter.BroadcastMemberChange(updatedBoard.TeamID, member.BoardID, member)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, oldMember := range oldMembers {
|
||||
if oldMember.Synthetic {
|
||||
a.wsAdapter.BroadcastMemberDelete(updatedBoard.TeamID, boardID, oldMember.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if patch.Type != nil && isTemplate {
|
||||
members, err := a.GetMembersForBoard(updatedBoard.ID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
a.broadcastTeamUsers(updatedBoard.TeamID, updatedBoard.ID, *patch.Type, members)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return updatedBoard, nil
|
||||
}
|
||||
|
||||
func (a *App) postChannelMessage(message, channelID string) {
|
||||
err := a.store.PostMessage(message, "", channelID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to post the link message to channel", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastTeamUsers notifies the members of a team when a template changes its type
|
||||
// from public to private or viceversa.
|
||||
func (a *App) broadcastTeamUsers(teamID, boardID string, boardType model.BoardType, members []*model.BoardMember) {
|
||||
users, err := a.GetTeamUsers(teamID, "")
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the team users", mlog.Err(err))
|
||||
}
|
||||
for _, user := range users {
|
||||
isMember := false
|
||||
for _, member := range members {
|
||||
if member.UserID == user.ID {
|
||||
isMember = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMember {
|
||||
if boardType == model.BoardTypePrivate {
|
||||
a.wsAdapter.BroadcastMemberDelete(teamID, boardID, user.ID)
|
||||
} else if boardType == model.BoardTypeOpen {
|
||||
a.wsAdapter.BroadcastMemberChange(teamID, boardID, &model.BoardMember{UserID: user.ID, BoardID: boardID, SchemeViewer: true, Synthetic: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) DeleteBoard(boardID, userID string) error {
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.store.DeleteBoard(boardID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardDelete(board.TeamID, boardID)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting a board",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) {
|
||||
members, err := a.store.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if board != nil {
|
||||
for i, m := range members {
|
||||
if !m.SchemeAdmin {
|
||||
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
|
||||
members[i].SchemeAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (a *App) GetMembersForUser(userID string) ([]*model.BoardMember, error) {
|
||||
members, err := a.store.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, m := range members {
|
||||
if !m.SchemeAdmin {
|
||||
board, err := a.store.GetBoard(m.BoardID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if board != nil {
|
||||
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
|
||||
// if system/team admin
|
||||
members[i].SchemeAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (a *App) GetMemberForBoard(boardID string, userID string) (*model.BoardMember, error) {
|
||||
return a.store.GetMemberForBoard(boardID, userID)
|
||||
}
|
||||
|
||||
func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) {
|
||||
board, err := a.store.GetBoard(member.BoardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingMembership, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingMembership != nil && !existingMembership.Synthetic {
|
||||
return existingMembership, nil
|
||||
}
|
||||
|
||||
newMember, err := a.store.SaveMember(member)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !newMember.SchemeAdmin {
|
||||
if board != nil {
|
||||
if a.permissions.HasPermissionToTeam(newMember.UserID, board.TeamID, model.PermissionManageTeam) {
|
||||
newMember.SchemeAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !board.IsTemplate {
|
||||
if err = a.addBoardsToDefaultCategory(member.UserID, board.TeamID, []*model.Board{board}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
|
||||
return nil
|
||||
})
|
||||
|
||||
return newMember, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, error) {
|
||||
board, bErr := a.store.GetBoard(member.BoardID)
|
||||
if model.IsErrNotFound(bErr) {
|
||||
return nil, nil
|
||||
}
|
||||
if bErr != nil {
|
||||
return nil, bErr
|
||||
}
|
||||
|
||||
oldMember, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// if we're updating an admin, we need to check that there is at
|
||||
// least still another admin on the board
|
||||
if oldMember.SchemeAdmin && !member.SchemeAdmin {
|
||||
isLastAdmin, err2 := a.isLastAdmin(member.UserID, member.BoardID)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
if isLastAdmin {
|
||||
return nil, model.ErrBoardMemberIsLastAdmin
|
||||
}
|
||||
}
|
||||
|
||||
newMember, err := a.store.SaveMember(member)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
|
||||
return nil
|
||||
})
|
||||
|
||||
return newMember, nil
|
||||
}
|
||||
|
||||
func (a *App) isLastAdmin(userID, boardID string) (bool, error) {
|
||||
members, err := a.store.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
if m.SchemeAdmin && m.UserID != userID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteBoardMember(boardID, userID string) error {
|
||||
board, bErr := a.store.GetBoard(boardID)
|
||||
if model.IsErrNotFound(bErr) {
|
||||
return nil
|
||||
}
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
|
||||
oldMember, err := a.store.GetMemberForBoard(boardID, userID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if we're removing an admin, we need to check that there is at
|
||||
// least still another admin on the board
|
||||
if oldMember.SchemeAdmin {
|
||||
isLastAdmin, err := a.isLastAdmin(userID, boardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isLastAdmin {
|
||||
return model.ErrBoardMemberIsLastAdmin
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.store.DeleteMember(boardID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
if syntheticMember, _ := a.GetMemberForBoard(boardID, userID); syntheticMember != nil {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, boardID, syntheticMember)
|
||||
} else {
|
||||
a.wsAdapter.BroadcastMemberDelete(board.TeamID, boardID, userID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, searchField, userID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
}
|
||||
|
||||
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
|
||||
boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boards) == 0 {
|
||||
// undeleting non-existing board not considered an error
|
||||
return nil
|
||||
}
|
||||
|
||||
err = a.store.UndeleteBoard(boardID, modifiedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if board == nil {
|
||||
a.logger.Error("Error loading the board after undelete, not propagating through websockets or notifications")
|
||||
return nil
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(board.TeamID, board)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after undeleting a board",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
170
server/boards/app/boards_and_blocks.go
Обычный файл
170
server/boards/app/boards_and_blocks.go
Обычный файл
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, addMember bool) (*model.BoardsAndBlocks, error) {
|
||||
var newBab *model.BoardsAndBlocks
|
||||
var members []*model.BoardMember
|
||||
var err error
|
||||
|
||||
if addMember {
|
||||
newBab, members, err = a.store.CreateBoardsAndBlocksWithAdmin(bab, userID)
|
||||
} else {
|
||||
newBab, err = a.store.CreateBoardsAndBlocks(bab, userID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// all new boards should belong to the same team
|
||||
teamID := newBab.Boards[0].TeamID
|
||||
|
||||
// This can be synchronous because this action is not common
|
||||
for _, board := range newBab.Boards {
|
||||
a.wsAdapter.BroadcastBoardChange(teamID, board)
|
||||
}
|
||||
|
||||
for _, block := range newBab.Blocks {
|
||||
b := block
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, b)
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(b)
|
||||
a.notifyBlockChanged(notify.Add, b, nil, userID)
|
||||
}
|
||||
|
||||
if addMember {
|
||||
for _, member := range members {
|
||||
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newBab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after creating boards and blocks",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, board := range newBab.Boards {
|
||||
if !board.IsTemplate {
|
||||
if err := a.addBoardsToDefaultCategory(userID, board.TeamID, []*model.Board{board}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newBab, nil
|
||||
}
|
||||
|
||||
func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
|
||||
oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, cErr := a.ContainsLimitedBlocks(oldBlocks)
|
||||
if cErr != nil {
|
||||
return nil, cErr
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return nil, model.ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
oldBlocksMap := map[string]*model.Block{}
|
||||
for _, block := range oldBlocks {
|
||||
oldBlocksMap[block.ID] = block
|
||||
}
|
||||
|
||||
bab, err := a.store.PatchBoardsAndBlocks(pbab, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
teamID := bab.Boards[0].TeamID
|
||||
|
||||
for _, block := range bab.Blocks {
|
||||
oldBlock, ok := oldBlocksMap[block.ID]
|
||||
if !ok {
|
||||
a.logger.Error("Error notifying for block change on patch boards and blocks; cannot get old block", mlog.String("blockID", block.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
b := block
|
||||
a.metrics.IncrementBlocksPatched(1)
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, b)
|
||||
a.webhook.NotifyUpdate(b)
|
||||
a.notifyBlockChanged(notify.Update, b, oldBlock, userID)
|
||||
}
|
||||
|
||||
for _, board := range bab.Boards {
|
||||
a.wsAdapter.BroadcastBoardChange(board.TeamID, board)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return bab, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID string) error {
|
||||
firstBoard, err := a.store.GetBoard(dbab.Boards[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// we need the block entity to notify of the block changes, so we
|
||||
// fetch and store the blocks first
|
||||
blocks := []*model.Block{}
|
||||
for _, blockID := range dbab.Blocks {
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
if err := a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
for _, block := range blocks {
|
||||
a.wsAdapter.BroadcastBlockDelete(firstBoard.TeamID, block.ID, block.BoardID)
|
||||
a.metrics.IncrementBlocksDeleted(1)
|
||||
a.notifyBlockChanged(notify.Update, block, block, userID)
|
||||
}
|
||||
|
||||
for _, boardID := range dbab.Boards {
|
||||
a.wsAdapter.BroadcastBoardDelete(firstBoard.TeamID, boardID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(dbab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting boards and blocks",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
779
server/boards/app/boards_test.go
Обычный файл
779
server/boards/app/boards_test.go
Обычный файл
@@ -0,0 +1,779 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestAddMemberToBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: "board_id_1",
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(nil, nil)
|
||||
|
||||
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
|
||||
p := i.(*model.BoardMember)
|
||||
return p.BoardID == boardID && p.UserID == userID
|
||||
})).Return(&model.BoardMember{
|
||||
BoardID: boardID,
|
||||
}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
|
||||
t.Run("return existing non-synthetic membership if any", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: false,
|
||||
}, nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
|
||||
t.Run("should convert synthetic membership into natural membership", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: "board_id_1",
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: true,
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
|
||||
p := i.(*model.BoardMember)
|
||||
return p.BoardID == boardID && p.UserID == userID
|
||||
})).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: false,
|
||||
}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
|
||||
th.API.EXPECT().HasPermissionToTeam("user_id_1", "team_id_1", model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case, title patch", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchTitle := "Patched Title"
|
||||
patch := &model.BoardPatch{
|
||||
Title: &patchTitle,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
Title: patchTitle,
|
||||
},
|
||||
nil)
|
||||
|
||||
// for WS BroadcastBoardChange
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(1)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, patchTitle, patchedBoard.Title)
|
||||
})
|
||||
|
||||
t.Run("patch type open, no users", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
|
||||
th.Store.EXPECT().GetUserByID(userID).Return(&model.User{ID: userID, Username: "UserName"}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// - for WS BroadcastBoardChange
|
||||
// - for AddTeamMembers check
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type private, no users", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypePrivate
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// - for WS BroadcastBoardChange
|
||||
// - for AddTeamMembers check
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type open, single user", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 3 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// for WS BroadcastMemberChange
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type private, single user", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypePrivate
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 3 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// for WS BroadcastMemberChange
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type open, user with member", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(3)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type private, user with member", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypePrivate
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
ChannelID: "",
|
||||
}, nil).Times(1)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
t.Run("patch type channel, user without post permissions", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
channelID := "myChannel"
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
ChannelID: &channelID,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(1)
|
||||
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
|
||||
_, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("patch type channel, user with post permissions", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
channelID := "myChannel"
|
||||
patch := &model.BoardPatch{
|
||||
ChannelID: &channelID,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
}, nil).Times(2)
|
||||
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// - for WS BroadcastBoardChange
|
||||
// - for AddTeamMembers check
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
th.Store.EXPECT().PostMessage(utils.Anything, "", "").Times(1)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchBoard2(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("patch type remove channel, user without post permissions", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
const channelID = "myChannel"
|
||||
clearChannel := ""
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
ChannelID: &clearChannel,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
ChannelID: channelID,
|
||||
}, nil).Times(2)
|
||||
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).AnyTimes()
|
||||
|
||||
_, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetBoardCount(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
boardCount := int64(100)
|
||||
th.Store.EXPECT().GetBoardCount().Return(boardCount, nil)
|
||||
|
||||
count, err := th.App.GetBoardCount()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardCount, count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoardCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("no boards default category exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1"},
|
||||
{BoardID: "board_id_2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil).Times(1)
|
||||
|
||||
// when this function is called the second time, the default category is created
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1"},
|
||||
{BoardID: "board_id_2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "default_category_id", Type: model.CategoryTypeSystem, Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(1)
|
||||
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", []string{
|
||||
"board_id_1",
|
||||
"board_id_2",
|
||||
"board_id_3",
|
||||
}).Return(nil)
|
||||
|
||||
boards := []*model.Board{
|
||||
{ID: "board_id_1"},
|
||||
{ID: "board_id_2"},
|
||||
{ID: "board_id_3"},
|
||||
}
|
||||
|
||||
err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDuplicateBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
board := &model.Board{
|
||||
ID: "board_id_2",
|
||||
Title: "Duplicated Board",
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: "block_id_1",
|
||||
Type: "image",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false).Return(
|
||||
&model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{
|
||||
board,
|
||||
},
|
||||
Blocks: []*model.Block{
|
||||
block,
|
||||
},
|
||||
},
|
||||
[]*model.BoardMember{},
|
||||
nil,
|
||||
)
|
||||
|
||||
th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
}, nil).Times(3)
|
||||
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "category_id_1", utils.Anything).Return(nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bab)
|
||||
assert.NotNil(t, members)
|
||||
})
|
||||
|
||||
t.Run("duplicating board as template should not set it's category", func(t *testing.T) {
|
||||
board := &model.Board{
|
||||
ID: "board_id_2",
|
||||
Title: "Duplicated Board",
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: "block_id_1",
|
||||
Type: "image",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true).Return(
|
||||
&model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{
|
||||
board,
|
||||
},
|
||||
Blocks: []*model.Block{
|
||||
block,
|
||||
},
|
||||
},
|
||||
[]*model.BoardMember{},
|
||||
nil,
|
||||
)
|
||||
|
||||
th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bab)
|
||||
assert.NotNil(t, members)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMembersForBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
},
|
||||
}, nil).Times(3)
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil).Times(1)
|
||||
t.Run("-base case", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForBoard(boardID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
board := &model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
t.Run("-team check false ", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForBoard(boardID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
t.Run("-team check true", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForBoard(boardID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.True(t, members[0].SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMembersForUser(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser(userID).Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
},
|
||||
}, nil).Times(3)
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil)
|
||||
t.Run("-base case", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForUser(userID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
board := &model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
t.Run("-team check false ", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForUser(userID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
t.Run("-team check true", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForUser(userID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.True(t, members[0].SchemeAdmin)
|
||||
})
|
||||
}
|
||||
96
server/boards/app/cards.go
Обычный файл
96
server/boards/app/cards.go
Обычный файл
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func (a *App) CreateCard(card *model.Card, boardID string, userID string, disableNotify bool) (*model.Card, error) {
|
||||
// Convert the card struct to a block and insert the block.
|
||||
now := utils.GetMillis()
|
||||
|
||||
card.ID = utils.NewID(utils.IDTypeCard)
|
||||
card.BoardID = boardID
|
||||
card.CreatedBy = userID
|
||||
card.ModifiedBy = userID
|
||||
card.CreateAt = now
|
||||
card.UpdateAt = now
|
||||
card.DeleteAt = 0
|
||||
|
||||
block := model.Card2Block(card)
|
||||
|
||||
newBlocks, err := a.InsertBlocksAndNotify([]*model.Block{block}, userID, disableNotify)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create card: %w", err)
|
||||
}
|
||||
|
||||
newCard, err := model.Block2Card(newBlocks[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCard, nil
|
||||
}
|
||||
|
||||
func (a *App) GetCardsForBoard(boardID string, page int, perPage int) ([]*model.Card, error) {
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: boardID,
|
||||
BlockType: model.TypeCard,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
blocks, err := a.store.GetBlocks(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]*model.Card, 0, len(blocks))
|
||||
var card *model.Card
|
||||
for _, blk := range blocks {
|
||||
b := blk
|
||||
if card, err = model.Block2Card(b); err != nil {
|
||||
return nil, fmt.Errorf("Block2Card fail: %w", err)
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (a *App) PatchCard(cardPatch *model.CardPatch, cardID string, userID string, disableNotify bool) (*model.Card, error) {
|
||||
blockPatch, err := model.CardPatch2BlockPatch(cardPatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newBlock, err := a.PatchBlockAndNotify(cardID, blockPatch, userID, disableNotify)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot patch card %s: %w", cardID, err)
|
||||
}
|
||||
|
||||
newCard, err := model.Block2Card(newBlock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCard, nil
|
||||
}
|
||||
|
||||
func (a *App) GetCardByID(cardID string) (*model.Card, error) {
|
||||
cardBlock, err := a.GetBlockByID(cardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
card, err := model.Block2Card(cardBlock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return card, nil
|
||||
}
|
||||
270
server/boards/app/cards_test.go
Обычный файл
270
server/boards/app/cards_test.go
Обычный файл
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func TestCreateCard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
}
|
||||
userID := utils.NewID(utils.IDTypeUser)
|
||||
|
||||
props := makeProps(3)
|
||||
|
||||
card := &model.Card{
|
||||
BoardID: board.ID,
|
||||
CreatedBy: userID,
|
||||
ModifiedBy: userID,
|
||||
Title: "test card",
|
||||
ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)},
|
||||
Properties: props,
|
||||
}
|
||||
block := model.Card2Block(card)
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
newCard, err := th.App.CreateCard(card, board.ID, userID, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, card.BoardID, newCard.BoardID)
|
||||
require.Equal(t, card.Title, newCard.Title)
|
||||
require.Equal(t, card.ContentOrder, newCard.ContentOrder)
|
||||
require.EqualValues(t, card.Properties, newCard.Properties)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(blockError{"error"})
|
||||
|
||||
newCard, err := th.App.CreateCard(card, board.ID, userID, false)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.Nil(t, newCard)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetCards(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
}
|
||||
|
||||
const cardCount = 25
|
||||
|
||||
// make some cards
|
||||
blocks := make([]*model.Block, 0, cardCount)
|
||||
for i := 0; i < cardCount; i++ {
|
||||
card := &model.Block{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
ParentID: board.ID,
|
||||
Schema: 1,
|
||||
Type: model.TypeCard,
|
||||
Title: fmt.Sprintf("card %d", i),
|
||||
BoardID: board.ID,
|
||||
}
|
||||
blocks = append(blocks, card)
|
||||
}
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: board.ID,
|
||||
BlockType: model.TypeCard,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBlocks(opts).Return(blocks, nil)
|
||||
|
||||
cards, err := th.App.GetCardsForBoard(board.ID, 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cards, cardCount)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: board.ID,
|
||||
BlockType: model.TypeCard,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBlocks(opts).Return(nil, blockError{"error"})
|
||||
|
||||
cards, err := th.App.GetCardsForBoard(board.ID, 0, 0)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, cards)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchCard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
}
|
||||
userID := utils.NewID(utils.IDTypeUser)
|
||||
|
||||
props := makeProps(3)
|
||||
|
||||
card := &model.Card{
|
||||
BoardID: board.ID,
|
||||
CreatedBy: userID,
|
||||
ModifiedBy: userID,
|
||||
Title: "test card for patch",
|
||||
ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)},
|
||||
Properties: copyProps(props),
|
||||
}
|
||||
|
||||
newTitle := "patched"
|
||||
newIcon := "😀"
|
||||
newContentOrder := reverse(card.ContentOrder)
|
||||
|
||||
cardPatch := &model.CardPatch{
|
||||
Title: &newTitle,
|
||||
ContentOrder: &newContentOrder,
|
||||
Icon: &newIcon,
|
||||
UpdatedProperties: modifyProps(props),
|
||||
}
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
expectedPatchedCard := cardPatch.Patch(card)
|
||||
expectedPatchedBlock := model.Card2Block(expectedPatchedCard)
|
||||
|
||||
var blockPatch *model.BlockPatch
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBlock(card.ID).Return(expectedPatchedBlock, nil).AnyTimes()
|
||||
|
||||
patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, board.ID, patchedCard.BoardID)
|
||||
require.Equal(t, newTitle, patchedCard.Title)
|
||||
require.Equal(t, newIcon, patchedCard.Icon)
|
||||
require.Equal(t, newContentOrder, patchedCard.ContentOrder)
|
||||
require.EqualValues(t, expectedPatchedCard.Properties, patchedCard.Properties)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
var blockPatch *model.BlockPatch
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(blockError{"error"})
|
||||
|
||||
patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.Nil(t, patchedCard)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetCard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
boardID := utils.NewID(utils.IDTypeBoard)
|
||||
userID := utils.NewID(utils.IDTypeUser)
|
||||
props := makeProps(5)
|
||||
contentOrder := []string{utils.NewID(utils.IDTypeUser), utils.NewID(utils.IDTypeUser)}
|
||||
fields := make(map[string]any)
|
||||
fields["contentOrder"] = contentOrder
|
||||
fields["properties"] = props
|
||||
fields["icon"] = "😀"
|
||||
fields["isTemplate"] = true
|
||||
|
||||
block := &model.Block{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
ParentID: boardID,
|
||||
Type: model.TypeCard,
|
||||
Title: "test card",
|
||||
BoardID: boardID,
|
||||
Fields: fields,
|
||||
CreatedBy: userID,
|
||||
ModifiedBy: userID,
|
||||
}
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBlock(block.ID).Return(block, nil)
|
||||
|
||||
card, err := th.App.GetCardByID(block.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, card.BoardID)
|
||||
require.Equal(t, block.Title, card.Title)
|
||||
require.Equal(t, "😀", card.Icon)
|
||||
require.Equal(t, true, card.IsTemplate)
|
||||
require.Equal(t, contentOrder, card.ContentOrder)
|
||||
require.EqualValues(t, props, card.Properties)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
bogusID := utils.NewID(utils.IDTypeBlock)
|
||||
th.Store.EXPECT().GetBlock(bogusID).Return(nil, model.NewErrNotFound(bogusID))
|
||||
|
||||
card, err := th.App.GetCardByID(bogusID)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.True(t, model.IsErrNotFound(err))
|
||||
require.Nil(t, card)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBlock(block.ID).Return(nil, blockError{"error"})
|
||||
|
||||
card, err := th.App.GetCardByID(block.ID)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.Nil(t, card)
|
||||
})
|
||||
}
|
||||
|
||||
// reverse is a helper function to copy and reverse a slice of strings.
|
||||
func reverse(src []string) []string {
|
||||
out := make([]string, 0, len(src))
|
||||
for i := len(src) - 1; i >= 0; i-- {
|
||||
out = append(out, src[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func makeProps(count int) map[string]any {
|
||||
props := make(map[string]any)
|
||||
for i := 0; i < count; i++ {
|
||||
props[utils.NewID(utils.IDTypeBlock)] = utils.NewID(utils.IDTypeBlock)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
func copyProps(m map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func modifyProps(m map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
for k := range m {
|
||||
out[k] = utils.NewID(utils.IDTypeBlock)
|
||||
}
|
||||
return out
|
||||
}
|
||||
248
server/boards/app/category.go
Обычный файл
248
server/boards/app/category.go
Обычный файл
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
var errCategoryNotFound = errors.New("category ID specified in input does not exist for user")
|
||||
var errCategoriesLengthMismatch = errors.New("cannot update category order, passed list of categories different size than in database")
|
||||
var ErrCannotDeleteSystemCategory = errors.New("cannot delete a system category")
|
||||
var ErrCannotUpdateSystemCategory = errors.New("cannot update a system category")
|
||||
|
||||
func (a *App) GetCategory(categoryID string) (*model.Category, error) {
|
||||
return a.store.GetCategory(categoryID)
|
||||
}
|
||||
|
||||
func (a *App) CreateCategory(category *model.Category) (*model.Category, error) {
|
||||
category.Hydrate()
|
||||
if err := category.IsValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := a.store.CreateCategory(*category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdCategory, err := a.store.GetCategory(category.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryChange(*createdCategory)
|
||||
}()
|
||||
|
||||
return createdCategory, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateCategory(category *model.Category) (*model.Category, error) {
|
||||
category.Hydrate()
|
||||
|
||||
if err := category.IsValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// verify if category belongs to the user
|
||||
existingCategory, err := a.store.GetCategory(category.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingCategory.DeleteAt != 0 {
|
||||
return nil, model.ErrCategoryDeleted
|
||||
}
|
||||
|
||||
if existingCategory.UserID != category.UserID {
|
||||
return nil, model.ErrCategoryPermissionDenied
|
||||
}
|
||||
|
||||
if existingCategory.TeamID != category.TeamID {
|
||||
return nil, model.ErrCategoryPermissionDenied
|
||||
}
|
||||
|
||||
// in case type was defaulted above, set to existingCategory.Type
|
||||
category.Type = existingCategory.Type
|
||||
if existingCategory.Type == model.CategoryTypeSystem {
|
||||
// You cannot rename or delete a system category,
|
||||
// So restoring its name and undeleting it if set so.
|
||||
category.Name = existingCategory.Name
|
||||
category.DeleteAt = 0
|
||||
}
|
||||
|
||||
category.UpdateAt = utils.GetMillis()
|
||||
if err = category.IsValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.store.UpdateCategory(*category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedCategory, err := a.store.GetCategory(category.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryChange(*updatedCategory)
|
||||
}()
|
||||
|
||||
return updatedCategory, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category, error) {
|
||||
existingCategory, err := a.store.GetCategory(categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// category is already deleted. This avoids
|
||||
// overriding the original deleted at timestamp
|
||||
if existingCategory.DeleteAt != 0 {
|
||||
return existingCategory, nil
|
||||
}
|
||||
|
||||
// verify if category belongs to the user
|
||||
if existingCategory.UserID != userID {
|
||||
return nil, model.ErrCategoryPermissionDenied
|
||||
}
|
||||
|
||||
// verify if category belongs to the team
|
||||
if existingCategory.TeamID != teamID {
|
||||
return nil, model.NewErrInvalidCategory("category doesn't belong to the team")
|
||||
}
|
||||
|
||||
if existingCategory.Type == model.CategoryTypeSystem {
|
||||
return nil, ErrCannotDeleteSystemCategory
|
||||
}
|
||||
|
||||
if err = a.moveBoardsToDefaultCategory(userID, teamID, categoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.store.DeleteCategory(categoryID, userID, teamID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deletedCategory, err := a.store.GetCategory(categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryChange(*deletedCategory)
|
||||
}()
|
||||
|
||||
return deletedCategory, nil
|
||||
}
|
||||
|
||||
func (a *App) moveBoardsToDefaultCategory(userID, teamID, sourceCategoryID string) error {
|
||||
// we need a list of boards associated to this category
|
||||
// so we can move them to user's default Boards category
|
||||
categoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sourceCategoryBoards *model.CategoryBoards
|
||||
defaultCategoryID := ""
|
||||
|
||||
// iterate user's categories to find the source category
|
||||
// and the default category.
|
||||
// We need source category to get the list of its board
|
||||
// and the default category to know its ID to
|
||||
// move source category's boards to.
|
||||
for i := range categoryBoards {
|
||||
if categoryBoards[i].ID == sourceCategoryID {
|
||||
sourceCategoryBoards = &categoryBoards[i]
|
||||
}
|
||||
|
||||
if categoryBoards[i].Name == defaultCategoryBoards {
|
||||
defaultCategoryID = categoryBoards[i].ID
|
||||
}
|
||||
|
||||
// if both categories are found, no need to iterate furthur.
|
||||
if sourceCategoryBoards != nil && defaultCategoryID != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sourceCategoryBoards == nil {
|
||||
return errCategoryNotFound
|
||||
}
|
||||
|
||||
if defaultCategoryID == "" {
|
||||
return fmt.Errorf("moveBoardsToDefaultCategory: %w", errNoDefaultCategoryFound)
|
||||
}
|
||||
|
||||
boardIDs := make([]string, len(sourceCategoryBoards.BoardMetadata))
|
||||
for i := range sourceCategoryBoards.BoardMetadata {
|
||||
boardIDs[i] = sourceCategoryBoards.BoardMetadata[i].BoardID
|
||||
}
|
||||
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
|
||||
return fmt.Errorf("moveBoardsToDefaultCategory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) {
|
||||
if err := a.verifyNewCategoriesMatchExisting(userID, teamID, newCategoryOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newOrder, err := a.store.ReorderCategories(userID, teamID, newCategoryOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryReorder(teamID, userID, newOrder)
|
||||
}()
|
||||
|
||||
return newOrder, nil
|
||||
}
|
||||
|
||||
func (a *App) verifyNewCategoriesMatchExisting(userID, teamID string, newCategoryOrder []string) error {
|
||||
existingCategories, err := a.store.GetUserCategories(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(newCategoryOrder) != len(existingCategories) {
|
||||
return fmt.Errorf(
|
||||
"%w length new categories: %d, length existing categories: %d, userID: %s, teamID: %s",
|
||||
errCategoriesLengthMismatch,
|
||||
len(newCategoryOrder),
|
||||
len(existingCategories),
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
|
||||
existingCategoriesMap := map[string]bool{}
|
||||
for _, category := range existingCategories {
|
||||
existingCategoriesMap[category.ID] = true
|
||||
}
|
||||
|
||||
for _, newCategoryID := range newCategoryOrder {
|
||||
if _, found := existingCategoriesMap[newCategoryID]; !found {
|
||||
return fmt.Errorf(
|
||||
"%w specified category ID: %s, userID: %s, teamID: %s",
|
||||
errCategoryNotFound,
|
||||
newCategoryID,
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
282
server/boards/app/category_boards.go
Обычный файл
282
server/boards/app/category_boards.go
Обычный файл
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const defaultCategoryBoards = "Boards"
|
||||
|
||||
var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database")
|
||||
var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID")
|
||||
var errBoardMembershipNotFound = errors.New("board membership not found for user's board")
|
||||
|
||||
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
|
||||
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdCategoryBoards, err := a.createDefaultCategoriesIfRequired(categoryBoards, userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
categoryBoards = append(categoryBoards, createdCategoryBoards...)
|
||||
return categoryBoards, nil
|
||||
}
|
||||
|
||||
func (a *App) createDefaultCategoriesIfRequired(existingCategoryBoards []model.CategoryBoards, userID, teamID string) ([]model.CategoryBoards, error) {
|
||||
createdCategories := []model.CategoryBoards{}
|
||||
|
||||
boardsCategoryExist := false
|
||||
for _, categoryBoard := range existingCategoryBoards {
|
||||
if categoryBoard.Name == defaultCategoryBoards {
|
||||
boardsCategoryExist = true
|
||||
}
|
||||
}
|
||||
|
||||
if !boardsCategoryExist {
|
||||
createdCategoryBoards, err := a.createBoardsCategory(userID, teamID, existingCategoryBoards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdCategories = append(createdCategories, *createdCategoryBoards)
|
||||
}
|
||||
|
||||
return createdCategories, nil
|
||||
}
|
||||
|
||||
func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards []model.CategoryBoards) (*model.CategoryBoards, error) {
|
||||
// create the category
|
||||
category := model.Category{
|
||||
Name: defaultCategoryBoards,
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
Collapsed: false,
|
||||
Type: model.CategoryTypeSystem,
|
||||
SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap,
|
||||
}
|
||||
createdCategory, err := a.CreateCategory(&category)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory default category creation failed: %w", err)
|
||||
}
|
||||
|
||||
// once the category is created, we need to move all boards which do not
|
||||
// belong to any category, into this category.
|
||||
|
||||
boardMembers, err := a.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory error fetching user's board memberships: %w", err)
|
||||
}
|
||||
|
||||
boardMemberByBoardID := map[string]*model.BoardMember{}
|
||||
for _, boardMember := range boardMembers {
|
||||
boardMemberByBoardID[boardMember.BoardID] = boardMember
|
||||
}
|
||||
|
||||
createdCategoryBoards := &model.CategoryBoards{
|
||||
Category: *createdCategory,
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
}
|
||||
|
||||
// get user's current team's baords
|
||||
userTeamBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
|
||||
}
|
||||
|
||||
boardIDsToAdd := []string{}
|
||||
|
||||
for _, board := range userTeamBoards {
|
||||
boardMembership, ok := boardMemberByBoardID[board.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("createBoardsCategory: %w", errBoardMembershipNotFound)
|
||||
}
|
||||
|
||||
// boards with implicit access (aka synthetic membership),
|
||||
// should show up in LHS only when openign them explicitelly.
|
||||
// So we don't process any synthetic membership boards
|
||||
// and only add boards with explicit access to, to the the LHS,
|
||||
// for example, if a user explicitelly added another user to a board.
|
||||
if boardMembership.Synthetic {
|
||||
continue
|
||||
}
|
||||
|
||||
belongsToCategory := false
|
||||
|
||||
for _, categoryBoard := range existingCategoryBoards {
|
||||
for _, metadata := range categoryBoard.BoardMetadata {
|
||||
if metadata.BoardID == board.ID {
|
||||
belongsToCategory = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// stop looking into other categories if
|
||||
// the board was found in a category
|
||||
if belongsToCategory {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !belongsToCategory {
|
||||
boardIDsToAdd = append(boardIDsToAdd, board.ID)
|
||||
newBoardMetadata := model.CategoryBoardMetadata{
|
||||
BoardID: board.ID,
|
||||
Hidden: false,
|
||||
}
|
||||
createdCategoryBoards.BoardMetadata = append(createdCategoryBoards.BoardMetadata, newBoardMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
if len(boardIDsToAdd) > 0 {
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, boardIDsToAdd); err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return createdCategoryBoards, nil
|
||||
}
|
||||
|
||||
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID string, boardIDs []string) error {
|
||||
if len(boardIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var updatedCategory *model.CategoryBoards
|
||||
for i := range userCategoryBoards {
|
||||
if userCategoryBoards[i].ID == categoryID {
|
||||
updatedCategory = &userCategoryBoards[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if updatedCategory == nil {
|
||||
return errCategoryNotFound
|
||||
}
|
||||
|
||||
wsPayload := make([]*model.BoardCategoryWebsocketData, len(updatedCategory.BoardMetadata))
|
||||
i := 0
|
||||
for _, categoryBoardMetadata := range updatedCategory.BoardMetadata {
|
||||
wsPayload[i] = &model.BoardCategoryWebsocketData{
|
||||
BoardID: categoryBoardMetadata.BoardID,
|
||||
CategoryID: categoryID,
|
||||
Hidden: categoryBoardMetadata.Hidden,
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastCategoryBoardChange(
|
||||
teamID,
|
||||
userID,
|
||||
wsPayload,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) {
|
||||
if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder)
|
||||
}()
|
||||
|
||||
return newOrder, nil
|
||||
}
|
||||
|
||||
func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error {
|
||||
// this function is to ensure that we don't miss specifying
|
||||
// all boards of the category while reordering.
|
||||
existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var targetCategoryBoards *model.CategoryBoards
|
||||
for i := range existingCategoryBoards {
|
||||
if existingCategoryBoards[i].Category.ID == categoryID {
|
||||
targetCategoryBoards = &existingCategoryBoards[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetCategoryBoards == nil {
|
||||
return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID)
|
||||
}
|
||||
|
||||
if len(targetCategoryBoards.BoardMetadata) != len(newBoardsOrder) {
|
||||
return fmt.Errorf(
|
||||
"%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s",
|
||||
errCategoryBoardsLengthMismatch,
|
||||
len(newBoardsOrder),
|
||||
len(targetCategoryBoards.BoardMetadata),
|
||||
userID,
|
||||
teamID,
|
||||
categoryID,
|
||||
)
|
||||
}
|
||||
|
||||
existingBoardMap := map[string]bool{}
|
||||
for _, metadata := range targetCategoryBoards.BoardMetadata {
|
||||
existingBoardMap[metadata.BoardID] = true
|
||||
}
|
||||
|
||||
for _, boardID := range newBoardsOrder {
|
||||
if _, found := existingBoardMap[boardID]; !found {
|
||||
return fmt.Errorf(
|
||||
"%w board ID: %s, category ID: %s, userID: %s, teamID: %s",
|
||||
errBoardNotFoundInCategory,
|
||||
boardID,
|
||||
categoryID,
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SetBoardVisibility(teamID, userID, categoryID, boardID string, visible bool) error {
|
||||
if err := a.store.SetBoardVisibility(userID, categoryID, boardID, visible); err != nil {
|
||||
return fmt.Errorf("SetBoardVisibility: failed to update board visibility: %w", err)
|
||||
}
|
||||
|
||||
a.wsAdapter.BroadcastCategoryBoardChange(teamID, userID, []*model.BoardCategoryWebsocketData{
|
||||
{
|
||||
BoardID: boardID,
|
||||
CategoryID: categoryID,
|
||||
Hidden: !visible,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
340
server/boards/app/category_boards_test.go
Обычный файл
340
server/boards/app/category_boards_test.go
Обычный файл
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestGetUserCategoryBoards(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("user had no default category and had boards", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: model.CategoryTypeSystem,
|
||||
Name: "Boards",
|
||||
},
|
||||
},
|
||||
}, nil).Times(1)
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board_id_2",
|
||||
}
|
||||
|
||||
board3 := &model.Board{
|
||||
ID: "board_id_3",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: false,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(categoryBoards))
|
||||
assert.Equal(t, "Boards", categoryBoards[0].Name)
|
||||
assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata))
|
||||
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false})
|
||||
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false})
|
||||
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false})
|
||||
})
|
||||
|
||||
t.Run("user had no default category BUT had no boards", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil)
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(categoryBoards))
|
||||
assert.Equal(t, "Boards", categoryBoards[0].Name)
|
||||
assert.Equal(t, 0, len(categoryBoards[0].BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user already had a default Boards category with boards in it", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{Name: "Boards"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1", Hidden: false},
|
||||
{BoardID: "board_id_2", Hidden: false},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(categoryBoards))
|
||||
assert.Equal(t, "Boards", categoryBoards[0].Name)
|
||||
assert.Equal(t, 2, len(categoryBoards[0].BoardMetadata))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateBoardsCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("user doesn't have any boards - implicit or explicit", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user has implicit access to some board", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: true,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: true,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: true,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
|
||||
// there should still be no boards in the default category as
|
||||
// the user had only implicit access to boards
|
||||
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user has explicit access to some board", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
board2 := &model.Board{
|
||||
ID: "board_id_2",
|
||||
}
|
||||
board3 := &model.Board{
|
||||
ID: "board_id_3",
|
||||
}
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: false,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
Type: model.CategoryTypeSystem,
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
|
||||
// since user has explicit access to three boards,
|
||||
// they should all end up in the default category
|
||||
assert.Equal(t, 3, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user has both implicit and explicit access to some board", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: true,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: true,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1"}).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
Type: model.CategoryTypeSystem,
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
|
||||
// there was only one explicit board access,
|
||||
// and so only that one should end up in the
|
||||
// default category
|
||||
assert.Equal(t, 1, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderCategoryBoards(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1", Hidden: false},
|
||||
{BoardID: "board_id_2", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().ReorderCategoryBoards("category_id_1", []string{"board_id_2", "board_id_1"}).Return([]string{"board_id_2", "board_id_1"}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(newOrder))
|
||||
assert.Equal(t, "board_id_2", newOrder[0])
|
||||
assert.Equal(t, "board_id_1", newOrder[1])
|
||||
})
|
||||
|
||||
t.Run("not specifying all boards", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1", Hidden: false},
|
||||
{BoardID: "board_id_2", Hidden: false},
|
||||
{BoardID: "board_id_3", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, newOrder)
|
||||
})
|
||||
}
|
||||
497
server/boards/app/category_test.go
Обычный файл
497
server/boards/app/category_test.go
Обычный файл
@@ -0,0 +1,497 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func TestCreateCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
Name: "Category",
|
||||
UserID: "user_id",
|
||||
TeamID: "team_id",
|
||||
Type: "custom",
|
||||
}
|
||||
createdCategory, err := th.App.CreateCategory(category)
|
||||
assert.NotNil(t, createdCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("creating invalid category", func(t *testing.T) {
|
||||
category := &model.Category{
|
||||
Name: "", // empty name shouldn't be allowed
|
||||
UserID: "user_id",
|
||||
TeamID: "team_id",
|
||||
Type: "custom",
|
||||
}
|
||||
createdCategory, err := th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Name = "Name"
|
||||
category.UserID = "" // empty creator user id shouldn't be allowed
|
||||
createdCategory, err = th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.UserID = "user_id"
|
||||
category.TeamID = "" // empty TeamID shouldn't be allowed
|
||||
createdCategory, err = th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Type = "invalid" // unknown type shouldn't be allowed
|
||||
createdCategory, err = th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.NotNil(t, updatedCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("updating invalid category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Name",
|
||||
UserID: "user_id",
|
||||
TeamID: "team_id",
|
||||
Type: "custom",
|
||||
}
|
||||
|
||||
category.ID = ""
|
||||
createdCategory, err := th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.ID = "category_id_1"
|
||||
category.Name = ""
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Name = "Name"
|
||||
category.UserID = "" // empty creator user id shouldn't be allowed
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.UserID = "user_id"
|
||||
category.TeamID = "" // empty TeamID shouldn't be allowed
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Type = "invalid" // unknown type shouldn't be allowed
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to update someone else's category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
UserID: "user_id_2",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.Nil(t, updatedCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to update some other team's category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_2",
|
||||
Type: "custom",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.Nil(t, updatedCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("should not be allowed to rename system category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
}, nil).Times(1)
|
||||
|
||||
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
Collapsed: true,
|
||||
}, nil).Times(1)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Updated Name",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "system",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.NotNil(t, updatedCategory)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Category", updatedCategory.Name)
|
||||
})
|
||||
|
||||
t.Run("should be allowed to collapse and expand any category type", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
Collapsed: false,
|
||||
}, nil).Times(1)
|
||||
|
||||
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
Collapsed: true,
|
||||
}, nil).Times(1)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Updated Name",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "system",
|
||||
Collapsed: true,
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.NotNil(t, updatedCategory)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Category", updatedCategory.Name, "The name should have not been updated")
|
||||
assert.True(t, updatedCategory.Collapsed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().DeleteCategory("category_id_1", "user_id_1", "team_id_1").Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
DeleteAt: 10000,
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_default",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "default",
|
||||
Name: "Boards",
|
||||
},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
Name: "Category 1",
|
||||
},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
|
||||
assert.NotNil(t, deletedCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to delete already deleted category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 1000,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
|
||||
assert.NotNil(t, deletedCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to delete system category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "system",
|
||||
}, nil)
|
||||
|
||||
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
|
||||
assert.Nil(t, deletedCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveBoardsToDefaultCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("When default category already exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_2",
|
||||
Name: "Custom Category 1",
|
||||
Type: "custom",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("When default category doesn't already exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_2",
|
||||
Name: "Custom Category 1",
|
||||
Type: "custom",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
|
||||
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderCategories(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}).
|
||||
Return([]string{"category_id_2", "category_id_3", "category_id_1"}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(newOrder))
|
||||
})
|
||||
|
||||
t.Run("not specifying all categories should fail", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, newOrder)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyNewCategoriesMatchExisting(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
|
||||
"category_id_2",
|
||||
"category_id_3",
|
||||
"category_id_1",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("different category counts", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
|
||||
"category_id_2",
|
||||
"category_id_3",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
19
server/boards/app/clientConfig.go
Обычный файл
19
server/boards/app/clientConfig.go
Обычный файл
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetClientConfig() *model.ClientConfig {
|
||||
return &model.ClientConfig{
|
||||
Telemetry: a.config.Telemetry,
|
||||
TelemetryID: a.config.TelemetryID,
|
||||
EnablePublicSharedBoards: a.config.EnablePublicSharedBoards,
|
||||
TeammateNameDisplay: a.config.TeammateNameDisplay,
|
||||
FeatureFlags: a.config.FeatureFlags,
|
||||
MaxFileSize: a.config.MaxFileSize,
|
||||
}
|
||||
}
|
||||
36
server/boards/app/clientConfig_test.go
Обычный файл
36
server/boards/app/clientConfig_test.go
Обычный файл
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
)
|
||||
|
||||
func TestGetClientConfig(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("Test Get Client Config", func(t *testing.T) {
|
||||
newConfiguration := config.Configuration{}
|
||||
newConfiguration.Telemetry = true
|
||||
newConfiguration.TelemetryID = "abcde"
|
||||
newConfiguration.EnablePublicSharedBoards = true
|
||||
newConfiguration.FeatureFlags = make(map[string]string)
|
||||
newConfiguration.FeatureFlags["BoardsFeature1"] = "true"
|
||||
newConfiguration.FeatureFlags["BoardsFeature2"] = "true"
|
||||
newConfiguration.TeammateNameDisplay = "username"
|
||||
th.App.SetConfig(&newConfiguration)
|
||||
|
||||
clientConfig := th.App.GetClientConfig()
|
||||
require.True(t, clientConfig.EnablePublicSharedBoards)
|
||||
require.True(t, clientConfig.Telemetry)
|
||||
require.Equal(t, "abcde", clientConfig.TelemetryID)
|
||||
require.Equal(t, 2, len(clientConfig.FeatureFlags))
|
||||
require.Equal(t, "username", clientConfig.TeammateNameDisplay)
|
||||
})
|
||||
}
|
||||
334
server/boards/app/cloud.go
Обычный файл
334
server/boards/app/cloud.go
Обычный файл
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
var ErrNilPluginAPI = errors.New("server not running in plugin mode")
|
||||
|
||||
// GetBoardsCloudLimits returns the limits of the server, and an empty
|
||||
// limits struct if there are no limits set.
|
||||
func (a *App) GetBoardsCloudLimits() (*model.BoardsCloudLimits, error) {
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
/*
|
||||
if !a.IsCloud() {
|
||||
return &model.BoardsCloudLimits{}, nil
|
||||
}
|
||||
|
||||
productLimits, err := a.store.GetCloudLimits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usedCards, err := a.store.GetUsedCardsCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardsCloudLimits := &model.BoardsCloudLimits{
|
||||
UsedCards: usedCards,
|
||||
CardLimitTimestamp: cardLimitTimestamp,
|
||||
}
|
||||
if productLimits != nil && productLimits.Boards != nil {
|
||||
if productLimits.Boards.Cards != nil {
|
||||
boardsCloudLimits.Cards = *productLimits.Boards.Cards
|
||||
}
|
||||
if productLimits.Boards.Views != nil {
|
||||
boardsCloudLimits.Views = *productLimits.Boards.Views
|
||||
}
|
||||
}
|
||||
|
||||
return boardsCloudLimits, nil
|
||||
*/
|
||||
|
||||
return &model.BoardsCloudLimits{}, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUsedCardsCount() (int, error) {
|
||||
return a.store.GetUsedCardsCount()
|
||||
}
|
||||
|
||||
// IsCloud returns true if the server is running as a plugin in a
|
||||
// cloud licensed server.
|
||||
func (a *App) IsCloud() bool {
|
||||
return utils.IsCloudLicense(a.store.GetLicense())
|
||||
}
|
||||
|
||||
// IsCloudLimited returns true if the server is running in cloud mode
|
||||
// and the card limit has been set.
|
||||
func (a *App) IsCloudLimited() bool {
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
|
||||
// return a.CardLimit() != 0 && a.IsCloud()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetCloudLimits sets the limits of the server.
|
||||
func (a *App) SetCloudLimits(limits *mm_model.ProductLimits) error {
|
||||
oldCardLimit := a.CardLimit()
|
||||
|
||||
// if the limit object doesn't come complete, we assume limits are
|
||||
// being disabled
|
||||
cardLimit := 0
|
||||
if limits != nil && limits.Boards != nil && limits.Boards.Cards != nil {
|
||||
cardLimit = *limits.Boards.Cards
|
||||
}
|
||||
|
||||
if oldCardLimit != cardLimit {
|
||||
a.logger.Info(
|
||||
"setting new cloud limits",
|
||||
mlog.Int("oldCardLimit", oldCardLimit),
|
||||
mlog.Int("cardLimit", cardLimit),
|
||||
)
|
||||
a.SetCardLimit(cardLimit)
|
||||
return a.doUpdateCardLimitTimestamp()
|
||||
}
|
||||
|
||||
a.logger.Info(
|
||||
"setting new cloud limits, equivalent to the existing ones",
|
||||
mlog.Int("cardLimit", cardLimit),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// doUpdateCardLimitTimestamp performs the update without running any
|
||||
// checks.
|
||||
func (a *App) doUpdateCardLimitTimestamp() error {
|
||||
cardLimitTimestamp, err := a.store.UpdateCardLimitTimestamp(a.CardLimit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.wsAdapter.BroadcastCardLimitTimestampChange(cardLimitTimestamp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCardLimitTimestamp checks if the server is a cloud instance
|
||||
// with limits applied, and if that's true, recalculates the card
|
||||
// limit timestamp and propagates the new one to the connected
|
||||
// clients.
|
||||
func (a *App) UpdateCardLimitTimestamp() error {
|
||||
if !a.IsCloudLimited() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return a.doUpdateCardLimitTimestamp()
|
||||
}
|
||||
|
||||
// getTemplateMapForBlocks gets all board ids for the blocks, and
|
||||
// builds a map with the board IDs as the key and their isTemplate
|
||||
// field as the value.
|
||||
func (a *App) getTemplateMapForBlocks(blocks []*model.Block) (map[string]bool, error) {
|
||||
boardMap := map[string]*model.Board{}
|
||||
for _, block := range blocks {
|
||||
if _, ok := boardMap[block.BoardID]; !ok {
|
||||
board, err := a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardMap[block.BoardID] = board
|
||||
}
|
||||
}
|
||||
|
||||
templateMap := map[string]bool{}
|
||||
for boardID, board := range boardMap {
|
||||
templateMap[boardID] = board.IsTemplate
|
||||
}
|
||||
|
||||
return templateMap, nil
|
||||
}
|
||||
|
||||
// ApplyCloudLimits takes a set of blocks and, if the server is cloud
|
||||
// limited, limits those that are outside of the card limit and don't
|
||||
// belong to a template.
|
||||
func (a *App) ApplyCloudLimits(blocks []*model.Block) ([]*model.Block, error) {
|
||||
// if there is no limit currently being applied, return
|
||||
if !a.IsCloudLimited() {
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templateMap, err := a.getTemplateMapForBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limitedBlocks := make([]*model.Block, len(blocks))
|
||||
for i, block := range blocks {
|
||||
// if the block belongs to a template, it will never be
|
||||
// limited
|
||||
if isTemplate, ok := templateMap[block.BoardID]; ok && isTemplate {
|
||||
limitedBlocks[i] = block
|
||||
continue
|
||||
}
|
||||
|
||||
if block.ShouldBeLimited(cardLimitTimestamp) {
|
||||
limitedBlocks[i] = block.GetLimited()
|
||||
} else {
|
||||
limitedBlocks[i] = block
|
||||
}
|
||||
}
|
||||
|
||||
return limitedBlocks, nil
|
||||
}
|
||||
|
||||
// ContainsLimitedBlocks checks if a list of blocks contain any block
|
||||
// that references a limited card.
|
||||
func (a *App) ContainsLimitedBlocks(blocks []*model.Block) (bool, error) {
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if cardLimitTimestamp == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cards := []*model.Block{}
|
||||
cardIDMap := map[string]bool{}
|
||||
for _, block := range blocks {
|
||||
switch block.Type {
|
||||
case model.TypeCard:
|
||||
cards = append(cards, block)
|
||||
default:
|
||||
cardIDMap[block.ParentID] = true
|
||||
}
|
||||
}
|
||||
|
||||
cardIDs := []string{}
|
||||
// if the card is already present on the set, we don't need to
|
||||
// fetch it from the database
|
||||
for cardID := range cardIDMap {
|
||||
alreadyPresent := false
|
||||
for _, card := range cards {
|
||||
if card.ID == cardID {
|
||||
alreadyPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !alreadyPresent {
|
||||
cardIDs = append(cardIDs, cardID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cardIDs) > 0 {
|
||||
fetchedCards, fErr := a.store.GetBlocksByIDs(cardIDs)
|
||||
if fErr != nil {
|
||||
return false, fErr
|
||||
}
|
||||
cards = append(cards, fetchedCards...)
|
||||
}
|
||||
|
||||
templateMap, err := a.getTemplateMapForBlocks(cards)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, card := range cards {
|
||||
isTemplate, ok := templateMap[card.BoardID]
|
||||
if !ok {
|
||||
return false, newErrBoardNotFoundInTemplateMap(card.BoardID)
|
||||
}
|
||||
|
||||
// if the block belongs to a template, it will never be
|
||||
// limited
|
||||
if isTemplate {
|
||||
continue
|
||||
}
|
||||
|
||||
if card.ShouldBeLimited(cardLimitTimestamp) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type errBoardNotFoundInTemplateMap struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func newErrBoardNotFoundInTemplateMap(id string) *errBoardNotFoundInTemplateMap {
|
||||
return &errBoardNotFoundInTemplateMap{id}
|
||||
}
|
||||
|
||||
func (eb *errBoardNotFoundInTemplateMap) Error() string {
|
||||
return fmt.Sprintf("board %q not found in template map", eb.id)
|
||||
}
|
||||
|
||||
func (a *App) NotifyPortalAdminsUpgradeRequest(teamID string) error {
|
||||
if a.servicesAPI == nil {
|
||||
return ErrNilPluginAPI
|
||||
}
|
||||
|
||||
team, err := a.store.GetTeam(teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ofWhat string
|
||||
if team == nil {
|
||||
ofWhat = "your organization"
|
||||
} else {
|
||||
ofWhat = team.Title
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("A member of %s has notified you to upgrade this workspace before the trial ends.", ofWhat)
|
||||
|
||||
page := 0
|
||||
getUsersOptions := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: page,
|
||||
}
|
||||
|
||||
for ; true; page++ {
|
||||
getUsersOptions.Page = page
|
||||
systemAdmins, appErr := a.servicesAPI.GetUsersFromProfiles(getUsersOptions)
|
||||
if appErr != nil {
|
||||
a.logger.Error("failed to fetch system admins", mlog.Int("page_size", getUsersOptions.PerPage), mlog.Int("page", page), mlog.Err(appErr))
|
||||
return appErr
|
||||
}
|
||||
|
||||
if len(systemAdmins) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
receiptUserIDs := []string{}
|
||||
for _, systemAdmin := range systemAdmins {
|
||||
receiptUserIDs = append(receiptUserIDs, systemAdmin.Id)
|
||||
}
|
||||
|
||||
if err := a.store.SendMessage(message, "custom_cloud_upgrade_nudge", receiptUserIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
780
server/boards/app/cloud_test.go
Обычный файл
780
server/boards/app/cloud_test.go
Обычный файл
@@ -0,0 +1,780 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
mockservicesapi "github.com/mattermost/mattermost-server/v6/server/boards/model/mocks"
|
||||
)
|
||||
|
||||
func TestIsCloud(t *testing.T) {
|
||||
t.Run("if it's not running on plugin mode", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(nil)
|
||||
require.False(t, th.App.IsCloud())
|
||||
})
|
||||
|
||||
t.Run("if it's running on plugin mode but the license is incomplete", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.False(t, th.App.IsCloud())
|
||||
|
||||
fakeLicense = &mm_model.License{Features: &mm_model.Features{}}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.False(t, th.App.IsCloud())
|
||||
})
|
||||
|
||||
t.Run("if it's running on plugin mode, with a non-cloud license", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(false)},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.False(t, th.App.IsCloud())
|
||||
})
|
||||
|
||||
t.Run("if it's running on plugin mode with a cloud license", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.True(t, th.App.IsCloud())
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsCloudLimited(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("if no limit has been set, it should be false", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
require.False(t, th.App.IsCloudLimited())
|
||||
})
|
||||
|
||||
t.Run("if the limit is set, it should be true", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
require.True(t, th.App.IsCloudLimited())
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetCloudLimits(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("if the limits are empty, it should do nothing", func(t *testing.T) {
|
||||
t.Run("limits empty", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(nil))
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("limits not empty but board limits empty", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
limits := &mm_model.ProductLimits{}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("limits not empty but board limits values empty", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
limits := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{},
|
||||
}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("if the limits are not empty, it should update them and calculate the new timestamp", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
newCardLimitTimestamp := int64(27)
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(5).Return(newCardLimitTimestamp, nil)
|
||||
|
||||
limits := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{Cards: mm_model.NewInt(5)},
|
||||
}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Equal(t, 5, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("if the limits are already set and we unset them, the timestamp will be unset too", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(20)
|
||||
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(0)
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(nil))
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("if the limits are already set and we try to set the same ones again", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(20)
|
||||
|
||||
// the call to update card limit timestamp should not happen
|
||||
// as the limits didn't change
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
|
||||
|
||||
limits := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{Cards: mm_model.NewInt(20)},
|
||||
}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Equal(t, 20, th.App.CardLimit())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateCardLimitTimestamp(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
t.Run("if the server is a cloud instance but not limited, it should do nothing", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
// the license check will not be done as the limit not being
|
||||
// set is enough for the method to return
|
||||
th.Store.EXPECT().GetLicense().Times(0)
|
||||
// no call to UpdateCardLimitTimestamp should happen as the
|
||||
// method should shortcircuit if not cloud limited
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
|
||||
|
||||
require.NoError(t, th.App.UpdateCardLimitTimestamp())
|
||||
})
|
||||
|
||||
t.Run("if the server is a cloud instance and the timestamp is set, it should run the update", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
// no call to UpdateCardLimitTimestamp should happen as the
|
||||
// method should shortcircuit if not cloud limited
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(5)
|
||||
|
||||
require.NoError(t, th.App.UpdateCardLimitTimestamp())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTemplateMapForBlocks(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("should fetch the necessary boards from the database", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board2",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: false,
|
||||
}
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
},
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board2",
|
||||
BoardID: "board2",
|
||||
},
|
||||
{
|
||||
ID: "text2",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card2",
|
||||
BoardID: "board2",
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().
|
||||
GetBoard("board1").
|
||||
Return(board1, nil).
|
||||
Times(1)
|
||||
th.Store.EXPECT().
|
||||
GetBoard("board2").
|
||||
Return(board2, nil).
|
||||
Times(1)
|
||||
|
||||
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, templateMap, 2)
|
||||
require.Contains(t, templateMap, "board1")
|
||||
require.True(t, templateMap["board1"])
|
||||
require.Contains(t, templateMap, "board2")
|
||||
require.False(t, templateMap["board2"])
|
||||
})
|
||||
|
||||
t.Run("should fail if the board is not in the database", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
},
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board2",
|
||||
BoardID: "board2",
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().
|
||||
GetBoard("board1").
|
||||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
require.Empty(t, templateMap)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyCloudLimits(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: false,
|
||||
}
|
||||
|
||||
template := &model.Board{
|
||||
ID: "template",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
{
|
||||
ID: "card-from-template",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "template",
|
||||
BoardID: "template",
|
||||
UpdateAt: 1,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("if the server is not limited, it should return the blocks untouched", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
newBlocks, err := th.App.ApplyCloudLimits(blocks)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, blocks, newBlocks)
|
||||
})
|
||||
|
||||
t.Run("if the server is limited, it should limit the blocks that are beyond the card limit timestamp", func(t *testing.T) {
|
||||
findBlock := func(blocks []*model.Block, id string) *model.Block {
|
||||
for _, block := range blocks {
|
||||
if block.ID == id {
|
||||
return block
|
||||
}
|
||||
}
|
||||
require.FailNow(t, "block %s not found", id)
|
||||
return &model.Block{} // this should never be reached
|
||||
}
|
||||
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil).Times(1)
|
||||
th.Store.EXPECT().GetBoard("template").Return(template, nil).Times(1)
|
||||
|
||||
newBlocks, err := th.App.ApplyCloudLimits(blocks)
|
||||
require.NoError(t, err)
|
||||
|
||||
// should be limited as it's beyond the threshold
|
||||
require.True(t, findBlock(newBlocks, "card1").Limited)
|
||||
// only cards are limited
|
||||
require.False(t, findBlock(newBlocks, "text1").Limited)
|
||||
// should not be limited as it's not beyond the threshold
|
||||
require.False(t, findBlock(newBlocks, "card2").Limited)
|
||||
// cards belonging to templates are never limited
|
||||
require.False(t, findBlock(newBlocks, "card-from-template").Limited)
|
||||
})
|
||||
}
|
||||
|
||||
func TestContainsLimitedBlocks(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
// for all the following tests, the timestamp will be set to 150,
|
||||
// which means that blocks with an UpdateAt set to 100 will be
|
||||
// outside the active window and possibly limited, and blocks with
|
||||
// UpdateAt set to 200 will not
|
||||
|
||||
t.Run("should return false if the card limit timestamp is zero", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(0), nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return true if the block set contains a card that is limited", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.True(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return false if that same block belongs to a template", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return true if the block contains a content block that belongs to a card that should be limited", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
}
|
||||
|
||||
card1 := &model.Block{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]*model.Block{card1}, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.True(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return false if that same block belongs to a card that is inside the active window", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
}
|
||||
|
||||
card1 := &model.Block{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]*model.Block{card1}, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should reach to the database to fetch the necessary information only in an efficient way", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
// a content block that references a card that needs
|
||||
// fetching
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
// a board that needs fetching referenced by a card and a content block
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board2",
|
||||
BoardID: "board2",
|
||||
// per timestamp should be limited but the board is a
|
||||
// template
|
||||
UpdateAt: 100,
|
||||
},
|
||||
{
|
||||
ID: "text2",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card2",
|
||||
BoardID: "board2",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
// a content block that references a card and a board,
|
||||
// both absent
|
||||
{
|
||||
ID: "image3",
|
||||
Type: model.TypeImage,
|
||||
ParentID: "card3",
|
||||
BoardID: "board3",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
card1 := &model.Block{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
}
|
||||
|
||||
card3 := &model.Block{
|
||||
ID: "card3",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board3",
|
||||
BoardID: "board3",
|
||||
UpdateAt: 200,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board2",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
board3 := &model.Board{
|
||||
ID: "board3",
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBlocksByIDs(gomock.InAnyOrder([]string{"card1", "card3"})).Return([]*model.Block{card1, card3}, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
th.Store.EXPECT().GetBoard("board2").Return(board2, nil)
|
||||
th.Store.EXPECT().GetBoard("board3").Return(board3, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyPortalAdminsUpgradeRequest(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("should send message", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
|
||||
sysAdmin1 := &mm_model.User{
|
||||
Id: "michael-scott",
|
||||
Username: "Michael Scott",
|
||||
}
|
||||
|
||||
sysAdmin2 := &mm_model.User{
|
||||
Id: "dwight-schrute",
|
||||
Username: "Dwight Schrute",
|
||||
}
|
||||
|
||||
getUsersOptionsPage0 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{sysAdmin1, sysAdmin2}, nil)
|
||||
|
||||
getUsersOptionsPage1 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 1,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage1).Return([]*mm_model.User{}, nil)
|
||||
|
||||
th.App.servicesAPI = servicesAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(1)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("no sys admins found", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
|
||||
getUsersOptionsPage0 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{}, nil)
|
||||
|
||||
th.App.servicesAPI = servicesAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("iterate multiple pages", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
|
||||
sysAdmin1 := &mm_model.User{
|
||||
Id: "michael-scott",
|
||||
Username: "Michael Scott",
|
||||
}
|
||||
|
||||
sysAdmin2 := &mm_model.User{
|
||||
Id: "dwight-schrute",
|
||||
Username: "Dwight Schrute",
|
||||
}
|
||||
|
||||
getUsersOptionsPage0 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{sysAdmin1}, nil)
|
||||
|
||||
getUsersOptionsPage1 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 1,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage1).Return([]*mm_model.User{sysAdmin2}, nil)
|
||||
|
||||
getUsersOptionsPage2 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 2,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage2).Return([]*mm_model.User{}, nil)
|
||||
|
||||
th.App.servicesAPI = servicesAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(2)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
18
server/boards/app/compliance.go
Обычный файл
18
server/boards/app/compliance.go
Обычный файл
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
func (a *App) GetBoardsForCompliance(opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) {
|
||||
return a.store.GetBoardsForCompliance(opts)
|
||||
}
|
||||
|
||||
func (a *App) GetBoardsComplianceHistory(opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) {
|
||||
return a.store.GetBoardsComplianceHistory(opts)
|
||||
}
|
||||
|
||||
func (a *App) GetBlocksComplianceHistory(opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) {
|
||||
return a.store.GetBlocksComplianceHistory(opts)
|
||||
}
|
||||
85
server/boards/app/content_blocks.go
Обычный файл
85
server/boards/app/content_blocks.go
Обычный файл
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) MoveContentBlock(block *model.Block, dstBlock *model.Block, where string, userID string) error {
|
||||
if block.ParentID != dstBlock.ParentID {
|
||||
message := fmt.Sprintf("not matching parent %s and %s", block.ParentID, dstBlock.ParentID)
|
||||
return model.NewErrBadRequest(message)
|
||||
}
|
||||
|
||||
card, err := a.GetBlockByID(block.ParentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentOrderData, ok := card.Fields["contentOrder"]
|
||||
var contentOrder []interface{}
|
||||
if ok {
|
||||
contentOrder = contentOrderData.([]interface{})
|
||||
}
|
||||
|
||||
newContentOrder := []interface{}{}
|
||||
foundDst := false
|
||||
foundSrc := false
|
||||
for _, id := range contentOrder {
|
||||
stringID, ok := id.(string)
|
||||
if !ok {
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
continue
|
||||
}
|
||||
|
||||
if dstBlock.ID == stringID {
|
||||
foundDst = true
|
||||
if where == "after" {
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
newContentOrder = append(newContentOrder, block.ID)
|
||||
} else {
|
||||
newContentOrder = append(newContentOrder, block.ID)
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if block.ID == stringID {
|
||||
foundSrc = true
|
||||
continue
|
||||
}
|
||||
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
}
|
||||
|
||||
if !foundSrc {
|
||||
message := fmt.Sprintf("source block %s not found", block.ID)
|
||||
return model.NewErrBadRequest(message)
|
||||
}
|
||||
|
||||
if !foundDst {
|
||||
message := fmt.Sprintf("destination block %s not found", dstBlock.ID)
|
||||
return model.NewErrBadRequest(message)
|
||||
}
|
||||
|
||||
patch := &model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
"contentOrder": newContentOrder,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = a.PatchBlock(block.ParentID, patch, userID)
|
||||
if errors.Is(err, model.ErrPatchUpdatesLimitedCards) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
194
server/boards/app/content_blocks_test.go
Обычный файл
194
server/boards/app/content_blocks_test.go
Обычный файл
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type contentOrderMatcher struct {
|
||||
contentOrder []string
|
||||
}
|
||||
|
||||
func NewContentOrderMatcher(contentOrder []string) contentOrderMatcher {
|
||||
return contentOrderMatcher{contentOrder}
|
||||
}
|
||||
|
||||
func (com contentOrderMatcher) Matches(x interface{}) bool {
|
||||
patch, ok := x.(*model.BlockPatch)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
contentOrderData, ok := patch.UpdatedFields["contentOrder"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
contentOrder, ok := contentOrderData.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(contentOrder) != len(com.contentOrder) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range contentOrder {
|
||||
if contentOrder[i] != com.contentOrder[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (com contentOrderMatcher) String() string {
|
||||
return fmt.Sprint(&model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": com.contentOrder}})
|
||||
}
|
||||
|
||||
func TestMoveContentBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
ttCases := []struct {
|
||||
name string
|
||||
srcBlock model.Block
|
||||
dstBlock model.Block
|
||||
parentBlock *model.Block
|
||||
where string
|
||||
userID string
|
||||
mockPatch bool
|
||||
mockPatchError error
|
||||
errorMessage string
|
||||
expectedContentOrder []string
|
||||
}{
|
||||
{
|
||||
name: "not matching parents",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "other-test-card"},
|
||||
parentBlock: nil,
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "not matching parent test-card and other-test-card",
|
||||
},
|
||||
{
|
||||
name: "parent not found",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "invalid-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "invalid-card"},
|
||||
parentBlock: &model.Block{ID: "invalid-card"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "{test} not found",
|
||||
},
|
||||
{
|
||||
name: "valid parent without content order",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "source block test-1 not found",
|
||||
},
|
||||
{
|
||||
name: "valid parent with content order but without test-1 in it",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-2"}}},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "source block test-1 not found",
|
||||
},
|
||||
{
|
||||
name: "valid parent with content order but without test-2 in it",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1"}}},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "destination block test-2 not found",
|
||||
},
|
||||
{
|
||||
name: "valid request but fail on patchparent with content order",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2"}}},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
mockPatchError: errors.New("test error"),
|
||||
errorMessage: "test error",
|
||||
},
|
||||
{
|
||||
name: "valid request with not real change",
|
||||
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
errorMessage: "",
|
||||
expectedContentOrder: []string{"test-1", "test-2", "test-3"},
|
||||
},
|
||||
{
|
||||
name: "valid request changing order with before",
|
||||
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
|
||||
where: "before",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
errorMessage: "",
|
||||
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
|
||||
},
|
||||
{
|
||||
name: "valid request changing order with after",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
errorMessage: "",
|
||||
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range ttCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.parentBlock != nil {
|
||||
if tc.parentBlock.ID == "invalid-card" {
|
||||
th.Store.EXPECT().GetBlock(tc.srcBlock.ParentID).Return(nil, model.NewErrNotFound("test"))
|
||||
} else {
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
|
||||
if tc.mockPatch {
|
||||
if tc.mockPatchError != nil {
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(nil, tc.mockPatchError)
|
||||
} else {
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
|
||||
th.Store.EXPECT().PatchBlock(tc.parentBlock.ID, NewContentOrderMatcher(tc.expectedContentOrder), gomock.Eq("user-id")).Return(nil)
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
|
||||
th.Store.EXPECT().GetBoard(tc.parentBlock.BoardID).Return(&model.Board{ID: "test-board"}, nil)
|
||||
// this call comes from the WS server notification
|
||||
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := th.App.MoveContentBlock(&tc.srcBlock, &tc.dstBlock, tc.where, tc.userID)
|
||||
if tc.errorMessage == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.EqualError(t, err, tc.errorMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
253
server/boards/app/export.go
Обычный файл
253
server/boards/app/export.go
Обычный файл
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
newline = []byte{'\n'}
|
||||
)
|
||||
|
||||
func (a *App) ExportArchive(w io.Writer, opt model.ExportArchiveOptions) (errs error) {
|
||||
boards, err := a.getBoardsForArchive(opt.BoardIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
defer func() {
|
||||
errs = merr.ErrorOrNil()
|
||||
}()
|
||||
|
||||
// wrap the writer in a zip.
|
||||
zw := zip.NewWriter(w)
|
||||
defer func() {
|
||||
merr.Append(zw.Close())
|
||||
}()
|
||||
|
||||
if err := a.writeArchiveVersion(zw); err != nil {
|
||||
merr.Append(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, board := range boards {
|
||||
if err := a.writeArchiveBoard(zw, board, opt); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot export board %s: %w", board.ID, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeArchiveVersion writes a version file to the zip.
|
||||
func (a *App) writeArchiveVersion(zw *zip.Writer) error {
|
||||
archiveHeader := model.ArchiveHeader{
|
||||
Version: archiveVersion,
|
||||
Date: model.GetMillis(),
|
||||
}
|
||||
b, _ := json.Marshal(&archiveHeader)
|
||||
|
||||
w, err := zw.Create("version.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write archive header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(b); err != nil {
|
||||
return fmt.Errorf("cannot write archive header: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeArchiveBoard writes a single board to the archive in a zip directory.
|
||||
func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.ExportArchiveOptions) error {
|
||||
// create a directory per board
|
||||
w, err := zw.Create(board.ID + "/board.jsonl")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write the board block first
|
||||
if err = a.writeArchiveBoardLine(w, board); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var files []string
|
||||
// write the board's blocks
|
||||
// TODO: paginate this
|
||||
blocks, err := a.GetBlocksForBoard(board.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, block := range blocks {
|
||||
if err = a.writeArchiveBlockLine(w, block); err != nil {
|
||||
return err
|
||||
}
|
||||
if block.Type == model.TypeImage {
|
||||
filename, err2 := extractImageFilename(block)
|
||||
if err2 != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, filename)
|
||||
}
|
||||
}
|
||||
|
||||
boardMembers, err := a.GetMembersForBoard(board.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, boardMember := range boardMembers {
|
||||
if err = a.writeArchiveBoardMemberLine(w, boardMember); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// write the files
|
||||
for _, filename := range files {
|
||||
if err := a.writeArchiveFile(zw, filename, board.ID, opt); err != nil {
|
||||
return fmt.Errorf("cannot write file %s to archive: %w", filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeArchiveBoardMemberLine writes a single boardMember to the archive.
|
||||
func (a *App) writeArchiveBoardMemberLine(w io.Writer, boardMember *model.BoardMember) error {
|
||||
bm, err := json.Marshal(&boardMember)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line := model.ArchiveLine{
|
||||
Type: "boardMember",
|
||||
Data: bm,
|
||||
}
|
||||
|
||||
bm, err = json.Marshal(&line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(bm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(newline)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeArchiveBlockLine writes a single block to the archive.
|
||||
func (a *App) writeArchiveBlockLine(w io.Writer, block *model.Block) error {
|
||||
b, err := json.Marshal(&block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line := model.ArchiveLine{
|
||||
Type: "block",
|
||||
Data: b,
|
||||
}
|
||||
|
||||
b, err = json.Marshal(&line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// jsonl files need a newline
|
||||
_, err = w.Write(newline)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeArchiveBlockLine writes a single block to the archive.
|
||||
func (a *App) writeArchiveBoardLine(w io.Writer, board model.Board) error {
|
||||
b, err := json.Marshal(&board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line := model.ArchiveLine{
|
||||
Type: "board",
|
||||
Data: b,
|
||||
}
|
||||
|
||||
b, err = json.Marshal(&line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// jsonl files need a newline
|
||||
_, err = w.Write(newline)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeArchiveFile writes a single file to the archive.
|
||||
func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string, opt model.ExportArchiveOptions) error {
|
||||
dest, err := zw.Create(boardID + "/" + filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := a.GetFileReader(opt.TeamID, boardID, filename)
|
||||
if err != nil {
|
||||
// just log this; image file is missing but we'll still export an equivalent board
|
||||
a.logger.Error("image file missing for export",
|
||||
mlog.String("filename", filename),
|
||||
mlog.String("team_id", opt.TeamID),
|
||||
mlog.String("board_id", boardID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
_, err = io.Copy(dest, src)
|
||||
return err
|
||||
}
|
||||
|
||||
// getBoardsForArchive fetches all the specified boards.
|
||||
func (a *App) getBoardsForArchive(boardIDs []string) ([]model.Board, error) {
|
||||
boards := make([]model.Board, 0, len(boardIDs))
|
||||
|
||||
for _, id := range boardIDs {
|
||||
b, err := a.GetBoard(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not fetch board %s: %w", id, err)
|
||||
}
|
||||
|
||||
boards = append(boards, *b)
|
||||
}
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func extractImageFilename(imageBlock *model.Block) (string, error) {
|
||||
f, ok := imageBlock.Fields["fileId"]
|
||||
if !ok {
|
||||
return "", model.ErrInvalidImageBlock
|
||||
}
|
||||
|
||||
filename, ok := f.(string)
|
||||
if !ok {
|
||||
return "", model.ErrInvalidImageBlock
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
177
server/boards/app/files.go
Обычный файл
177
server/boards/app/files.go
Обычный файл
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const emptyString = "empty"
|
||||
|
||||
var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed")
|
||||
var ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (string, error) {
|
||||
// NOTE: File extension includes the dot
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
if fileExtension == ".jpeg" {
|
||||
fileExtension = ".jpg"
|
||||
}
|
||||
|
||||
createdFilename := utils.NewID(utils.IDTypeNone)
|
||||
fullFilename := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
|
||||
filePath := filepath.Join(utils.GetBaseFilePath(), fullFilename)
|
||||
|
||||
fileSize, appErr := a.filesBackend.WriteFile(reader, filePath)
|
||||
if appErr != nil {
|
||||
return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr)
|
||||
}
|
||||
|
||||
now := utils.GetMillis()
|
||||
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: createdFilename[1:],
|
||||
CreatorId: "boards",
|
||||
PostId: emptyString,
|
||||
ChannelId: emptyString,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
DeleteAt: 0,
|
||||
Path: filePath,
|
||||
ThumbnailPath: emptyString,
|
||||
PreviewPath: emptyString,
|
||||
Name: filename,
|
||||
Extension: fileExtension,
|
||||
Size: fileSize,
|
||||
MimeType: emptyString,
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
HasPreviewImage: false,
|
||||
MiniPreview: nil,
|
||||
Content: "",
|
||||
RemoteId: nil,
|
||||
}
|
||||
err := a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fullFilename, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
|
||||
if filename == "" {
|
||||
return nil, errEmptyFilename
|
||||
}
|
||||
|
||||
// filename is in the format 7<some-alphanumeric-string>.<extension>
|
||||
// we want to extract the <some-alphanumeric-string> part of this as this
|
||||
// will be the fileinfo id.
|
||||
parts := strings.Split(filename, ".")
|
||||
fileInfoID := parts[0][1:]
|
||||
fileInfo, err := a.store.GetFileInfo(fileInfoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, filestore.ReadCloseSeeker, error) {
|
||||
fileInfo, err := a.GetFileInfo(fileName)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
a.logger.Error("111")
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var filePath string
|
||||
|
||||
if fileInfo != nil && fileInfo.Path != "" {
|
||||
filePath = fileInfo.Path
|
||||
} else {
|
||||
filePath = filepath.Join(teamID, rootID, fileName)
|
||||
}
|
||||
|
||||
exists, err := a.filesBackend.FileExists(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error(fmt.Sprintf("GetFile: Failed to check if file exists as path. Path: %s, error: %e", filePath, err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, nil, ErrFileNotFound
|
||||
}
|
||||
|
||||
reader, err := a.filesBackend.Reader(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error(fmt.Sprintf("GetFile: Failed to get file reader of existing file at path: %s, error: %e", filePath, err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileInfo, reader, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) {
|
||||
filePath := filepath.Join(teamID, rootID, filename)
|
||||
exists, err := a.filesBackend.FileExists(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// FIXUP: Check the deprecated old location
|
||||
if teamID == "0" && !exists {
|
||||
oldExists, err2 := a.filesBackend.FileExists(filename)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
if oldExists {
|
||||
err2 := a.filesBackend.MoveFile(filename, filePath)
|
||||
if err2 != nil {
|
||||
a.logger.Error("ERROR moving file",
|
||||
mlog.String("old", filename),
|
||||
mlog.String("new", filePath),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
} else {
|
||||
a.logger.Debug("Moved file",
|
||||
mlog.String("old", filename),
|
||||
mlog.String("new", filePath),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if !exists {
|
||||
return nil, ErrFileNotFound
|
||||
}
|
||||
|
||||
reader, err := a.filesBackend.Reader(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
func (a *App) MoveFile(channelID, teamID, boardID, filename string) error {
|
||||
oldPath := filepath.Join(channelID, boardID, filename)
|
||||
newPath := filepath.Join(teamID, boardID, filename)
|
||||
err := a.filesBackend.MoveFile(oldPath, newPath)
|
||||
if err != nil {
|
||||
a.logger.Error("ERROR moving file",
|
||||
mlog.String("old", oldPath),
|
||||
mlog.String("new", newPath),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
387
server/boards/app/files_test.go
Обычный файл
387
server/boards/app/files_test.go
Обычный файл
@@ -0,0 +1,387 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore/mocks"
|
||||
)
|
||||
|
||||
const (
|
||||
testFileName = "temp-file-name"
|
||||
testBoardID = "test-board-id"
|
||||
)
|
||||
|
||||
var errDummy = errors.New("hello")
|
||||
|
||||
type TestError struct{}
|
||||
|
||||
func (err *TestError) Error() string { return "Mocked File backend error" }
|
||||
|
||||
func TestGetFileReader(t *testing.T) {
|
||||
testFilePath := filepath.Join("1", "test-board-id", "temp-file-name")
|
||||
|
||||
th, _ := SetupTestHelper(t)
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
t.Run("should get file reader from filestore successfully", func(t *testing.T) {
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
actual, _ := th.App.GetFileReader("1", testBoardID, testFileName)
|
||||
assert.Equal(t, mockedReadCloseSeek, actual)
|
||||
})
|
||||
|
||||
t.Run("should get error from filestore when file exists return error", func(t *testing.T) {
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedError := &TestError{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return mockedError
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
actual, err := th.App.GetFileReader("1", testBoardID, testFileName)
|
||||
assert.Error(t, err, mockedError)
|
||||
assert.Nil(t, actual)
|
||||
})
|
||||
|
||||
t.Run("should return error, if get reader from file backend returns error", func(t *testing.T) {
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedError := &TestError{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return nil
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return mockedError
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
actual, err := th.App.GetFileReader("1", testBoardID, testFileName)
|
||||
assert.Error(t, err, mockedError)
|
||||
assert.Nil(t, actual)
|
||||
})
|
||||
|
||||
t.Run("should move file from old filepath to new filepath, if file doesnot exists in new filepath and workspace id is 0", func(t *testing.T) {
|
||||
filePath := filepath.Join("0", "test-board-id", "temp-file-name")
|
||||
workspaceid := "0"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
// return true for old path
|
||||
return path == testFileName
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
moveFileFunc := func(oldFileName, newFileName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("MoveFile", testFileName, filePath).Return(moveFileFunc)
|
||||
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
|
||||
|
||||
actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName)
|
||||
assert.Equal(t, mockedReadCloseSeek, actual)
|
||||
})
|
||||
|
||||
t.Run("should return file reader, if file doesnot exists in new filepath and old file path", func(t *testing.T) {
|
||||
filePath := filepath.Join("0", "test-board-id", "temp-file-name")
|
||||
fileName := testFileName
|
||||
workspaceid := "0"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
// return true for old path
|
||||
return false
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
moveFileFunc := func(oldFileName, newFileName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("MoveFile", fileName, filePath).Return(moveFileFunc)
|
||||
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
|
||||
|
||||
actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName)
|
||||
assert.Equal(t, mockedReadCloseSeek, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveFile(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
t.Run("should save file to file store using file backend", func(t *testing.T) {
|
||||
fileName := "temp-file-name.txt"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
assert.Equal(t, "boards", paths[0])
|
||||
assert.Equal(t, time.Now().Format("20060102"), paths[1])
|
||||
fileName = paths[2]
|
||||
return int64(10)
|
||||
}
|
||||
|
||||
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName)
|
||||
assert.Equal(t, fileName, actual)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should save .jpeg file as jpg file to file store using file backend", func(t *testing.T) {
|
||||
fileName := "temp-file-name.jpeg"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
assert.Equal(t, "boards", paths[0])
|
||||
assert.Equal(t, time.Now().Format("20060102"), paths[1])
|
||||
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
|
||||
return int64(10)
|
||||
}
|
||||
|
||||
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, actual)
|
||||
})
|
||||
|
||||
t.Run("should return error when fileBackend.WriteFile returns error", func(t *testing.T) {
|
||||
fileName := "temp-file-name.jpeg"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedError := &TestError{}
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
assert.Equal(t, "boards", paths[0])
|
||||
assert.Equal(t, time.Now().Format("20060102"), paths[1])
|
||||
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
|
||||
return int64(10)
|
||||
}
|
||||
|
||||
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
|
||||
return mockedError
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
|
||||
assert.Equal(t, "", actual)
|
||||
assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFileInfo(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
|
||||
t.Run("should return file info", func(t *testing.T) {
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: "file_info_id",
|
||||
Archived: false,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil).Times(2)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.False(t, fetchedFileInfo.Archived)
|
||||
|
||||
fetchedFileInfo, err = th.App.GetFileInfo("Afilename.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.False(t, fetchedFileInfo.Archived)
|
||||
})
|
||||
|
||||
t.Run("should return archived file info", func(t *testing.T) {
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: "file_info_id",
|
||||
Archived: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.True(t, fetchedFileInfo.Archived)
|
||||
})
|
||||
|
||||
t.Run("should return archived file infoerror", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(nil, errDummy)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, fetchedFileInfo)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFile(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
|
||||
t.Run("when FileInfo exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: "/path/to/file/fileName.txt",
|
||||
}, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
})
|
||||
|
||||
t.Run("when FileInfo doesn't exist", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
})
|
||||
|
||||
t.Run("when FileInfo exists but FileInfo.Path is not set", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: "",
|
||||
}, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
})
|
||||
}
|
||||
74
server/boards/app/helper_test.go
Обычный файл
74
server/boards/app/helper_test.go
Обычный файл
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/metrics"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mmpermissions"
|
||||
mmpermissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mmpermissions/mocks"
|
||||
permissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mocks"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/store/mockstore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/webhook"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/ws"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore/mocks"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type TestHelper struct {
|
||||
App *App
|
||||
Store *mockstore.MockStore
|
||||
FilesBackend *mocks.FileBackend
|
||||
logger mlog.LoggerIFace
|
||||
API *mmpermissionsMocks.MockAPI
|
||||
}
|
||||
|
||||
func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
|
||||
ctrl := gomock.NewController(t)
|
||||
cfg := config.Configuration{}
|
||||
store := mockstore.NewMockStore(ctrl)
|
||||
filesBackend := &mocks.FileBackend{}
|
||||
auth := auth.New(&cfg, store, nil)
|
||||
logger := mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)
|
||||
sessionToken := "TESTTOKEN"
|
||||
wsserver := ws.NewServer(auth, sessionToken, false, logger, store)
|
||||
webhook := webhook.NewClient(&cfg, logger)
|
||||
metricsService := metrics.NewMetrics(metrics.InstanceInfo{})
|
||||
|
||||
mockStore := permissionsMocks.NewMockStore(ctrl)
|
||||
mockAPI := mmpermissionsMocks.NewMockAPI(ctrl)
|
||||
permissions := mmpermissions.New(mockStore, mockAPI, mlog.CreateConsoleTestLogger(true, mlog.LvlError))
|
||||
|
||||
appServices := Services{
|
||||
Auth: auth,
|
||||
Store: store,
|
||||
FilesBackend: filesBackend,
|
||||
Webhook: webhook,
|
||||
Metrics: metricsService,
|
||||
Logger: logger,
|
||||
SkipTemplateInit: true,
|
||||
Permissions: permissions,
|
||||
}
|
||||
app2 := New(&cfg, wsserver, appServices)
|
||||
|
||||
tearDown := func() {
|
||||
app2.Shutdown()
|
||||
if logger != nil {
|
||||
_ = logger.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
return &TestHelper{
|
||||
App: app2,
|
||||
Store: store,
|
||||
FilesBackend: filesBackend,
|
||||
logger: logger,
|
||||
API: mockAPI,
|
||||
}, tearDown
|
||||
}
|
||||
449
server/boards/app/import.go
Обычный файл
449
server/boards/app/import.go
Обычный файл
@@ -0,0 +1,449 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/krolaw/zipstream"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
archiveVersion = 2
|
||||
legacyFileBegin = "{\"version\":1"
|
||||
)
|
||||
|
||||
var (
|
||||
errBlockIsNotABoard = errors.New("block is not a board")
|
||||
)
|
||||
|
||||
// ImportArchive imports an archive containing zero or more boards, plus all
|
||||
// associated content, including cards, content blocks, views, and images.
|
||||
//
|
||||
// Archives are ZIP files containing a `version.json` file and zero or more
|
||||
// directories, each containing a `board.jsonl` and zero or more image files.
|
||||
func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
|
||||
// peek at the first bytes to see if this is a legacy archive format
|
||||
br := bufio.NewReader(r)
|
||||
peek, err := br.Peek(len(legacyFileBegin))
|
||||
if err == nil && string(peek) == legacyFileBegin {
|
||||
a.logger.Debug("importing legacy archive")
|
||||
_, errImport := a.ImportBoardJSONL(br, opt)
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after importing a legacy file",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return errImport
|
||||
}
|
||||
|
||||
a.logger.Debug("importing archive")
|
||||
zr := zipstream.NewReader(br)
|
||||
|
||||
boardMap := make(map[string]string) // maps old board ids to new
|
||||
|
||||
for {
|
||||
hdr, err := zr.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap)))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
dir, filename := filepath.Split(hdr.Name)
|
||||
dir = path.Clean(dir)
|
||||
|
||||
switch filename {
|
||||
case "version.json":
|
||||
ver, errVer := parseVersionFile(zr)
|
||||
if errVer != nil {
|
||||
return errVer
|
||||
}
|
||||
if ver != archiveVersion {
|
||||
return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion)
|
||||
}
|
||||
case "board.jsonl":
|
||||
boardID, err := a.ImportBoardJSONL(zr, opt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot import board %s: %w", dir, err)
|
||||
}
|
||||
boardMap[dir] = boardID
|
||||
default:
|
||||
// import file/image; dir is the old board id
|
||||
boardID, ok := boardMap[dir]
|
||||
if !ok {
|
||||
a.logger.Warn("skipping orphan image in archive",
|
||||
mlog.String("dir", dir),
|
||||
mlog.String("filename", filename),
|
||||
)
|
||||
continue
|
||||
}
|
||||
// save file with original filename so it matches name in image block.
|
||||
filePath := filepath.Join(opt.TeamID, boardID, filename)
|
||||
_, err := a.filesBackend.WriteFile(zr, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Trace("import archive file",
|
||||
mlog.String("dir", dir),
|
||||
mlog.String("filename", filename),
|
||||
)
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after importing an archive",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting
|
||||
// board id is returned.
|
||||
func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (string, error) {
|
||||
// TODO: Stream this once `model.GenerateBlockIDs` can take a stream of blocks.
|
||||
// We don't want to load the whole file in memory, even though it's a single board.
|
||||
boardsAndBlocks := &model.BoardsAndBlocks{
|
||||
Blocks: make([]*model.Block, 0, 10),
|
||||
Boards: make([]*model.Board, 0, 10),
|
||||
}
|
||||
lineReader := bufio.NewReader(r)
|
||||
|
||||
userID := opt.ModifiedBy
|
||||
if userID == model.SingleUser {
|
||||
userID = ""
|
||||
}
|
||||
now := utils.GetMillis()
|
||||
var boardID string
|
||||
var boardMembers []*model.BoardMember
|
||||
|
||||
lineNum := 1
|
||||
firstLine := true
|
||||
for {
|
||||
line, errRead := readLine(lineReader)
|
||||
if len(line) != 0 {
|
||||
var skip bool
|
||||
if firstLine {
|
||||
// first line might be a header tag (old archive format)
|
||||
if strings.HasPrefix(string(line), legacyFileBegin) {
|
||||
skip = true
|
||||
}
|
||||
}
|
||||
|
||||
if !skip {
|
||||
var archiveLine model.ArchiveLine
|
||||
if err := json.Unmarshal(line, &archiveLine); err != nil {
|
||||
return "", fmt.Errorf("error parsing archive line %d: %w", lineNum, err)
|
||||
}
|
||||
|
||||
// first line must be a board
|
||||
if firstLine && archiveLine.Type == "block" {
|
||||
archiveLine.Type = "board_block"
|
||||
}
|
||||
|
||||
switch archiveLine.Type {
|
||||
case "board":
|
||||
var board model.Board
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
board.ModifiedBy = userID
|
||||
board.UpdateAt = now
|
||||
board.TeamID = opt.TeamID
|
||||
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, &board)
|
||||
boardID = board.ID
|
||||
case "board_block":
|
||||
// legacy archives encoded boards as blocks; we need to convert them to real boards.
|
||||
var block *model.Block
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
block.ModifiedBy = userID
|
||||
block.UpdateAt = now
|
||||
board, err := a.blockToBoard(block, opt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err)
|
||||
}
|
||||
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board)
|
||||
boardID = board.ID
|
||||
case "block":
|
||||
var block *model.Block
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
|
||||
return "", fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
block.ModifiedBy = userID
|
||||
block.UpdateAt = now
|
||||
block.BoardID = boardID
|
||||
boardsAndBlocks.Blocks = append(boardsAndBlocks.Blocks, block)
|
||||
case "boardMember":
|
||||
var boardMember *model.BoardMember
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &boardMember); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
boardMembers = append(boardMembers, boardMember)
|
||||
default:
|
||||
return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
|
||||
}
|
||||
firstLine = false
|
||||
}
|
||||
}
|
||||
|
||||
if errRead != nil {
|
||||
if errors.Is(errRead, io.EOF) {
|
||||
break
|
||||
}
|
||||
return "", fmt.Errorf("error reading archive line %d: %w", lineNum, errRead)
|
||||
}
|
||||
lineNum++
|
||||
}
|
||||
|
||||
// loop to remove the people how are not part of the team and system
|
||||
for i := len(boardMembers) - 1; i >= 0; i-- {
|
||||
if _, err := a.GetUser(boardMembers[i].UserID); err != nil {
|
||||
boardMembers = append(boardMembers[:i], boardMembers[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
a.fixBoardsandBlocks(boardsAndBlocks, opt)
|
||||
|
||||
var err error
|
||||
boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating archive block IDs: %w", err)
|
||||
}
|
||||
|
||||
boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error inserting archive blocks: %w", err)
|
||||
}
|
||||
|
||||
// add users to all the new boards (if not the fake system user).
|
||||
for _, board := range boardsAndBlocks.Boards {
|
||||
// make sure an admin user gets added
|
||||
adminMember := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: opt.ModifiedBy,
|
||||
SchemeAdmin: true,
|
||||
}
|
||||
if _, err2 := a.AddMemberToBoard(adminMember); err2 != nil {
|
||||
return "", fmt.Errorf("cannot add adminMember to board: %w", err2)
|
||||
}
|
||||
for _, boardMember := range boardMembers {
|
||||
bm := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: boardMember.UserID,
|
||||
Roles: boardMember.Roles,
|
||||
MinimumRole: boardMember.MinimumRole,
|
||||
SchemeAdmin: boardMember.SchemeAdmin,
|
||||
SchemeEditor: boardMember.SchemeEditor,
|
||||
SchemeCommenter: boardMember.SchemeCommenter,
|
||||
SchemeViewer: boardMember.SchemeViewer,
|
||||
Synthetic: boardMember.Synthetic,
|
||||
}
|
||||
if _, err2 := a.AddMemberToBoard(bm); err2 != nil {
|
||||
return "", fmt.Errorf("cannot add member to board: %w", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find new board id
|
||||
for _, board := range boardsAndBlocks.Boards {
|
||||
return board.ID, nil
|
||||
}
|
||||
return "", fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock)
|
||||
}
|
||||
|
||||
// fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being
|
||||
// imported via callbacks.
|
||||
func (a *App) fixBoardsandBlocks(boardsAndBlocks *model.BoardsAndBlocks, opt model.ImportArchiveOptions) {
|
||||
if opt.BlockModifier == nil && opt.BoardModifier == nil {
|
||||
return
|
||||
}
|
||||
|
||||
modInfoCache := make(map[string]interface{})
|
||||
modBoards := make([]*model.Board, 0, len(boardsAndBlocks.Boards))
|
||||
modBlocks := make([]*model.Block, 0, len(boardsAndBlocks.Blocks))
|
||||
|
||||
for _, board := range boardsAndBlocks.Boards {
|
||||
b := *board
|
||||
if opt.BoardModifier != nil && !opt.BoardModifier(&b, modInfoCache) {
|
||||
a.logger.Debug("skipping insert board per board modifier",
|
||||
mlog.String("boardID", board.ID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
modBoards = append(modBoards, &b)
|
||||
}
|
||||
|
||||
for _, block := range boardsAndBlocks.Blocks {
|
||||
b := block
|
||||
if opt.BlockModifier != nil && !opt.BlockModifier(b, modInfoCache) {
|
||||
a.logger.Debug("skipping insert block per block modifier",
|
||||
mlog.String("blockID", block.ID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
modBlocks = append(modBlocks, b)
|
||||
}
|
||||
|
||||
boardsAndBlocks.Boards = modBoards
|
||||
boardsAndBlocks.Blocks = modBlocks
|
||||
}
|
||||
|
||||
// blockToBoard converts a `model.Block` to `model.Board`. Legacy archive formats encode boards as blocks
|
||||
// and need conversion during import.
|
||||
func (a *App) blockToBoard(block *model.Block, opt model.ImportArchiveOptions) (*model.Board, error) {
|
||||
if block.Type != model.TypeBoard {
|
||||
return nil, errBlockIsNotABoard
|
||||
}
|
||||
|
||||
board := &model.Board{
|
||||
ID: block.ID,
|
||||
TeamID: opt.TeamID,
|
||||
CreatedBy: block.CreatedBy,
|
||||
ModifiedBy: block.ModifiedBy,
|
||||
Type: model.BoardTypePrivate,
|
||||
Title: block.Title,
|
||||
CreateAt: block.CreateAt,
|
||||
UpdateAt: block.UpdateAt,
|
||||
DeleteAt: block.DeleteAt,
|
||||
Properties: make(map[string]interface{}),
|
||||
CardProperties: make([]map[string]interface{}, 0),
|
||||
}
|
||||
|
||||
if icon, ok := stringValue(block.Fields, "icon"); ok {
|
||||
board.Icon = icon
|
||||
}
|
||||
if description, ok := stringValue(block.Fields, "description"); ok {
|
||||
board.Description = description
|
||||
}
|
||||
if showDescription, ok := boolValue(block.Fields, "showDescription"); ok {
|
||||
board.ShowDescription = showDescription
|
||||
}
|
||||
if isTemplate, ok := boolValue(block.Fields, "isTemplate"); ok {
|
||||
board.IsTemplate = isTemplate
|
||||
}
|
||||
if templateVer, ok := intValue(block.Fields, "templateVer"); ok {
|
||||
board.TemplateVersion = templateVer
|
||||
}
|
||||
if properties, ok := mapValue(block.Fields, "properties"); ok {
|
||||
board.Properties = properties
|
||||
}
|
||||
if cardProperties, ok := arrayMapsValue(block.Fields, "cardProperties"); ok {
|
||||
board.CardProperties = cardProperties
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func stringValue(m map[string]interface{}, key string) (string, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
func boolValue(m map[string]interface{}, key string) (bool, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
func intValue(m map[string]interface{}, key string) (int, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
i, ok := v.(int)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
func mapValue(m map[string]interface{}, key string) (map[string]interface{}, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
mm, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return mm, true
|
||||
}
|
||||
|
||||
func arrayMapsValue(m map[string]interface{}, key string) ([]map[string]interface{}, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ai, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
arr := make([]map[string]interface{}, 0, len(ai))
|
||||
for _, mi := range ai {
|
||||
mm, ok := mi.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
arr = append(arr, mm)
|
||||
}
|
||||
return arr, true
|
||||
}
|
||||
|
||||
func parseVersionFile(r io.Reader) (int, error) {
|
||||
file, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot read version.json: %w", err)
|
||||
}
|
||||
|
||||
var header model.ArchiveHeader
|
||||
if err := json.Unmarshal(file, &header); err != nil {
|
||||
return 0, fmt.Errorf("cannot parse version.json: %w", err)
|
||||
}
|
||||
return header.Version, nil
|
||||
}
|
||||
|
||||
func readLine(r *bufio.Reader) ([]byte, error) {
|
||||
line, err := r.ReadBytes('\n')
|
||||
line = bytes.TrimSpace(line)
|
||||
return line, err
|
||||
}
|
||||
169
server/boards/app/import_test.go
Обычный файл
169
server/boards/app/import_test.go
Обычный файл
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestApp_ImportArchive(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: "d14b9df9-1f31-4732-8a64-92bc7162cd28",
|
||||
TeamID: "test-team",
|
||||
Title: "Cross-Functional Project Plan",
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: "2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e",
|
||||
ParentID: board.ID,
|
||||
Type: model.TypeView,
|
||||
BoardID: board.ID,
|
||||
}
|
||||
|
||||
babs := &model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{board},
|
||||
Blocks: []*model.Block{block},
|
||||
}
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "user",
|
||||
}
|
||||
|
||||
t.Run("import asana archive", func(t *testing.T) {
|
||||
r := bytes.NewReader([]byte(asana))
|
||||
opts := model.ImportArchiveOptions{
|
||||
TeamID: "test-team",
|
||||
ModifiedBy: "user",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "user").Return(babs, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil)
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
Type: "default",
|
||||
Name: "Boards",
|
||||
ID: "boards_category_id",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team")
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything, utils.Anything).Return(nil)
|
||||
|
||||
err := th.App.ImportArchive(r, opts)
|
||||
require.NoError(t, err, "import archive should not fail")
|
||||
})
|
||||
|
||||
t.Run("import board archive", func(t *testing.T) {
|
||||
r := bytes.NewReader([]byte(boardArchive))
|
||||
opts := model.ImportArchiveOptions{
|
||||
TeamID: "test-team",
|
||||
ModifiedBy: "f1tydgc697fcbp8ampr6881jea",
|
||||
}
|
||||
|
||||
bm1 := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "f1tydgc697fcbp8ampr6881jea",
|
||||
}
|
||||
|
||||
bm2 := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "hxxzooc3ff8cubsgtcmpn8733e",
|
||||
}
|
||||
|
||||
bm3 := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "nto73edn5ir6ifimo5a53y1dwa",
|
||||
}
|
||||
|
||||
user1 := &model.User{
|
||||
ID: "f1tydgc697fcbp8ampr6881jea",
|
||||
}
|
||||
|
||||
user2 := &model.User{
|
||||
ID: "hxxzooc3ff8cubsgtcmpn8733e",
|
||||
}
|
||||
|
||||
user3 := &model.User{
|
||||
ID: "nto73edn5ir6ifimo5a53y1dwa",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "f1tydgc697fcbp8ampr6881jea").Return(babs, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{bm1, bm2, bm3}, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{}, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
Type: model.CategoryTypeSystem,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("f1tydgc697fcbp8ampr6881jea").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("f1tydgc697fcbp8ampr6881jea", "test-team", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("f1tydgc697fcbp8ampr6881jea", utils.Anything, utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(bm1, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(bm2, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(bm3, nil)
|
||||
th.Store.EXPECT().GetUserByID("f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(user1, nil)
|
||||
th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil)
|
||||
th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil)
|
||||
|
||||
boardID, err := th.App.ImportBoardJSONL(r, opts)
|
||||
require.Equal(t, board.ID, boardID, "Board ID should be same")
|
||||
require.NoError(t, err, "import archive should not fail")
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:lll
|
||||
const asana = `{"version":1,"date":1614714686842}
|
||||
{"type":"block","data":{"id":"d14b9df9-1f31-4732-8a64-92bc7162cd28","fields":{"icon":"","description":"","cardProperties":[{"id":"3bdcbaeb-bc78-4884-8531-a0323b74676a","name":"Section","type":"select","options":[{"id":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732","value":"Planning","color":"propColorGray"},{"id":"454559bb-b788-4ff6-873e-04def8491d2c","value":"Milestones","color":"propColorBrown"},{"id":"deaab476-c690-48df-828f-725b064dc476","value":"Next steps","color":"propColorOrange"},{"id":"2138305a-3157-461c-8bbe-f19ebb55846d","value":"Comms Plan","color":"propColorYellow"}]}]},"createAt":1614714686836,"updateAt":1614714686836,"deleteAt":0,"schema":1,"parentId":"","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"board","title":"Cross-Functional Project Plan"}}
|
||||
{"type":"block","data":{"id":"2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e","fields":{"sortOptions":[],"visiblePropertyIds":[],"visibleOptionIds":[],"hiddenOptionIds":[],"filter":{"operation":"and","filters":[]},"cardOrder":[],"columnWidths":{},"viewType":"board"},"createAt":1614714686840,"updateAt":1614714686840,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"view","title":"Board View"}}
|
||||
{"type":"block","data":{"id":"520c332b-adf5-4a32-88ab-43655c8b6aa2","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["deb3966c-6d56-43b1-8e95-36806877ce81"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[READ ME] - Instructions for using this template"}}
|
||||
{"type":"block","data":{"id":"deb3966c-6d56-43b1-8e95-36806877ce81","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"520c332b-adf5-4a32-88ab-43655c8b6aa2","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"This project template is set up in List View with sections and Asana-created Custom Fields to help you track your team's work. We've provided some example content in this template to get you started, but you should add tasks, change task names, add more Custom Fields, and change any other info to make this project your own.\n\nSend feedback about this template: https://asa.na/templatesfeedback"}}
|
||||
{"type":"block","data":{"id":"be791f66-a5e5-4408-82f6-cb1280f5bc45","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["2688b31f-e7ff-4de1-87ae-d4b5570f8712"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"Redesign the landing page of our website"}}
|
||||
{"type":"block","data":{"id":"2688b31f-e7ff-4de1-87ae-d4b5570f8712","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"be791f66-a5e5-4408-82f6-cb1280f5bc45","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"Redesign the landing page to focus on the main persona."}}
|
||||
{"type":"block","data":{"id":"98f74948-1700-4a3c-8cc2-8bb632499def","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Consider trying a new email marketing service"}}
|
||||
{"type":"block","data":{"id":"142fba5d-05e6-4865-83d9-b3f54d9de96e","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Budget finalization"}}
|
||||
{"type":"block","data":{"id":"ca6670b1-b034-4e42-8971-c659b478b9e0","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Find a venue for the holiday party"}}
|
||||
{"type":"block","data":{"id":"db1dd596-0999-4741-8b05-72ca8e438e31","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Approve campaign copy"}}
|
||||
{"type":"block","data":{"id":"16861c05-f31f-46af-8429-80a87b5aa93a","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"2138305a-3157-461c-8bbe-f19ebb55846d"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Send out updated attendee list"}}
|
||||
`
|
||||
|
||||
//nolint:lll
|
||||
const boardArchive = `{"type":"board","data":{"id":"bfoi6yy6pa3yzika53spj7pq9ee","teamId":"wsmqbtwb5jb35jb3mtp85c8a9h","channelId":"","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","type":"P","minimumRole":"","title":"Custom","description":"","icon":"","showDescription":false,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"aonihehbifijmx56aqzu3cc7w1r","name":"Status","options":[],"type":"select"},{"id":"aohjkzt769rxhtcz1o9xcoce5to","name":"Person","options":[],"type":"person"}],"createAt":1672750481591,"updateAt":1672750481591,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"ckpc3b1dp3pbw7bqntfryy9jbzo","parentId":"bjaqxtbyqz3bu7pgyddpgpms74a","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"card","title":"Test","fields":{"contentOrder":[],"icon":"","isTemplate":false,"properties":{"aohjkzt769rxhtcz1o9xcoce5to":"hxxzooc3ff8cubsgtcmpn8733e"}},"createAt":1672750481612,"updateAt":1672845003530,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}}
|
||||
{"type":"block","data":{"id":"v7tdajwpm47r3u8duedk89bhxar","parentId":"bpypang3a3errqstj1agx9kuqay","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"view","title":"Board view","fields":{"cardOrder":["crsyw7tbr3pnjznok6ppngmmyya","c5titiemp4pgaxbs4jksgybbj4y"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":[],"visiblePropertyIds":["aohjkzt769rxhtcz1o9xcoce5to"]},"createAt":1672750481626,"updateAt":1672750481626,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}}
|
||||
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"f1tydgc697fcbp8ampr6881jea","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}}
|
||||
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"hxxzooc3ff8cubsgtcmpn8733e","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}}
|
||||
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"nto73edn5ir6ifimo5a53y1dwa","roles":"","minimumRole":"","schemeAdmin":true,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":false,"synthetic":false}}
|
||||
`
|
||||
29
server/boards/app/initialize.go
Обычный файл
29
server/boards/app/initialize.go
Обычный файл
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// initialize is called when the App is first created.
|
||||
func (a *App) initialize(skipTemplateInit bool) {
|
||||
if !skipTemplateInit {
|
||||
if err := a.InitTemplates(); err != nil {
|
||||
a.logger.Error(`InitializeTemplates failed`, mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Shutdown() {
|
||||
if a.blockChangeNotifier != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), blockChangeNotifierShutdownTimeout)
|
||||
defer cancel()
|
||||
if !a.blockChangeNotifier.Shutdown(ctx) {
|
||||
a.logger.Warn("blockChangeNotifier shutdown timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
87
server/boards/app/insights.go
Обычный файл
87
server/boards/app/insights.go
Обычный файл
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetTeamBoardsInsights(userID string, teamID string, opts *mm_model.InsightsOpts) (*model.BoardInsightsList, error) {
|
||||
// check if server is properly licensed, and user is not a guest
|
||||
userPermitted, err := insightPermissionGate(a, userID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !userPermitted {
|
||||
return nil, errors.New("User isn't authorized to access insights.")
|
||||
}
|
||||
boardIDs, err := getUserBoards(userID, teamID, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.store.GetTeamBoardsInsights(teamID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs)
|
||||
}
|
||||
|
||||
func (a *App) GetUserBoardsInsights(userID string, teamID string, opts *mm_model.InsightsOpts) (*model.BoardInsightsList, error) {
|
||||
// check if server is properly licensed, and user is not a guest
|
||||
userPermitted, err := insightPermissionGate(a, userID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !userPermitted {
|
||||
return nil, errors.New("User isn't authorized to access insights.")
|
||||
}
|
||||
boardIDs, err := getUserBoards(userID, teamID, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.store.GetUserBoardsInsights(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs)
|
||||
}
|
||||
|
||||
func insightPermissionGate(a *App, userID string, isMyInsights bool) (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()
|
||||
|
||||
user, err := a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if user.IsGuest {
|
||||
return false, guestError
|
||||
}
|
||||
|
||||
if lic == nil && !isMyInsights {
|
||||
a.logger.Debug("Deployment doesn't have a license")
|
||||
return false, licenseError
|
||||
}
|
||||
|
||||
if !isMyInsights && (lic.SkuShortName != mm_model.LicenseShortSkuProfessional && lic.SkuShortName != mm_model.LicenseShortSkuEnterprise) {
|
||||
return false, licenseError
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUserTimezone(userID string) (string, error) {
|
||||
return a.store.GetUserTimezone(userID)
|
||||
}
|
||||
|
||||
func getUserBoards(userID string, teamID string, a *App) ([]string, error) {
|
||||
// get boards accessible by user and filter boardIDs
|
||||
boards, err := a.store.GetBoardsForUserAndTeam(userID, teamID, true)
|
||||
if err != nil {
|
||||
return nil, errors.New("error getting boards for user")
|
||||
}
|
||||
boardIDs := make([]string, 0, len(boards))
|
||||
|
||||
for _, board := range boards {
|
||||
boardIDs = append(boardIDs, board.ID)
|
||||
}
|
||||
return boardIDs, nil
|
||||
}
|
||||
93
server/boards/app/insights_test.go
Обычный файл
93
server/boards/app/insights_test.go
Обычный файл
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
var mockInsightsBoards = []*model.Board{
|
||||
{
|
||||
ID: "mock-user-workspace-id",
|
||||
Title: "MockUserWorkspace",
|
||||
},
|
||||
}
|
||||
|
||||
var mockTeamInsights = []*model.BoardInsight{
|
||||
{
|
||||
BoardID: "board-id-1",
|
||||
},
|
||||
{
|
||||
BoardID: "board-id-2",
|
||||
},
|
||||
}
|
||||
|
||||
var mockTeamInsightsList = &model.BoardInsightsList{
|
||||
InsightsListData: mm_model.InsightsListData{HasNext: false},
|
||||
Items: mockTeamInsights,
|
||||
}
|
||||
|
||||
type insightError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (ie insightError) Error() string {
|
||||
return ie.msg
|
||||
}
|
||||
|
||||
func TestGetTeamAndUserBoardsInsights(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success query", func(t *testing.T) {
|
||||
fakeLicense := &mm_model.License{Features: &mm_model.Features{}, SkuShortName: mm_model.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes()
|
||||
fakeUser := &model.User{
|
||||
ID: "user-id",
|
||||
IsGuest: false,
|
||||
}
|
||||
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().
|
||||
GetTeamBoardsInsights("team-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(mockTeamInsightsList, nil)
|
||||
results, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Items, 2)
|
||||
th.Store.EXPECT().
|
||||
GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(mockTeamInsightsList, nil)
|
||||
results, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mm_model.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) {
|
||||
fakeLicense := &mm_model.License{Features: &mm_model.Features{}, SkuShortName: mm_model.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes()
|
||||
fakeUser := &model.User{
|
||||
ID: "user-id",
|
||||
IsGuest: false,
|
||||
}
|
||||
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().
|
||||
GetTeamBoardsInsights("team-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(nil, insightError{"board-insight-error"})
|
||||
_, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, insightError{"board-insight-error"})
|
||||
th.Store.EXPECT().
|
||||
GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(nil, insightError{"board-insight-error"})
|
||||
_, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, insightError{"board-insight-error"})
|
||||
})
|
||||
}
|
||||
99
server/boards/app/onboarding.go
Обычный файл
99
server/boards/app/onboarding.go
Обычный файл
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyOnboardingTourStarted = "onboardingTourStarted"
|
||||
KeyOnboardingTourCategory = "tourCategory"
|
||||
KeyOnboardingTourStep = "onboardingTourStep"
|
||||
|
||||
ValueOnboardingFirstStep = "0"
|
||||
ValueTourCategoryOnboarding = "onboarding"
|
||||
|
||||
WelcomeBoardTitle = "Welcome to Boards!"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnableToFindWelcomeBoard = errors.New("unable to find welcome board in newly created blocks")
|
||||
errCannotCreateBoard = errors.New("new board wasn't created")
|
||||
)
|
||||
|
||||
func (a *App) PrepareOnboardingTour(userID string, teamID string) (string, string, error) {
|
||||
// copy the welcome board into this workspace
|
||||
boardID, err := a.createWelcomeBoard(userID, teamID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// set user's tour state to initial state
|
||||
userPreferencesPatch := model.UserPreferencesPatch{
|
||||
UpdatedFields: map[string]string{
|
||||
KeyOnboardingTourStarted: "1",
|
||||
KeyOnboardingTourStep: ValueOnboardingFirstStep,
|
||||
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
|
||||
},
|
||||
}
|
||||
if _, err := a.store.PatchUserPreferences(userID, userPreferencesPatch); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return teamID, boardID, nil
|
||||
}
|
||||
|
||||
func (a *App) getOnboardingBoardID() (string, error) {
|
||||
boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var onboardingBoardID string
|
||||
for _, block := range boards {
|
||||
if block.Title == WelcomeBoardTitle && block.TeamID == model.GlobalTeamID {
|
||||
onboardingBoardID = block.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if onboardingBoardID == "" {
|
||||
return "", errUnableToFindWelcomeBoard
|
||||
}
|
||||
|
||||
return onboardingBoardID, nil
|
||||
}
|
||||
|
||||
func (a *App) createWelcomeBoard(userID, teamID string) (string, error) {
|
||||
onboardingBoardID, err := a.getOnboardingBoardID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
bab, _, err := a.DuplicateBoard(onboardingBoardID, userID, teamID, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(bab.Boards) != 1 {
|
||||
return "", errCannotCreateBoard
|
||||
}
|
||||
|
||||
// need variable for this to
|
||||
// get reference for board patch
|
||||
newType := model.BoardTypePrivate
|
||||
|
||||
patch := &model.BoardPatch{
|
||||
Type: &newType,
|
||||
}
|
||||
|
||||
if _, err := a.PatchBoard(patch, bab.Boards[0].ID, userID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return bab.Boards[0].ID, nil
|
||||
}
|
||||
197
server/boards/app/onboarding_test.go
Обычный файл
197
server/boards/app/onboarding_test.go
Обычный файл
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const (
|
||||
testTeamID = "team_id"
|
||||
)
|
||||
|
||||
func TestPrepareOnboardingTour(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
userID := "user_id_1"
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{
|
||||
{
|
||||
ID: "board_id_2",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
},
|
||||
}},
|
||||
nil, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
th.Store.EXPECT().GetMembersForBoard("board_id_2").Return([]*model.BoardMember{}, nil).Times(1)
|
||||
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).Times(2)
|
||||
th.Store.EXPECT().GetBoard("board_id_2").Return(&welcomeBoard, nil).Times(1)
|
||||
th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil)
|
||||
|
||||
privateWelcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
newType := model.BoardTypePrivate
|
||||
th.Store.EXPECT().PatchBoard("board_id_2", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id_1").Return([]*model.BoardMember{}, nil)
|
||||
|
||||
userPreferencesPatch := model.UserPreferencesPatch{
|
||||
UpdatedFields: map[string]string{
|
||||
KeyOnboardingTourStarted: "1",
|
||||
KeyOnboardingTourStep: ValueOnboardingFirstStep,
|
||||
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().PatchUserPreferences(userID, userPreferencesPatch).Return(nil, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
|
||||
|
||||
// when this is called the second time, the default category is created so we need to include that in the response list
|
||||
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil).Times(1)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_2"}).Return(nil)
|
||||
|
||||
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testTeamID, teamID)
|
||||
assert.NotEmpty(t, boardID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateWelcomeBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
userID := "user_id_1"
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).
|
||||
Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(3)
|
||||
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil)
|
||||
|
||||
privateWelcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
newType := model.BoardTypePrivate
|
||||
th.Store.EXPECT().PatchBoard("board_id_1", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_1"}).Return(nil)
|
||||
|
||||
boardID, err := th.App.createWelcomeBoard(userID, teamID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, boardID)
|
||||
})
|
||||
|
||||
t.Run("template doesn't contain a board", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
|
||||
boardID, err := th.App.createWelcomeBoard("user_id_1", teamID)
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, boardID)
|
||||
})
|
||||
|
||||
t.Run("template doesn't contain the welcome board", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Other template",
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, boardID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetOnboardingBoardID(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
|
||||
onboardingBoardID, err := th.App.getOnboardingBoardID()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "board_id_1", onboardingBoardID)
|
||||
})
|
||||
|
||||
t.Run("no blocks found", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
|
||||
|
||||
onboardingBoardID, err := th.App.getOnboardingBoardID()
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, onboardingBoardID)
|
||||
})
|
||||
|
||||
t.Run("onboarding board doesn't exists", func(t *testing.T) {
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Other template",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
|
||||
onboardingBoardID, err := th.App.getOnboardingBoardID()
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, onboardingBoardID)
|
||||
})
|
||||
}
|
||||
12
server/boards/app/permissions.go
Обычный файл
12
server/boards/app/permissions.go
Обычный файл
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func (a *App) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
|
||||
return a.permissions.HasPermissionToBoard(userID, boardID, permission)
|
||||
}
|
||||
45
server/boards/app/server_metadata.go
Обычный файл
45
server/boards/app/server_metadata.go
Обычный файл
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type ServerMetadata struct {
|
||||
Version string `json:"version"`
|
||||
BuildNumber string `json:"build_number"`
|
||||
BuildDate string `json:"build_date"`
|
||||
Commit string `json:"commit"`
|
||||
Edition string `json:"edition"`
|
||||
DBType string `json:"db_type"`
|
||||
DBVersion string `json:"db_version"`
|
||||
OSType string `json:"os_type"`
|
||||
OSArch string `json:"os_arch"`
|
||||
SKU string `json:"sku"`
|
||||
}
|
||||
|
||||
func (a *App) GetServerMetadata() *ServerMetadata {
|
||||
var dbType string
|
||||
var dbVersion string
|
||||
if a != nil && a.store != nil {
|
||||
dbType = a.store.DBType()
|
||||
dbVersion = a.store.DBVersion()
|
||||
}
|
||||
|
||||
return &ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: dbType,
|
||||
DBVersion: dbVersion,
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
}
|
||||
40
server/boards/app/server_metadata_test.go
Обычный файл
40
server/boards/app/server_metadata_test.go
Обычный файл
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestGetServerMetadata(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().DBType().Return("TEST_DB_TYPE")
|
||||
th.Store.EXPECT().DBVersion().Return("TEST_DB_VERSION")
|
||||
|
||||
t.Run("Get Server Metadata", func(t *testing.T) {
|
||||
got := th.App.GetServerMetadata()
|
||||
want := &ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: "TEST_DB_TYPE",
|
||||
DBVersion: "TEST_DB_VERSION",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got: %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
20
server/boards/app/sharing.go
Обычный файл
20
server/boards/app/sharing.go
Обычный файл
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetSharing(boardID string) (*model.Sharing, error) {
|
||||
sharing, err := a.store.GetSharing(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sharing, nil
|
||||
}
|
||||
|
||||
func (a *App) UpsertSharing(sharing model.Sharing) error {
|
||||
return a.store.UpsertSharing(sharing)
|
||||
}
|
||||
88
server/boards/app/sharing_test.go
Обычный файл
88
server/boards/app/sharing_test.go
Обычный файл
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func TestGetSharing(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("should get a sharing successfully", func(t *testing.T) {
|
||||
want := &model.Sharing{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
Enabled: true,
|
||||
Token: "token",
|
||||
ModifiedBy: "otherid",
|
||||
UpdateAt: utils.GetMillis(),
|
||||
}
|
||||
th.Store.EXPECT().GetSharing("test-id").Return(want, nil)
|
||||
|
||||
result, err := th.App.GetSharing("test-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, result, want)
|
||||
require.NotNil(t, th.App)
|
||||
})
|
||||
|
||||
t.Run("should fail to get a sharing", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetSharing("test-id").Return(
|
||||
nil,
|
||||
errors.New("sharing not found"),
|
||||
)
|
||||
result, err := th.App.GetSharing("test-id")
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "sharing not found", err.Error())
|
||||
})
|
||||
|
||||
t.Run("should return a not found error", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetSharing("test-id").Return(
|
||||
nil,
|
||||
sql.ErrNoRows,
|
||||
)
|
||||
result, err := th.App.GetSharing("test-id")
|
||||
require.Error(t, err)
|
||||
require.True(t, model.IsErrNotFound(err))
|
||||
require.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpsertSharing(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
sharing := model.Sharing{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
Enabled: true,
|
||||
Token: "token",
|
||||
ModifiedBy: "otherid",
|
||||
UpdateAt: utils.GetMillis(),
|
||||
}
|
||||
|
||||
t.Run("should success to upsert sharing", func(t *testing.T) {
|
||||
th.Store.EXPECT().UpsertSharing(sharing).Return(nil)
|
||||
err := th.App.UpsertSharing(sharing)
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should fail to upsert a sharing", func(t *testing.T) {
|
||||
th.Store.EXPECT().UpsertSharing(sharing).Return(errors.New("sharing not found"))
|
||||
err := th.App.UpsertSharing(sharing)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "sharing not found", err.Error())
|
||||
})
|
||||
}
|
||||
55
server/boards/app/subscriptions.go
Обычный файл
55
server/boards/app/subscriptions.go
Обычный файл
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *App) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) {
|
||||
sub, err := a.store.CreateSubscription(sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.notifySubscriptionChanged(sub)
|
||||
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteSubscription(blockID string, subscriberID string) (*model.Subscription, error) {
|
||||
sub, err := a.store.GetSubscription(blockID, subscriberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.store.DeleteSubscription(blockID, subscriberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.DeleteAt = utils.GetMillis()
|
||||
a.notifySubscriptionChanged(sub)
|
||||
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (a *App) GetSubscriptions(subscriberID string) ([]*model.Subscription, error) {
|
||||
return a.store.GetSubscriptions(subscriberID)
|
||||
}
|
||||
|
||||
func (a *App) notifySubscriptionChanged(subscription *model.Subscription) {
|
||||
if a.notifications == nil {
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.getBoardForBlock(subscription.BlockID)
|
||||
if err != nil {
|
||||
a.logger.Error("Error notifying subscription change",
|
||||
mlog.String("subscriber_id", subscription.SubscriberID),
|
||||
mlog.String("block_id", subscription.BlockID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
a.wsAdapter.BroadcastSubscriptionChange(board.TeamID, subscription)
|
||||
}
|
||||
68
server/boards/app/teams.go
Обычный файл
68
server/boards/app/teams.go
Обычный файл
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *App) GetRootTeam() (*model.Team, error) {
|
||||
teamID := "0"
|
||||
team, _ := a.store.GetTeam(teamID)
|
||||
if team == nil {
|
||||
team = &model.Team{
|
||||
ID: teamID,
|
||||
SignupToken: utils.NewID(utils.IDTypeToken),
|
||||
}
|
||||
err := a.store.UpsertTeamSignupToken(*team)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to initialize team", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
team, err = a.store.GetTeam(teamID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get initialized team", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.logger.Info("initialized team")
|
||||
}
|
||||
|
||||
return team, nil
|
||||
}
|
||||
|
||||
func (a *App) GetTeam(id string) (*model.Team, error) {
|
||||
team, err := a.store.GetTeam(id)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return team, nil
|
||||
}
|
||||
|
||||
func (a *App) GetTeamsForUser(userID string) ([]*model.Team, error) {
|
||||
return a.store.GetTeamsForUser(userID)
|
||||
}
|
||||
|
||||
func (a *App) DoesUserHaveTeamAccess(userID string, teamID string) bool {
|
||||
return a.auth.DoesUserHaveTeamAccess(userID, teamID)
|
||||
}
|
||||
|
||||
func (a *App) UpsertTeamSettings(team model.Team) error {
|
||||
return a.store.UpsertTeamSettings(team)
|
||||
}
|
||||
|
||||
func (a *App) UpsertTeamSignupToken(team model.Team) error {
|
||||
return a.store.UpsertTeamSignupToken(team)
|
||||
}
|
||||
|
||||
func (a *App) GetTeamCount() (int64, error) {
|
||||
return a.store.GetTeamCount()
|
||||
}
|
||||
158
server/boards/app/teams_test.go
Обычный файл
158
server/boards/app/teams_test.go
Обычный файл
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
var errInvalidTeam = errors.New("invalid team id")
|
||||
|
||||
var mockTeam = &model.Team{
|
||||
ID: "mock-team-id",
|
||||
Title: "MockTeam",
|
||||
}
|
||||
|
||||
var errUpsertSignupToken = errors.New("upsert error")
|
||||
|
||||
func TestGetRootTeam(t *testing.T) {
|
||||
var newRootTeam = &model.Team{
|
||||
ID: "0",
|
||||
Title: "NewRootTeam",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
title string
|
||||
teamToReturnBeforeUpsert *model.Team
|
||||
teamToReturnAfterUpsert *model.Team
|
||||
isError bool
|
||||
}{
|
||||
{
|
||||
"Success, Return new root team, when root team returned by mockstore is nil",
|
||||
nil,
|
||||
newRootTeam,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Success, Return existing root team, when root team returned by mockstore is notnil",
|
||||
newRootTeam,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Fail, Return nil, when root team returned by mockstore is nil, and upsert new root team fails",
|
||||
nil,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnBeforeUpsert, nil)
|
||||
if tc.teamToReturnBeforeUpsert == nil {
|
||||
th.Store.EXPECT().UpsertTeamSignupToken(gomock.Any()).DoAndReturn(
|
||||
func(arg0 model.Team) error {
|
||||
if tc.isError {
|
||||
return errUpsertSignupToken
|
||||
}
|
||||
th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnAfterUpsert, nil)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
rootTeam, err := th.App.GetRootTeam()
|
||||
|
||||
if tc.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
assert.NotNil(t, rootTeam.ID)
|
||||
assert.NotNil(t, rootTeam.SignupToken)
|
||||
assert.Equal(t, "", rootTeam.ModifiedBy)
|
||||
assert.Equal(t, int64(0), rootTeam.UpdateAt)
|
||||
assert.Equal(t, "NewRootTeam", rootTeam.Title)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rootTeam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTeam(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testCases := []struct {
|
||||
title string
|
||||
teamID string
|
||||
isError bool
|
||||
}{
|
||||
{
|
||||
"Success, Return new root team, when team returned by mockstore is not nil",
|
||||
"mock-team-id",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Success, Return nil, when get team returns an sql error",
|
||||
"team-not-available-id",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Fail, Return nil, when get team by mockstore returns an error",
|
||||
"invalid-team-id",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("mock-team-id").Return(mockTeam, nil)
|
||||
th.Store.EXPECT().GetTeam("invalid-team-id").Return(nil, errInvalidTeam)
|
||||
th.Store.EXPECT().GetTeam("team-not-available-id").Return(nil, sql.ErrNoRows)
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
t.Log(tc.title)
|
||||
team, err := th.App.GetTeam(tc.teamID)
|
||||
|
||||
if tc.isError {
|
||||
require.Error(t, err)
|
||||
} else if tc.teamID != "team-not-available-id" {
|
||||
assert.NotNil(t, team.ID)
|
||||
assert.NotNil(t, team.SignupToken)
|
||||
assert.Equal(t, "mock-team-id", team.ID)
|
||||
assert.Equal(t, "", team.ModifiedBy)
|
||||
assert.Equal(t, int64(0), team.UpdateAt)
|
||||
assert.Equal(t, "MockTeam", team.Title)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, team)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamOperations(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().UpsertTeamSettings(*mockTeam).Return(nil)
|
||||
th.Store.EXPECT().UpsertTeamSignupToken(*mockTeam).Return(nil)
|
||||
th.Store.EXPECT().GetTeamCount().Return(int64(10), nil)
|
||||
|
||||
errUpsertTeamSettings := th.App.UpsertTeamSettings(*mockTeam)
|
||||
assert.NoError(t, errUpsertTeamSettings)
|
||||
|
||||
errUpsertTeamSignupToken := th.App.UpsertTeamSignupToken(*mockTeam)
|
||||
assert.NoError(t, errUpsertTeamSignupToken)
|
||||
|
||||
count, errGetTeamCount := th.App.GetTeamCount()
|
||||
assert.NoError(t, errGetTeamCount)
|
||||
assert.Equal(t, int64(10), count)
|
||||
}
|
||||
Двоичные данные
server/boards/app/templates.boardarchive
Обычный файл
Двоичные данные
server/boards/app/templates.boardarchive
Обычный файл
Двоичный файл не отображается.
115
server/boards/app/templates.go
Обычный файл
115
server/boards/app/templates.go
Обычный файл
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/assets"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTemplateVersion = 6 // bump this number to force default templates to be re-imported
|
||||
)
|
||||
|
||||
func (a *App) InitTemplates() error {
|
||||
_, err := a.initializeTemplates()
|
||||
return err
|
||||
}
|
||||
|
||||
// initializeTemplates imports default templates if the boards table is empty.
|
||||
func (a *App) initializeTemplates() (bool, error) {
|
||||
boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot initialize templates: %w", err)
|
||||
}
|
||||
|
||||
a.logger.Debug("Fetched template boards", mlog.Int("count", len(boards)))
|
||||
|
||||
isNeeded, reason := a.isInitializationNeeded(boards)
|
||||
if !isNeeded {
|
||||
a.logger.Debug("Template import not needed, skipping")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
a.logger.Debug("Importing new default templates",
|
||||
mlog.String("reason", reason),
|
||||
mlog.Int("size", len(assets.DefaultTemplatesArchive)),
|
||||
)
|
||||
|
||||
// Remove in case of newer Templates
|
||||
if err = a.store.RemoveDefaultTemplates(boards); err != nil {
|
||||
return false, fmt.Errorf("cannot remove old template boards: %w", err)
|
||||
}
|
||||
|
||||
r := bytes.NewReader(assets.DefaultTemplatesArchive)
|
||||
|
||||
opt := model.ImportArchiveOptions{
|
||||
TeamID: model.GlobalTeamID,
|
||||
ModifiedBy: model.SystemUserID,
|
||||
BlockModifier: fixTemplateBlock,
|
||||
BoardModifier: fixTemplateBoard,
|
||||
}
|
||||
if err = a.ImportArchive(r, opt); err != nil {
|
||||
return false, fmt.Errorf("cannot initialize global templates for team %s: %w", model.GlobalTeamID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isInitializationNeeded returns true if the blocks table contains no default templates,
|
||||
// or contains at least one default template with an old version number.
|
||||
func (a *App) isInitializationNeeded(boards []*model.Board) (bool, string) {
|
||||
if len(boards) == 0 {
|
||||
return true, "no default templates found"
|
||||
}
|
||||
|
||||
// look for any built-in template boards with the wrong version number (or no version #).
|
||||
for _, board := range boards {
|
||||
// if not built-in board...skip
|
||||
if board.CreatedBy != model.SystemUserID {
|
||||
continue
|
||||
}
|
||||
if board.TemplateVersion < defaultTemplateVersion {
|
||||
return true, "template_version too old"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// fixTemplateBlock fixes a block to be inserted as part of a template.
|
||||
func fixTemplateBlock(block *model.Block, cache map[string]interface{}) bool {
|
||||
// cache contains ids of skipped boards. Ensure their children are skipped as well.
|
||||
if _, ok := cache[block.BoardID]; ok {
|
||||
cache[block.ID] = struct{}{}
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := cache[block.ParentID]; ok {
|
||||
cache[block.ID] = struct{}{}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fixTemplateBoard fixes a board to be inserted as part of a template.
|
||||
func fixTemplateBoard(board *model.Board, cache map[string]interface{}) bool {
|
||||
// filter out template blocks; we only want the non-template
|
||||
// blocks which we will turn into default template blocks.
|
||||
if board.IsTemplate {
|
||||
cache[board.ID] = struct{}{}
|
||||
return false
|
||||
}
|
||||
|
||||
// remove '(NEW)' from title & force template flag
|
||||
board.Title = strings.ReplaceAll(board.Title, "(NEW)", "")
|
||||
board.IsTemplate = true
|
||||
board.TemplateVersion = defaultTemplateVersion
|
||||
board.Type = model.BoardTypeOpen
|
||||
return true
|
||||
}
|
||||
74
server/boards/app/templates_test.go
Обычный файл
74
server/boards/app/templates_test.go
Обычный файл
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
|
||||
)
|
||||
|
||||
func TestApp_initializeTemplates(t *testing.T) {
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
TeamID: model.GlobalTeamID,
|
||||
Type: model.BoardTypeOpen,
|
||||
Title: "test board",
|
||||
IsTemplate: true,
|
||||
TemplateVersion: defaultTemplateVersion,
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
ParentID: board.ID,
|
||||
BoardID: board.ID,
|
||||
Type: model.TypeText,
|
||||
Title: "test text",
|
||||
}
|
||||
|
||||
boardsAndBlocks := &model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{board},
|
||||
Blocks: []*model.Block{block},
|
||||
}
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "test-user",
|
||||
}
|
||||
|
||||
t.Run("Needs template init", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().RemoveDefaultTemplates([]*model.Board{}).Return(nil)
|
||||
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(gomock.Any(), gomock.Any()).AnyTimes().Return(boardMember, nil)
|
||||
|
||||
th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil)
|
||||
|
||||
done, err := th.App.initializeTemplates()
|
||||
require.NoError(t, err, "initializeTemplates should not error")
|
||||
require.True(t, done, "initialization was needed")
|
||||
})
|
||||
|
||||
t.Run("Skip template init", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{board}, nil)
|
||||
|
||||
done, err := th.App.initializeTemplates()
|
||||
require.NoError(t, err, "initializeTemplates should not error")
|
||||
require.False(t, done, "initialization was not needed")
|
||||
})
|
||||
}
|
||||
85
server/boards/app/user.go
Обычный файл
85
server/boards/app/user.go
Обычный файл
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetTeamUsers(teamID string, asGuestID string) ([]*model.User, error) {
|
||||
return a.store.GetUsersByTeam(teamID, asGuestID, a.config.ShowEmailAddress, a.config.ShowFullName)
|
||||
}
|
||||
|
||||
func (a *App) SearchTeamUsers(teamID string, searchQuery string, asGuestID string, excludeBots bool) ([]*model.User, error) {
|
||||
users, err := a.store.SearchUsersByTeam(teamID, searchQuery, asGuestID, excludeBots, a.config.ShowEmailAddress, a.config.ShowFullName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, u := range users {
|
||||
if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) {
|
||||
users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id)
|
||||
}
|
||||
if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) {
|
||||
users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id)
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateUserConfig(userID string, patch model.UserPreferencesPatch) ([]mm_model.Preference, error) {
|
||||
updatedPreferences, err := a.store.PatchUserPreferences(userID, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedPreferences, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUserPreferences(userID string) ([]mm_model.Preference, error) {
|
||||
return a.store.GetUserPreferences(userID)
|
||||
}
|
||||
|
||||
func (a *App) UserIsGuest(userID string) (bool, error) {
|
||||
user, err := a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return user.IsGuest, nil
|
||||
}
|
||||
|
||||
func (a *App) CanSeeUser(seerUser string, seenUser string) (bool, error) {
|
||||
isGuest, err := a.UserIsGuest(seerUser)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if isGuest {
|
||||
hasSharedChannels, err := a.store.CanSeeUser(seerUser, seenUser)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hasSharedChannels, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) SearchUserChannels(teamID string, userID string, query string) ([]*mm_model.Channel, error) {
|
||||
channels, err := a.store.SearchUserChannels(teamID, userID, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var writeableChannels []*mm_model.Channel
|
||||
for _, channel := range channels {
|
||||
if a.permissions.HasPermissionToChannel(userID, channel.Id, model.PermissionCreatePost) {
|
||||
writeableChannels = append(writeableChannels, channel)
|
||||
}
|
||||
}
|
||||
return writeableChannels, nil
|
||||
}
|
||||
|
||||
func (a *App) GetChannel(teamID string, channelID string) (*mm_model.Channel, error) {
|
||||
return a.store.GetChannel(teamID, channelID)
|
||||
}
|
||||
89
server/boards/app/user_test.go
Обычный файл
89
server/boards/app/user_test.go
Обычный файл
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestSearchUsers(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
th.App.config.ShowEmailAddress = false
|
||||
th.App.config.ShowFullName = false
|
||||
|
||||
teamID := "team-id-1"
|
||||
userID := "user-id-1"
|
||||
|
||||
t.Run("return empty users", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{}, nil)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(users))
|
||||
})
|
||||
|
||||
t.Run("return user", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(users))
|
||||
assert.Equal(t, 0, len(users[0].Permissions))
|
||||
})
|
||||
|
||||
t.Run("return team admin", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
th.App.config.ShowEmailAddress = false
|
||||
th.App.config.ShowFullName = false
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(users))
|
||||
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
|
||||
})
|
||||
|
||||
t.Run("return system admin", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
th.App.config.ShowEmailAddress = false
|
||||
th.App.config.ShowFullName = false
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(true).Times(1)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(users))
|
||||
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
|
||||
assert.Equal(t, users[0].Permissions[1], model.PermissionManageSystem.Id)
|
||||
})
|
||||
|
||||
t.Run("test user channels", func(t *testing.T) {
|
||||
channelID := "Channel1"
|
||||
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mm_model.Channel{{Id: channelID}}, nil)
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1)
|
||||
|
||||
channels, err := th.App.SearchUserChannels(teamID, userID, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(channels))
|
||||
})
|
||||
|
||||
t.Run("test user channels- no permissions", func(t *testing.T) {
|
||||
channelID := "Channel1"
|
||||
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mm_model.Channel{{Id: channelID}}, nil)
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
|
||||
|
||||
channels, err := th.App.SearchUserChannels(teamID, userID, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(channels))
|
||||
})
|
||||
}
|
||||
15
server/boards/assets/assets.go
Обычный файл
15
server/boards/assets/assets.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// DefaultTemplatesArchive is an embedded archive file containing the default
|
||||
// templates to be imported to team 0.
|
||||
// This archive is generated with `make templates-archive`
|
||||
//
|
||||
//go:embed templates.boardarchive
|
||||
var DefaultTemplatesArchive []byte
|
||||
195
server/boards/assets/build-template-archive/main.go
Обычный файл
195
server/boards/assets/build-template-archive/main.go
Обычный файл
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
defArchiveFilename = "templates.boardarchive"
|
||||
versionFilename = "version.json"
|
||||
boardFilename = "board.jsonl"
|
||||
minArchiveVersion = 2
|
||||
maxArchiveVersion = 2
|
||||
)
|
||||
|
||||
type archiveVersion struct {
|
||||
Version int `json:"version"`
|
||||
Date int64 `json:"date"`
|
||||
}
|
||||
|
||||
type appConfig struct {
|
||||
dir string
|
||||
out string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := appConfig{}
|
||||
|
||||
flag.StringVar(&cfg.dir, "dir", "", "source directory of templates")
|
||||
flag.StringVar(&cfg.out, "out", defArchiveFilename, "output filename")
|
||||
flag.BoolVar(&cfg.verbose, "verbose", false, "enable verbose output")
|
||||
flag.Parse()
|
||||
|
||||
if cfg.dir == "" {
|
||||
flag.Usage()
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
var code int
|
||||
if err := build(cfg); err != nil {
|
||||
code = -1
|
||||
fmt.Fprintf(os.Stderr, "error creating archive: %v\n", err)
|
||||
} else if cfg.verbose {
|
||||
fmt.Fprintf(os.Stdout, "archive created: %s\n", cfg.out)
|
||||
}
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func build(cfg appConfig) (err error) {
|
||||
version, err := getVersionFile(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create the output archive zip file
|
||||
archiveFile, err := os.Create(cfg.out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating %s: %w", cfg.out, err)
|
||||
}
|
||||
archiveZip := zip.NewWriter(archiveFile)
|
||||
defer func() {
|
||||
if err2 := archiveZip.Close(); err2 != nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("error closing zip %s: %w", cfg.out, err2)
|
||||
}
|
||||
}
|
||||
if err2 := archiveFile.Close(); err2 != nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("error closing %s: %w", cfg.out, err2)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// write the version file
|
||||
v, err := archiveZip.Create(versionFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating %s: %w", cfg.out, err)
|
||||
}
|
||||
if _, err = v.Write(version); err != nil {
|
||||
return fmt.Errorf("error writing %s: %w", cfg.out, err)
|
||||
}
|
||||
|
||||
// each board is a subdirectory; write each to the archive
|
||||
files, err := os.ReadDir(cfg.dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading directory %s: %w", cfg.dir, err)
|
||||
}
|
||||
for _, f := range files {
|
||||
if !f.IsDir() {
|
||||
if f.Name() != versionFilename && cfg.verbose {
|
||||
fmt.Fprintf(os.Stdout, "skipping non-directory %s\n", f.Name())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err = writeBoard(archiveZip, f.Name(), cfg); err != nil {
|
||||
return fmt.Errorf("error writing board %s: %w", f.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getVersionFile(cfg appConfig) ([]byte, error) {
|
||||
path := filepath.Join(cfg.dir, versionFilename)
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var version archiveVersion
|
||||
if err := json.Unmarshal(buf, &version); err != nil {
|
||||
return nil, fmt.Errorf("cannot parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
if version.Version < minArchiveVersion || version.Version > maxArchiveVersion {
|
||||
return nil, errUnsupportedVersion{Min: minArchiveVersion, Max: maxArchiveVersion, Got: version.Version}
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func writeBoard(w *zip.Writer, boardID string, cfg appConfig) error {
|
||||
// copy the board's jsonl file first. BoardID is also the directory name.
|
||||
srcPath := filepath.Join(cfg.dir, boardID, boardFilename)
|
||||
destPath := filepath.Join(boardID, boardFilename)
|
||||
if err := writeFile(w, srcPath, destPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
boardPath := filepath.Join(cfg.dir, boardID)
|
||||
files, err := os.ReadDir(boardPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading board directory %s: %w", cfg.dir, err)
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
if cfg.verbose {
|
||||
fmt.Fprintf(os.Stdout, "skipping directory %s\n", f.Name())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.Name() == boardFilename {
|
||||
continue
|
||||
}
|
||||
|
||||
srcPath = filepath.Join(cfg.dir, boardID, f.Name())
|
||||
destPath = filepath.Join(boardID, f.Name())
|
||||
if err = writeFile(w, srcPath, destPath, cfg); err != nil {
|
||||
return fmt.Errorf("error writing %s: %w", destPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFile(w *zip.Writer, srcPath string, destPath string, cfg appConfig) (err error) {
|
||||
inFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading %s: %w", srcPath, err)
|
||||
}
|
||||
defer inFile.Close()
|
||||
|
||||
outFile, err := w.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating %s: %w", destPath, err)
|
||||
}
|
||||
size, err := io.Copy(outFile, inFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
if cfg.verbose {
|
||||
fmt.Fprintf(os.Stdout, "%s written (%d bytes)\n", destPath, size)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type errUnsupportedVersion struct {
|
||||
Min int
|
||||
Max int
|
||||
Got int
|
||||
}
|
||||
|
||||
func (e errUnsupportedVersion) Error() string {
|
||||
return fmt.Sprintf("unsupported archive version; require between %d and %d inclusive, got %d", e.Min, e.Max, e.Got)
|
||||
}
|
||||
Двоичные данные
server/boards/assets/build-template-archive/test.boardarchive
Обычный файл
Двоичные данные
server/boards/assets/build-template-archive/test.boardarchive
Обычный файл
Двоичный файл не отображается.
@@ -0,0 +1,36 @@
|
||||
{"type":"block","data":{"id":"b7wnw9awd4pnefryhq51apbzb4c","parentId":"","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"board","title":"Meeting Agenda (NEW)","fields":{"cardProperties":[{"id":"d777ba3b-8728-40d1-87a6-59406bbbbfb0","name":"Status","options":[{"color":"propColorPink","id":"34eb9c25-d5bf-49d9-859e-f74f4e0030e7","value":"To Discuss 💬"},{"color":"propColorYellow","id":"d37a61f4-f332-4db9-8b2d-5e0a91aa20ed","value":"Revisit Later ⏳"},{"color":"propColorGreen","id":"dabadd9b-adf1-4d9f-8702-805ac6cef602","value":"Done / Archived 📦"}],"type":"select"},{"id":"4cf1568d-530f-4028-8ffd-bdc65249187e","name":"Priority","options":[{"color":"propColorRed","id":"8b05c83e-a44a-4d04-831e-97f01d8e2003","value":"1. High"},{"color":"propColorYellow","id":"b1abafbf-a038-4a19-8b68-56e0fd2319f7","value":"2. Medium"},{"color":"propColorGray","id":"2491ffaa-eb55-417b-8aff-4bd7d4136613","value":"3. Low"}],"type":"select"},{"id":"aw4w63xhet79y9gueqzzeiifdoe","name":"Created by","options":[],"type":"createdBy"},{"id":"a6ux19353xcwfqg9k1inqg5sg4w","name":"Created time","options":[],"type":"createdTime"}],"description":"Use this template for recurring meeting agendas, like team meetings and 1:1's. To use this board:\n* Participants queue new items to discuss under \"To Discuss\"\n* Go through items during the meeting\n* Move items to Done or Revisit Later as needed","icon":"🍩","isTemplate":false,"showDescription":true},"createAt":1641497047916,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cgwagmaw6gin7xcq7nwew8rsynr","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Team Schedule","fields":{"contentOrder":["a4t1p1pbxbtnnu8p8e538o8369a","7b7hsbkm6sifqfqi4gstxxaz7my","aoqz1pydxbtnzdcs4ehcuys6cuc","7b3njq5m3n78hdpe4bimzr34fic","73dzfgistnbgzuekc6c8irou9wy","7z4cjur4ybbfibgmydhfct4jdke"],"icon":"⏰","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"8b05c83e-a44a-4d04-831e-97f01d8e2003","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"34eb9c25-d5bf-49d9-859e-f74f4e0030e7"}},"createAt":1641497048246,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"chki1tsudciyiiffrkqbcmp71rh","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Video production","fields":{"contentOrder":["a9ti13dqo8jfmjdmg97f5umfdyw","717fa85sx3f8f8m81f771s9hmwr","a4se5s4ozx3ry8ec57w6z6jpk7y","7n37rxrn9uffdzrfi1xajotzjey","7ifofmuwjzbdzppfxgtuai4i47h","7cfc4fkpz53gn9frciz9kui4p1c"],"icon":"📹","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"b1abafbf-a038-4a19-8b68-56e0fd2319f7","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"34eb9c25-d5bf-49d9-859e-f74f4e0030e7"}},"createAt":1641497048092,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cmt5usr1mw3fom886t34ekjquay","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Offsite plans","fields":{"contentOrder":["aw53ugkfq8pyi9fjh9j6i4kdeiw","7ni9593iz3pnb7xitoz3guwq5gh","agjkcro3x7irbxedyxrn8iuerrr","75zkot1f3sjb7ifysuzijitw91y","7is5m8apdu3g53c8f6cz6sq7bmh","7xsmzscbqn3ftudzqbb4w1q7t7e"],"icon":"🚙","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"8b05c83e-a44a-4d04-831e-97f01d8e2003","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"dabadd9b-adf1-4d9f-8702-805ac6cef602"}},"createAt":1641497048336,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cnqsbzg4b7brfddtyh7fc66atrw","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"card","title":"Social Media Strategy","fields":{"contentOrder":["ao57n1fbtmt8q8bfk8ieqgzqt3a","76h9y996sdj8sbrbpqjo9d8cwto","aco8iu5jp7jbyzmzegwxkeusgzr","7y6zcyofmsfrbt899ts1ixr3iey","7hudywfzcwirkpcp1p5jhsfs83r","7jzw67ngdgtns8mstsg9g614oac"],"icon":"🎉","isTemplate":false,"properties":{"4cf1568d-530f-4028-8ffd-bdc65249187e":"b1abafbf-a038-4a19-8b68-56e0fd2319f7","d777ba3b-8728-40d1-87a6-59406bbbbfb0":"d37a61f4-f332-4db9-8b2d-5e0a91aa20ed"}},"createAt":1641497048417,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vfs8sj79dt7n75bomn46fybxmfo","parentId":"b7wnw9awd4pnefryhq51apbzb4c","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"view","title":"Discussion Items","fields":{"cardOrder":["cjpkiya33qsagr4f9hrdwhgiajc"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"d777ba3b-8728-40d1-87a6-59406bbbbfb0","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[{"propertyId":"4cf1568d-530f-4028-8ffd-bdc65249187e","reversed":false}],"viewType":"board","visibleOptionIds":[],"visiblePropertyIds":["4cf1568d-530f-4028-8ffd-bdc65249187e"]},"createAt":1641497048501,"updateAt":1643788318629,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"73dzfgistnbgzuekc6c8irou9wy","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586451774,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7b3njq5m3n78hdpe4bimzr34fic","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586448934,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7b7hsbkm6sifqfqi4gstxxaz7my","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586358664,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7z4cjur4ybbfibgmydhfct4jdke","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586454130,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a4t1p1pbxbtnnu8p8e538o8369a","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586355777,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aoqz1pydxbtnzdcs4ehcuys6cuc","parentId":"cgwagmaw6gin7xcq7nwew8rsynr","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586443526,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"766mkfhc4u7dxzcc36nhfpmm5fy","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586677789,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"76w5qigi5ufgktcmmnw9ze88w5w","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641497389096,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"79wi7osb3utd3mjt9x57h7wpqfa","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641497390990,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7un1ccdg7qi8j3gxmkx5y3d9nhr","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641497382984,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"as3orhrci6tnutp5etbh6bzbgdy","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"text","title":"# Action Items","fields":{},"createAt":1641497371429,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"axyitfq8ae38qictgcw34cmwueh","parentId":"ch798q5ucefyobf5bymgqjt4f3h","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"mweioqznbife7p7aee7dr4wcxo","modifiedBy":"mweioqznbife7p7aee7dr4wcxo","schema":1,"type":"text","title":"# Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641497348992,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"717fa85sx3f8f8m81f771s9hmwr","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586368705,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7cfc4fkpz53gn9frciz9kui4p1c","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586479058,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7ifofmuwjzbdzppfxgtuai4i47h","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586476646,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7n37rxrn9uffdzrfi1xajotzjey","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586469805,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a4se5s4ozx3ry8ec57w6z6jpk7y","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586462602,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a9ti13dqo8jfmjdmg97f5umfdyw","parentId":"chki1tsudciyiiffrkqbcmp71rh","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586365342,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"75zkot1f3sjb7ifysuzijitw91y","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586514173,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7is5m8apdu3g53c8f6cz6sq7bmh","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586516563,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7ni9593iz3pnb7xitoz3guwq5gh","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586383504,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7xsmzscbqn3ftudzqbb4w1q7t7e","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586518624,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"agjkcro3x7irbxedyxrn8iuerrr","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586506048,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aw53ugkfq8pyi9fjh9j6i4kdeiw","parentId":"cmt5usr1mw3fom886t34ekjquay","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586380592,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"76h9y996sdj8sbrbpqjo9d8cwto","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641586375619,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7hudywfzcwirkpcp1p5jhsfs83r","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586495344,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7jzw67ngdgtns8mstsg9g614oac","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586497433,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7y6zcyofmsfrbt899ts1ixr3iey","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"","fields":{"value":false},"createAt":1641586492877,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aco8iu5jp7jbyzmzegwxkeusgzr","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Action Items","fields":{},"createAt":1641586487881,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ao57n1fbtmt8q8bfk8ieqgzqt3a","parentId":"cnqsbzg4b7brfddtyh7fc66atrw","rootId":"b7wnw9awd4pnefryhq51apbzb4c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n*[Add meeting notes here]*","fields":{},"createAt":1641586373252,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
@@ -0,0 +1,94 @@
|
||||
{"type":"board","data":{"id":"bbkpwdj8x17bdpdqd176n8ctoua","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Sales Pipeline CRM","description":"Use this template to grow and keep track of your sales opportunities.","icon":"📈","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"a5hwxjsmkn6bak6r7uea5bx1kwc","name":"Status","options":[{"color":"propColorGray","id":"akj61wc9yxdwyw3t6m8igyf9d5o","value":"Lead"},{"color":"propColorYellow","id":"aic89a5xox4wbppi6mbyx6ujsda","value":"Qualified"},{"color":"propColorBrown","id":"ah6ehh43rwj88jy4awensin8pcw","value":"Meeting"},{"color":"propColorPurple","id":"aprhd96zwi34o9cs4xyr3o9sf3c","value":"Proposal"},{"color":"propColorOrange","id":"axesd74yuxtbmw1sbk8ufax7z3a","value":"Negotiation"},{"color":"propColorRed","id":"a5txuiubumsmrs8gsd5jz5gc1oa","value":"Lost"},{"color":"propColorGreen","id":"acm9q494bcthyoqzmfogxxy5czy","value":"Closed 🏆"}],"type":"select"},{"id":"aoheuj1f3mu6eehygr45fxa144y","name":"Account Owner","options":[],"type":"multiPerson"},{"id":"aro91wme9kfaie5ceu9qasmtcnw","name":"Priority","options":[{"color":"propColorRed","id":"apjnaggwixchfxwiatfh7ey7uno","value":"High 🔥"},{"color":"propColorYellow","id":"apiswzj7uiwbh87z8dw8c6mturw","value":"Medium"},{"color":"propColorBrown","id":"auu9bfzqeuruyjwzzqgz7q8apuw","value":"Low"}],"type":"select"},{"id":"ainpw47babwkpyj77ic4b9zq9xr","name":"Company","options":[],"type":"text"},{"id":"ahf43e44h3y8ftanqgzno9z7q7w","name":"Estimated Value","options":[],"type":"number"},{"id":"amahgyn9n4twaapg3jyxb6y4jic","name":"Territory","options":[{"color":"propColorBrown","id":"ar6t1ttcumgfuqugg5o4g4mzrza","value":"Western US"},{"color":"propColorGreen","id":"asbwojkm7zb4ohrtij97jkdfgwe","value":"Mountain West / Central US"},{"color":"propColorGray","id":"aw8ppwtcrm8iwopdadje3ni196w","value":"Mid-Atlantic / Southeast"},{"color":"propColorBlue","id":"aafwyza5iwdcwcyfyj6bp7emufw","value":"Northeast US / Canada"},{"color":"propColorPink","id":"agw8rcb9uxyt3c7g6tq3r65fgqe","value":"Eastern Europe"},{"color":"propColorPurple","id":"as5bk6afoaaa7caewe1zc391sce","value":"Central Europe / Africa"},{"color":"propColorYellow","id":"a8fj94bka8z9t6p95qd3hn6t5re","value":"Middle East"},{"color":"propColorOrange","id":"arpxa3faaou9trt4zx5sh435gne","value":"UK"},{"color":"propColorRed","id":"azdidd5wze4kcxf8neefj3ctkyr","value":"Asia"},{"color":"propColorGray","id":"a4jn5mhqs3thknqf5opykntgsnc","value":"Australia"},{"color":"propColorBrown","id":"afjbgrecb7hp5owj7xh8u4w33tr","value":"Latin America"}],"type":"select"},{"id":"abru6tz8uebdxy4skheqidh7zxy","name":"Email","options":[],"type":"email"},{"id":"a1438fbbhjeffkexmcfhnx99o1h","name":"Phone","options":[],"type":"phone"},{"id":"auhf91pm85f73swwidi4wid8jqe","name":"Last Contact Date","options":[],"type":"date"},{"id":"adtf1151chornmihz4xbgbk9exa","name":"Expected Close","options":[],"type":"date"},{"id":"aejo5tcmq54bauuueem9wc4fw4y","name":"Close Probability","options":[],"type":"text"},{"id":"amba7ot98fh7hwsx8jdcfst5g7h","name":"Created Date","options":[],"type":"createdTime"}],"createAt":1667509277974,"updateAt":1667511890353,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"v76ciioz6ujd49phimp5jzomsww","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"All Contacts","fields":{"cardOrder":["cyt3qdus94pg3fkxq4ojebyd5fr","chew1d7kc3py3pj51qyqaiz6ade","c91bktnpajfrrdpxs7ck1h7ziwh","c77c6z9k9oigdpbocg8kxi7h8ah","c9ciauq49ifdntc99rnehkkshpr"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":240,"a1438fbbhjeffkexmcfhnx99o1h":151,"a5hwxjsmkn6bak6r7uea5bx1kwc":132,"abru6tz8uebdxy4skheqidh7zxy":247,"adtf1151chornmihz4xbgbk9exa":125,"aejo5tcmq54bauuueem9wc4fw4y":127,"ahf43e44h3y8ftanqgzno9z7q7w":129,"ainpw47babwkpyj77ic4b9zq9xr":157,"amahgyn9n4twaapg3jyxb6y4jic":224,"amba7ot98fh7hwsx8jdcfst5g7h":171,"aoheuj1f3mu6eehygr45fxa144y":130,"auhf91pm85f73swwidi4wid8jqe":157},"defaultTemplateId":"cphg5tyix4irsipkcp9ujaj3gwh","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a5hwxjsmkn6bak6r7uea5bx1kwc","aoheuj1f3mu6eehygr45fxa144y","aro91wme9kfaie5ceu9qasmtcnw","ainpw47babwkpyj77ic4b9zq9xr","ahf43e44h3y8ftanqgzno9z7q7w","amahgyn9n4twaapg3jyxb6y4jic","abru6tz8uebdxy4skheqidh7zxy","a1438fbbhjeffkexmcfhnx99o1h","auhf91pm85f73swwidi4wid8jqe","adtf1151chornmihz4xbgbk9exa","aejo5tcmq54bauuueem9wc4fw4y","amba7ot98fh7hwsx8jdcfst5g7h"]},"createAt":1667513494864,"updateAt":1667513802156,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"va9qcbagmdbfwb8xq5hawbq1a4r","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Pipeline Tracker","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["akj61wc9yxdwyw3t6m8igyf9d5o","aic89a5xox4wbppi6mbyx6ujsda","ah6ehh43rwj88jy4awensin8pcw","aprhd96zwi34o9cs4xyr3o9sf3c","axesd74yuxtbmw1sbk8ufax7z3a","a5txuiubumsmrs8gsd5jz5gc1oa","acm9q494bcthyoqzmfogxxy5czy"],"visiblePropertyIds":["aro91wme9kfaie5ceu9qasmtcnw","amahgyn9n4twaapg3jyxb6y4jic"]},"createAt":1667513379646,"updateAt":1667513589086,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"c77c6z9k9oigdpbocg8kxi7h8ah","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Jonathan Frazier","fields":{"contentOrder":["a7hc9n8oz47gybkxj4ssnwgi7ky","a4bminunz1j8p3go9ixxdxpi4no","71ibw3rrac7gcmgr4f16st7fz1c","736fwfii9t7nafekshdjc6y4rge","78aiw1o1wzibzzbiuo4e78p4pdr","7ei858uzb9jye8yqo7j5nq1knaa","7bai1o5z5fibiuxs7i9i8tti87w","7cg1mxma4fjb67xmh1p7fyxekro","76ry4rpfhq7ykprpmbidxdjr33o","77gckzfpcmjb1bysnnqs7cnzseo","7biw71wn9nfdgxd7fbh9un68zrc","7iz6fjou66i8muqnhzb9pocff3e"],"icon":"🙎♂️","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(999) 123-5678","a5hwxjsmkn6bak6r7uea5bx1kwc":"a5txuiubumsmrs8gsd5jz5gc1oa","abru6tz8uebdxy4skheqidh7zxy":"jonathan.frazier@email.com","aejo5tcmq54bauuueem9wc4fw4y":"0%","ahf43e44h3y8ftanqgzno9z7q7w":"$800,000","ainpw47babwkpyj77ic4b9zq9xr":"Ositions Inc.","amahgyn9n4twaapg3jyxb6y4jic":"as5bk6afoaaa7caewe1zc391sce","aro91wme9kfaie5ceu9qasmtcnw":"apiswzj7uiwbh87z8dw8c6mturw","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1669118400000}"}},"createAt":1667513212844,"updateAt":1667513367839,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"c91bktnpajfrrdpxs7ck1h7ziwh","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Richard Guzman","fields":{"contentOrder":["a43kn6138w7boimnp5xe1khezjc","ab6q8dsqh7ifhmk14ow4m9ytj3e","73p7qyd8h13nq5fk54rqgbee7or","7sqafho6jofdtjk5byn3yskq5ry","7q6pi4f9dbtrpzbcm65hg9useso","7976uafbzbjrmjya983z5bweesy","7nu71kxnutbd6fdnmzjfbrczinw","7j3q9i5a337ym3fdnigx3ifrhoh","7jap7w5js9bfazgsa59skmocmhw","7mndskgucj3g18ys7c6wjpub78o","7kwmrfpx8pir5ieg5w8orbtq8ba","7mwxoycnpq7nhix7r5x3wtmqd3h"],"icon":"👨💼","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(222) 123-1234","a5hwxjsmkn6bak6r7uea5bx1kwc":"axesd74yuxtbmw1sbk8ufax7z3a","abru6tz8uebdxy4skheqidh7zxy":"richard.guzman@email.com","adtf1151chornmihz4xbgbk9exa":"{\"from\":1681992000000}","aejo5tcmq54bauuueem9wc4fw4y":"80%","ahf43e44h3y8ftanqgzno9z7q7w":"$3,200,000","ainpw47babwkpyj77ic4b9zq9xr":"Afformance Ltd.","amahgyn9n4twaapg3jyxb6y4jic":"ar6t1ttcumgfuqugg5o4g4mzrza","aro91wme9kfaie5ceu9qasmtcnw":"apjnaggwixchfxwiatfh7ey7uno","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1667476800000}"}},"createAt":1667512379637,"updateAt":1667512604683,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"c9ciauq49ifdntc99rnehkkshpr","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Byron Cole","fields":{"contentOrder":["a4wwsynhjafb4dgbubda18ho3fr","a1ag6b4hwkibbbbxdmse74cw3ur","767qdn4uhbbrb8gyq4x7w1rfcoc","7ad16jbhbcpro78cueumekyqjyy","7xbj8zr1jxfnxfkyfyccb84ddeo","7sggexapxebb1zk9oqta6gcwsda","7y3ncauhatfrg7nzyr67twe36wc","74ach4ckw53grfygwp8m6wbj4ya","7agc943grqtgidb3e49dkqumrce","7owy1izqn1if55r5hc3fgu8fada","7zcbwgrw5apd4frn6uxd386rktc","7zijtxs3enjy5frzc4zb6937b3w"],"icon":"🤵","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(333) 123-1234","a5hwxjsmkn6bak6r7uea5bx1kwc":"acm9q494bcthyoqzmfogxxy5czy","abru6tz8uebdxy4skheqidh7zxy":"byron.cole@email.com","adtf1151chornmihz4xbgbk9exa":"{\"from\":1667563200000}","aejo5tcmq54bauuueem9wc4fw4y":"100%","ahf43e44h3y8ftanqgzno9z7q7w":"$500,000","ainpw47babwkpyj77ic4b9zq9xr":"Helx Industries","amahgyn9n4twaapg3jyxb6y4jic":"aafwyza5iwdcwcyfyj6bp7emufw","aro91wme9kfaie5ceu9qasmtcnw":"apjnaggwixchfxwiatfh7ey7uno","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1667822400000}"}},"createAt":1667512692248,"updateAt":1667512904723,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"chew1d7kc3py3pj51qyqaiz6ade","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Caitlyn Russel","fields":{"contentOrder":["atgpetmwdubb5jkugcb6jm9pzyo","acod8woq6zjbzmc1hz8qkfxyi1h","7s47d7rzh4pnw5rcnpjysxg6duh","7s9smhppjff87tndwawqwdmfryo","7t3ib1amo7fgzbmhg4tkzqustcy","7s5bgtoajcbnd8rrc5bxzabdcyw","7nze85jfmobfm8j8xfmrbdwyrfa","7jrwix8rkbtb5bdek79mtat8w1c","7c1iwiqsi1iddpfzqisbkubjxhh","7tp1rgey147nnfjuose7418oioh","7ftxm79a1e7nuxpb913aqphoqbo","799cbodnfr3ydfjp53die7egd1e"],"icon":"🧑💼","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(111) 123-1234","a5hwxjsmkn6bak6r7uea5bx1kwc":"ah6ehh43rwj88jy4awensin8pcw","abru6tz8uebdxy4skheqidh7zxy":"caitlyn.russel@email.com","adtf1151chornmihz4xbgbk9exa":"{\"from\":1689336000000}","aejo5tcmq54bauuueem9wc4fw4y":"20%","ahf43e44h3y8ftanqgzno9z7q7w":"$250,000","ainpw47babwkpyj77ic4b9zq9xr":"Liminary Corp.","amahgyn9n4twaapg3jyxb6y4jic":"aafwyza5iwdcwcyfyj6bp7emufw","aro91wme9kfaie5ceu9qasmtcnw":"apiswzj7uiwbh87z8dw8c6mturw","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1668168000000}"}},"createAt":1667509567800,"updateAt":1667512683024,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"cphg5tyix4irsipkcp9ujaj3gwh","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"New Prospect","fields":{"contentOrder":["atw8pz7bqgp877pd5714jbpqrsh","azwoek6rwfpfqiruig13owyyagr","71735rqboe3rkxypssssddjykkc","7jgf4cownfiy7xpaznxdsnyze9a","74khjujy4hir4zmer4hkj1gcckh","768ut9xkqipgf9fk6ub146spu5e","7jryotoo5wig9bdt3kh1fmgm5qw","7p7hz5ky15jgrirb64533xzsquo","7c9cy5ohjd3b85xkee539zw9owh","7dsynp6qf8tdtjpcqsxfyuqyzmo","7kxzdhjtx8pdazm7bufusybwygo","7h1zyk7thz7gx3r5degq6qorjay"],"icon":"👤","isTemplate":true,"properties":{"a5hwxjsmkn6bak6r7uea5bx1kwc":"akj61wc9yxdwyw3t6m8igyf9d5o"}},"createAt":1667513652330,"updateAt":1667513749765,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"cyt3qdus94pg3fkxq4ojebyd5fr","parentId":"bbkpwdj8x17bdpdqd176n8ctoua","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Shelby Olson","fields":{"contentOrder":["ay46giuhekby5tmrnw4abnz97pw","awugzceoocjdnmn1ab6srb5cc6r","7bncmywm3h38s7ndpcu9sytfffy","7zmoekhdb4i848p313eh1okp78c","7xxy1eewp8jdxpcouho8jq7ed4w","7rgodjyks6jrtprbeizusduat4c","7u5qxs77u57bc8bd33ug5aa91rw","7jexsw3nutb8m3x6eyqio7gtcxr","7abw4xifxubn1urheakij9kjc5e","7r47h1d8fjfrpzkfzyxha44wrqe","7yq4oh69547rm9mp3eqg9zqzoxw","7yhpeqyesfif188x4pabwurnw4o"],"icon":"🙎♀️","isTemplate":false,"properties":{"a1438fbbhjeffkexmcfhnx99o1h":"(111) 321-5678","a5hwxjsmkn6bak6r7uea5bx1kwc":"akj61wc9yxdwyw3t6m8igyf9d5o","abru6tz8uebdxy4skheqidh7zxy":"shelby.olson@email.com","ahf43e44h3y8ftanqgzno9z7q7w":"$30,000","ainpw47babwkpyj77ic4b9zq9xr":"Kadera Global","amahgyn9n4twaapg3jyxb6y4jic":"ar6t1ttcumgfuqugg5o4g4mzrza","aro91wme9kfaie5ceu9qasmtcnw":"auu9bfzqeuruyjwzzqgz7q8apuw","auhf91pm85f73swwidi4wid8jqe":"{\"from\":1669291200000}"}},"createAt":1667512982640,"updateAt":1667513171727,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"vg19cqh9bnbfq5edwq4kep3ssxr","parentId":"bzwb99zf498tsm7mjqbiy7g81ze","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Open Deals","fields":{"cardOrder":["chew1d7kc3py3pj51qyqaiz6ade"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[{"condition":"includes","propertyId":"a5hwxjsmkn6bak6r7uea5bx1kwc","values":["akj61wc9yxdwyw3t6m8igyf9d5o","aic89a5xox4wbppi6mbyx6ujsda","ah6ehh43rwj88jy4awensin8pcw","aprhd96zwi34o9cs4xyr3o9sf3c","axesd74yuxtbmw1sbk8ufax7z3a"]}],"operation":"and"},"groupById":"aro91wme9kfaie5ceu9qasmtcnw","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["apjnaggwixchfxwiatfh7ey7uno","apiswzj7uiwbh87z8dw8c6mturw","auu9bfzqeuruyjwzzqgz7q8apuw",""],"visiblePropertyIds":[]},"createAt":1667509277984,"updateAt":1667513521431,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"71ibw3rrac7gcmgr4f16st7fz1c","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667513212852,"updateAt":1667513212852,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"736fwfii9t7nafekshdjc6y4rge","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667513212861,"updateAt":1667513341391,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"76ry4rpfhq7ykprpmbidxdjr33o","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{"value":true},"createAt":1667513212920,"updateAt":1667513348088,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"77gckzfpcmjb1bysnnqs7cnzseo","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667513212930,"updateAt":1667513212930,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"78aiw1o1wzibzzbiuo4e78p4pdr","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667513212869,"updateAt":1667513342078,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7bai1o5z5fibiuxs7i9i8tti87w","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667513212887,"updateAt":1667513344670,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7biw71wn9nfdgxd7fbh9un68zrc","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667513212939,"updateAt":1667513212939,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7cg1mxma4fjb67xmh1p7fyxekro","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{"value":true},"createAt":1667513212912,"updateAt":1667513345694,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7ei858uzb9jye8yqo7j5nq1knaa","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667513212878,"updateAt":1667513343116,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7iz6fjou66i8muqnhzb9pocff3e","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667513212947,"updateAt":1667513212947,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"a4bminunz1j8p3go9ixxdxpi4no","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667513212903,"updateAt":1667513212903,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"a7hc9n8oz47gybkxj4ssnwgi7ky","parentId":"c77c6z9k9oigdpbocg8kxi7h8ah","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667513212895,"updateAt":1667513212895,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"73p7qyd8h13nq5fk54rqgbee7or","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667512379656,"updateAt":1667512968074,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7976uafbzbjrmjya983z5bweesy","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667512379686,"updateAt":1667512970061,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7j3q9i5a337ym3fdnigx3ifrhoh","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{"value":true},"createAt":1667512379752,"updateAt":1667512975240,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7jap7w5js9bfazgsa59skmocmhw","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{"value":true},"createAt":1667512379772,"updateAt":1667512975857,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7kwmrfpx8pir5ieg5w8orbtq8ba","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667512379805,"updateAt":1667512379805,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7mndskgucj3g18ys7c6wjpub78o","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667512379792,"updateAt":1667512379792,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7mwxoycnpq7nhix7r5x3wtmqd3h","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667512379814,"updateAt":1667512379814,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7nu71kxnutbd6fdnmzjfbrczinw","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667512379695,"updateAt":1667512973476,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7q6pi4f9dbtrpzbcm65hg9useso","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667512379677,"updateAt":1667512969519,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7sqafho6jofdtjk5byn3yskq5ry","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667512379668,"updateAt":1667512968798,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"a43kn6138w7boimnp5xe1khezjc","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512379704,"updateAt":1667512379704,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"ab6q8dsqh7ifhmk14ow4m9ytj3e","parentId":"c91bktnpajfrrdpxs7ck1h7ziwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512379729,"updateAt":1667512379728,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"74ach4ckw53grfygwp8m6wbj4ya","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{"value":true},"createAt":1667512692319,"updateAt":1667512917248,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"767qdn4uhbbrb8gyq4x7w1rfcoc","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667512692257,"updateAt":1667512911931,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7ad16jbhbcpro78cueumekyqjyy","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667512692265,"updateAt":1667512912836,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7agc943grqtgidb3e49dkqumrce","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{"value":true},"createAt":1667512692327,"updateAt":1667512919194,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7owy1izqn1if55r5hc3fgu8fada","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{"value":true},"createAt":1667512692335,"updateAt":1667512920115,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7sggexapxebb1zk9oqta6gcwsda","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667512692283,"updateAt":1667512914481,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7xbj8zr1jxfnxfkyfyccb84ddeo","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667512692273,"updateAt":1667512913567,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7y3ncauhatfrg7nzyr67twe36wc","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667512692292,"updateAt":1667512915496,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7zcbwgrw5apd4frn6uxd386rktc","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{"value":true},"createAt":1667512692344,"updateAt":1667512920721,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7zijtxs3enjy5frzc4zb6937b3w","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{"value":true},"createAt":1667512692353,"updateAt":1667512922687,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"a1ag6b4hwkibbbbxdmse74cw3ur","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512692310,"updateAt":1667512692310,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"a4wwsynhjafb4dgbubda18ho3fr","parentId":"c9ciauq49ifdntc99rnehkkshpr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512692301,"updateAt":1667512692301,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"799cbodnfr3ydfjp53die7egd1e","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667512344379,"updateAt":1667512354748,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7c1iwiqsi1iddpfzqisbkubjxhh","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667512215518,"updateAt":1667512224971,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7ftxm79a1e7nuxpb913aqphoqbo","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667512251753,"updateAt":1667512267186,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7jrwix8rkbtb5bdek79mtat8w1c","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667512204105,"updateAt":1667512287236,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7nze85jfmobfm8j8xfmrbdwyrfa","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":true},"createAt":1667510597027,"updateAt":1667512961521,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7s47d7rzh4pnw5rcnpjysxg6duh","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667510557630,"updateAt":1667512956967,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7s5bgtoajcbnd8rrc5bxzabdcyw","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":true},"createAt":1667510586823,"updateAt":1667512960547,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7s9smhppjff87tndwawqwdmfryo","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":true},"createAt":1667510564441,"updateAt":1667512958081,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7t3ib1amo7fgzbmhg4tkzqustcy","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":true},"createAt":1667510573106,"updateAt":1667512959302,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7tp1rgey147nnfjuose7418oioh","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667512225170,"updateAt":1667512251543,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"acod8woq6zjbzmc1hz8qkfxyi1h","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512186838,"updateAt":1667512192833,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"atgpetmwdubb5jkugcb6jm9pzyo","parentId":"chew1d7kc3py3pj51qyqaiz6ade","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512110036,"updateAt":1667512180024,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"71735rqboe3rkxypssssddjykkc","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":false},"createAt":1667513652337,"updateAt":1667513703739,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"74khjujy4hir4zmer4hkj1gcckh","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":false},"createAt":1667513652354,"updateAt":1667513652354,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"768ut9xkqipgf9fk6ub146spu5e","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":false},"createAt":1667513652368,"updateAt":1667513652368,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7c9cy5ohjd3b85xkee539zw9owh","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667513652448,"updateAt":1667513652448,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7dsynp6qf8tdtjpcqsxfyuqyzmo","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667513652464,"updateAt":1667513652464,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7h1zyk7thz7gx3r5degq6qorjay","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667513652495,"updateAt":1667513652495,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7jgf4cownfiy7xpaznxdsnyze9a","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":false},"createAt":1667513652344,"updateAt":1667513652344,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7jryotoo5wig9bdt3kh1fmgm5qw","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":false},"createAt":1667513652384,"updateAt":1667513652384,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7kxzdhjtx8pdazm7bufusybwygo","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667513652486,"updateAt":1667513652486,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7p7hz5ky15jgrirb64533xzsquo","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667513652428,"updateAt":1667513652428,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"atw8pz7bqgp877pd5714jbpqrsh","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n[Enter notes here...]","fields":{},"createAt":1667513652402,"updateAt":1667513741067,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"azwoek6rwfpfqiruig13owyyagr","parentId":"cphg5tyix4irsipkcp9ujaj3gwh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667513652416,"updateAt":1667513652416,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"777h45bs9xffj3ecpe9ti9jqdar","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667513758151,"updateAt":1667513758151,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"78sze8whs5i8htgtsuwqc81agjr","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":false},"createAt":1667513758058,"updateAt":1667513758058,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7brzhoqztxpyg8jtqrd7c6dqtie","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667513758161,"updateAt":1667513758161,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7dejdqngn43rp3phmg64ditmyrr","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667513758142,"updateAt":1667513758142,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7dpbn45wo63r97fgb5356od9jyr","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":false},"createAt":1667513758067,"updateAt":1667513758067,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7gku1jfqfppb358a3q6y1b8sb7a","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":false},"createAt":1667513758086,"updateAt":1667513758086,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7p6xqywuxwifsfb67bc5zbhu1ny","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667513758133,"updateAt":1667513758133,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7qfj7p7xb4fgp9y4a8sp13ixiny","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667513758124,"updateAt":1667513758124,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7xrfub5nuxtb5pbuwrwtjbtekdw","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":false},"createAt":1667513758077,"updateAt":1667513758077,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7ynnwzqecx7f8t8yci1htkikude","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":false},"createAt":1667513758096,"updateAt":1667513758096,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"as8xstqmiobdr7ykjc4rb9pfcdh","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667513758114,"updateAt":1667513758114,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"azxp9y8hk33guugjq6iba7whj6h","parentId":"ct59gu9j4cpnrtjcpyn3a5okdqa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\n[Enter notes here...]","fields":{},"createAt":1667513758104,"updateAt":1667513758104,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7abw4xifxubn1urheakij9kjc5e","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send proposal","fields":{},"createAt":1667512982703,"updateAt":1667512982703,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7bncmywm3h38s7ndpcu9sytfffy","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send initial email","fields":{"value":true},"createAt":1667512982648,"updateAt":1667512982648,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7jexsw3nutb8m3x6eyqio7gtcxr","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Follow up after demo","fields":{},"createAt":1667512982697,"updateAt":1667512982697,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7r47h1d8fjfrpzkfzyxha44wrqe","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Finalize contract","fields":{},"createAt":1667512982712,"updateAt":1667512982712,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7rgodjyks6jrtprbeizusduat4c","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule follow-up sales call","fields":{"value":false},"createAt":1667512982669,"updateAt":1667513178427,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7u5qxs77u57bc8bd33ug5aa91rw","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule demo","fields":{"value":false},"createAt":1667512982675,"updateAt":1667513176256,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7xxy1eewp8jdxpcouho8jq7ed4w","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Schedule initial sales call","fields":{"value":false},"createAt":1667512982661,"updateAt":1667513177889,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7yhpeqyesfif188x4pabwurnw4o","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Post-sales follow up","fields":{},"createAt":1667512982725,"updateAt":1667512982725,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7yq4oh69547rm9mp3eqg9zqzoxw","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Hand-off to customer success","fields":{},"createAt":1667512982718,"updateAt":1667512982718,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"7zmoekhdb4i848p313eh1okp78c","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Send follow-up email","fields":{"value":false},"createAt":1667512982655,"updateAt":1667513179761,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"awugzceoocjdnmn1ab6srb5cc6r","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1667512982690,"updateAt":1667512982690,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
{"type":"block","data":{"id":"ay46giuhekby5tmrnw4abnz97pw","parentId":"cyt3qdus94pg3fkxq4ojebyd5fr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Notes\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.","fields":{},"createAt":1667512982683,"updateAt":1667512982683,"deleteAt":0,"boardId":"bbkpwdj8x17bdpdqd176n8ctoua"}}
|
||||
Двоичные данные
server/boards/assets/templates-boardarchive/bbn1888mprfrm5fjw9f1je9x3xo/76fwrj36hptg6dywka4k5mt3sph.png
Обычный файл
Двоичные данные
server/boards/assets/templates-boardarchive/bbn1888mprfrm5fjw9f1je9x3xo/76fwrj36hptg6dywka4k5mt3sph.png
Обычный файл
Двоичный файл не отображается.
|
После Ширина: | Высота: | Размер: 502 KiB |
@@ -0,0 +1,22 @@
|
||||
{"type":"block","data":{"id":"bbn1888mprfrm5fjw9f1je9x3xo","parentId":"","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Personal Tasks (NEW)","fields":{"cardProperties":[{"id":"a9zf59u8x1rf4ywctpcqama7tio","name":"Occurrence","options":[{"color":"propColorBlue","id":"an51dnkenmoog9cetapbc4uyt3y","value":"Daily"},{"color":"propColorOrange","id":"afpy8s7i45frggprmfsqngsocqh","value":"Weekly"},{"color":"propColorPurple","id":"aj4jyekqqssatjcq7r7chmy19ey","value":"Monthly"}],"type":"select"},{"id":"abthng7baedhhtrwsdodeuincqy","name":"Completed","options":[],"type":"checkbox"}],"description":"Use this template to organize your life and track your personal tasks.","icon":"✔️","isTemplate":false,"showDescription":true},"createAt":1640281433899,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c5xamko6rpibhje3bjreenon7ce","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Pay bills","fields":{"contentOrder":["7gwsf4uxtftgjt841zgwydxeere","7j6rbt87htj83bbssod76iumsja","7fjacjgfxjfrf3psxc46wwsgqdo"],"icon":"🔌","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"aj4jyekqqssatjcq7r7chmy19ey","abthng7baedhhtrwsdodeuincqy":"true"}},"createAt":1640366942078,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"co6a88h6og3dm3kkub64kyb71jw","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Buy groceries","fields":{"contentOrder":["amd9sbzwrkpdspkisato6ajmzby","7r749xjm5pfnuib18sefxwezc4o","7zhat99shridtfntr97ek5j7yho","7imjjx8fazty8fcjzkns464nupy","7cbjz6bszwprnby56gfgzqehexc","76x8gh63upjdnm8uso3nja7gjqh","7z6ho1e3dibg6mki7jug84yxpja"],"icon":"🛒","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"afpy8s7i45frggprmfsqngsocqh"}},"createAt":1640365957059,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cr7gz7sempbfqpq7sign4jaeyxc","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Go for a walk","fields":{"contentOrder":["a6b44enuiwpgszm1wt6og1mshqa","aumtoywd8wjy7udm4ntcib4ckpo","75gpszxg6difjmf1j3f5edj3w7a"],"icon":"👣","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"an51dnkenmoog9cetapbc4uyt3y"}},"createAt":1640281433950,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cx7cki81xppd3pdgnyktwbgtzer","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Feed Fluffy","fields":{"contentOrder":["as5kdrix3ibd3jrnqzz94dcqqba"],"icon":"🐱","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"an51dnkenmoog9cetapbc4uyt3y"}},"createAt":1640281433850,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"czowhma7rnpgb3eczbqo3t7fijo","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Gardening","fields":{"contentOrder":[],"icon":"🌳","isTemplate":false,"properties":{"a9zf59u8x1rf4ywctpcqama7tio":"afpy8s7i45frggprmfsqngsocqh"}},"createAt":1640281433750,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vjq4piq89kbds5x5zq39zww7joo","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"List View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":280},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a9zf59u8x1rf4ywctpcqama7tio","abthng7baedhhtrwsdodeuincqy"]},"createAt":1641247999081,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vyeipq97iqbfjtd6fgcbxg6xbme","parentId":"bbn1888mprfrm5fjw9f1je9x3xo","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board View","fields":{"cardOrder":["co6a88h6og3dm3kkub64kyb71jw","c5xamko6rpibhje3bjreenon7ce","cr7gz7sempbfqpq7sign4jaeyxc","cx7cki81xppd3pdgnyktwbgtzer","czowhma7rnpgb3eczbqo3t7fijo"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"a9zf59u8x1rf4ywctpcqama7tio","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["an51dnkenmoog9cetapbc4uyt3y","afpy8s7i45frggprmfsqngsocqh","aj4jyekqqssatjcq7r7chmy19ey",""],"visiblePropertyIds":["a9zf59u8x1rf4ywctpcqama7tio"]},"createAt":1640281433698,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7fjacjgfxjfrf3psxc46wwsgqdo","parentId":"c5xamko6rpibhje3bjreenon7ce","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Utilities","fields":{"value":true},"createAt":1640367568655,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7gwsf4uxtftgjt841zgwydxeere","parentId":"c5xamko6rpibhje3bjreenon7ce","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Mobile phone","fields":{"value":true},"createAt":1640367517692,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7j6rbt87htj83bbssod76iumsja","parentId":"c5xamko6rpibhje3bjreenon7ce","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Internet","fields":{"value":true},"createAt":1640367560684,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"76x8gh63upjdnm8uso3nja7gjqh","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Cereal","fields":{"value":false},"createAt":1640366017886,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7cbjz6bszwprnby56gfgzqehexc","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Butter","fields":{"value":false},"createAt":1640365985683,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7imjjx8fazty8fcjzkns464nupy","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Bread","fields":{"value":false},"createAt":1640365983209,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7r749xjm5pfnuib18sefxwezc4o","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Milk","fields":{"value":false},"createAt":1640365978720,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7z6ho1e3dibg6mki7jug84yxpja","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Bananas","fields":{"value":false},"createAt":1640367364568,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7zhat99shridtfntr97ek5j7yho","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"Eggs","fields":{"value":false},"createAt":1640365980953,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"amd9sbzwrkpdspkisato6ajmzby","parentId":"co6a88h6og3dm3kkub64kyb71jw","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Grocery list","fields":{},"createAt":1640367228497,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"75gpszxg6difjmf1j3f5edj3w7a","parentId":"cr7gz7sempbfqpq7sign4jaeyxc","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"76fwrj36hptg6dywka4k5mt3sph.png"},"createAt":1640368278060,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a6b44enuiwpgszm1wt6og1mshqa","parentId":"cr7gz7sempbfqpq7sign4jaeyxc","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Goal\nWalk at least 10,000 steps every day.","fields":{},"createAt":1640367836067,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aumtoywd8wjy7udm4ntcib4ckpo","parentId":"cr7gz7sempbfqpq7sign4jaeyxc","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Route","fields":{},"createAt":1640368155600,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"as5kdrix3ibd3jrnqzz94dcqqba","parentId":"cx7cki81xppd3pdgnyktwbgtzer","rootId":"bbn1888mprfrm5fjw9f1je9x3xo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"","fields":{},"createAt":1640368933239,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{"type":"block","data":{"id":"bc41mwxg9ybb69pn9j5zna6d36c","parentId":"","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Project Tasks (NEW)","fields":{"cardProperties":[{"id":"a972dc7a-5f4c-45d2-8044-8c28c69717f1","name":"Status","options":[{"color":"propColorBlue","id":"ayz81h9f3dwp7rzzbdebesc7ute","value":"Not Started"},{"color":"propColorYellow","id":"ar6b8m3jxr3asyxhr8iucdbo6yc","value":"In Progress"},{"color":"propColorRed","id":"afi4o5nhnqc3smtzs1hs3ij34dh","value":"Blocked"},{"color":"propColorGreen","id":"adeo5xuwne3qjue83fcozekz8ko","value":"Completed 🙌"},{"color":"propColorBrown","id":"ahpyxfnnrzynsw3im1psxpkgtpe","value":"Archived"}],"type":"select"},{"id":"d3d682bf-e074-49d9-8df5-7320921c2d23","name":"Priority","options":[{"color":"propColorRed","id":"d3bfb50f-f569-4bad-8a3a-dd15c3f60101","value":"1. High 🔥"},{"color":"propColorYellow","id":"87f59784-b859-4c24-8ebe-17c766e081dd","value":"2. Medium"},{"color":"propColorGray","id":"98a57627-0f76-471d-850d-91f3ed9fd213","value":"3. Low"}],"type":"select"},{"id":"axkhqa4jxr3jcqe4k87g8bhmary","name":"Assignee","options":[],"type":"person"},{"id":"a8daz81s4xjgke1ww6cwik5w7ye","name":"Estimated Hours","options":[],"type":"number"},{"id":"a3zsw7xs8sxy7atj8b6totp3mby","name":"Due Date","options":[],"type":"date"},{"id":"a7gdnz8ff8iyuqmzddjgmgo9ery","name":"Created By","options":[],"type":"createdBy"},{"id":"2a5da320-735c-4093-8787-f56e15cdfeed","name":"Date Created","options":[],"type":"createdTime"}],"description":"Use this template to stay on top of your project tasks and progress.","icon":"🎯","isTemplate":false,"showDescription":true},"createAt":1640281242611,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c68gyx34srjgjxmrs1z8pj7nbce","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Identify dependencies","fields":{"contentOrder":["akqkae666a7bnbgib4ykbexjjey","7b1h5q66pkig4mp948z635dejxy","aepujbmb347ye9j7uikbk3oajqh","76q9tmzey4byqdpimsdxeg1gx3h","79qbaadiuwjgujnz9tgqmmkaaqo","7msorzdb7r3rk3qjncmdxhpqz5o","7izro8efd1irwpepfph4uz56bgh"],"icon":"🔗","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"16","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ayz81h9f3dwp7rzzbdebesc7ute","d3d682bf-e074-49d9-8df5-7320921c2d23":"98a57627-0f76-471d-850d-91f3ed9fd213"}},"createAt":1640364405240,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c6w7rxrootfdw7j4fsftc5gsyoo","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Define project scope","fields":{"contentOrder":["ags74nq3isiywmmkkg8h4tbxcfh","7q7rkcbuqwfffjgrk57yjkydnry","a66dncm7qppd4tjo9886d5bbsaa","7jy54jqerhbnj7r4efpuk3g4cda","716fy9hw4p38a5mf8rq5ap6txoo","7opf3hssh6pn9zyy6toh53r49iw","7g1qskptj9i8gimg1aynyqtnwka"],"icon":"🔬","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"32","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ar6b8m3jxr3asyxhr8iucdbo6yc","d3d682bf-e074-49d9-8df5-7320921c2d23":"87f59784-b859-4c24-8ebe-17c766e081dd"}},"createAt":1640364532461,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cdwqxf4b3utbbxdrgbwtmk9y9eo","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Requirements sign-off","fields":{"contentOrder":["aags5e9sbbfnqtrtf39hoopbxme","7kriyyuos4pgg8k6t8fkcsa7bde","adw7awe3ucp8g781dfq7yw6kfur","7xk7xg6yonbn88fpkihigzn8whr","7b9uyiog56jr1zgonbutxfd7w3c","7r3ua3e7w3jrmpqdngzqs74i1go","76hsxtocpnbnrijxqcfccfkyo1e"],"icon":"🖋️","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"8","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ayz81h9f3dwp7rzzbdebesc7ute","d3d682bf-e074-49d9-8df5-7320921c2d23":"d3bfb50f-f569-4bad-8a3a-dd15c3f60101"}},"createAt":1640281242441,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cfk8kwmuhcfd8m8qicz5aqw4mar","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Project budget approval","fields":{"contentOrder":["a9h4kfaurrprepefrw95i1raoxr","7btyuex8nji8jxn9yieaxgwoe6h","a34hy46bu8bngxcxpz9woui4afa","7ekrgkgq67fdofn9gskpe19bkrc","7ygi1kq3683ya5ydfttuc5rhasr","7qmjyww91rj8a38dsgu5b5wu7hr","7qmmpepfm4byqjqo9m16yp7m3no"],"icon":"💵","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"16","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ayz81h9f3dwp7rzzbdebesc7ute","d3d682bf-e074-49d9-8df5-7320921c2d23":"d3bfb50f-f569-4bad-8a3a-dd15c3f60101"}},"createAt":1640281242677,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ckcntrrmcjbywpciau57gw5suoo","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Conduct market analysis","fields":{"contentOrder":["a6gowxxpgijgip8qzrsp5rmjwqy","771bq4ja3ejfwbgaq78cdpgmjih","asdoj8ffhcirh3x3iys3joeox9o","7k975b49ni7yrfn3nqg7q4x4wde","7e9aj57zouidozb8sf8e1wybywe","71dm4jiu43byubx7pukjiy19pay","719y6x4tkiigd9nwarn1e6ek7ic"],"icon":"📈","isTemplate":false,"properties":{"a8daz81s4xjgke1ww6cwik5w7ye":"40","a972dc7a-5f4c-45d2-8044-8c28c69717f1":"ar6b8m3jxr3asyxhr8iucdbo6yc","d3d682bf-e074-49d9-8df5-7320921c2d23":"87f59784-b859-4c24-8ebe-17c766e081dd"}},"createAt":1640281242851,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vcuoise4b8jn1ffzujfuacymmmr","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Project Priorities","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"d3d682bf-e074-49d9-8df5-7320921c2d23","hiddenOptionIds":[],"kanbanCalculations":{"":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"87f59784-b859-4c24-8ebe-17c766e081dd":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"98a57627-0f76-471d-850d-91f3ed9fd213":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"d3bfb50f-f569-4bad-8a3a-dd15c3f60101":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"}},"sortOptions":[],"viewType":"board","visibleOptionIds":["d3bfb50f-f569-4bad-8a3a-dd15c3f60101","87f59784-b859-4c24-8ebe-17c766e081dd","98a57627-0f76-471d-850d-91f3ed9fd213",""],"visiblePropertyIds":["a972dc7a-5f4c-45d2-8044-8c28c69717f1","a8daz81s4xjgke1ww6cwik5w7ye"]},"createAt":1640281242551,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vey61xzc6u38ptnpjqaik6ap91e","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Progress Tracker","fields":{"cardOrder":["cfk8kwmuhcfd8m8qicz5aqw4mar","cdwqxf4b3utbbxdrgbwtmk9y9eo","c68gyx34srjgjxmrs1z8pj7nbce","ckcntrrmcjbywpciau57gw5suoo","c6w7rxrootfdw7j4fsftc5gsyoo","coxnjt3ro1in19dd1e3awdt338r"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"a972dc7a-5f4c-45d2-8044-8c28c69717f1","hiddenOptionIds":[],"kanbanCalculations":{"":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"adeo5xuwne3qjue83fcozekz8ko":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"afi4o5nhnqc3smtzs1hs3ij34dh":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"ahpyxfnnrzynsw3im1psxpkgtpe":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"ar6b8m3jxr3asyxhr8iucdbo6yc":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"},"ayz81h9f3dwp7rzzbdebesc7ute":{"calculation":"sum","propertyId":"a8daz81s4xjgke1ww6cwik5w7ye"}},"sortOptions":[],"viewType":"board","visibleOptionIds":["ayz81h9f3dwp7rzzbdebesc7ute","ar6b8m3jxr3asyxhr8iucdbo6yc","afi4o5nhnqc3smtzs1hs3ij34dh","adeo5xuwne3qjue83fcozekz8ko","ahpyxfnnrzynsw3im1psxpkgtpe",""],"visiblePropertyIds":["d3d682bf-e074-49d9-8df5-7320921c2d23","a8daz81s4xjgke1ww6cwik5w7ye"]},"createAt":1640281242788,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vfztxwjnegbdh38nfccu3bq1auc","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Task Overview","fields":{"cardOrder":["c6w7rxrootfdw7j4fsftc5gsyoo","ckcntrrmcjbywpciau57gw5suoo","c68gyx34srjgjxmrs1z8pj7nbce","cfk8kwmuhcfd8m8qicz5aqw4mar","cdwqxf4b3utbbxdrgbwtmk9y9eo","cz8p8gofakfby8kzz83j97db8ph","ce1jm5q5i54enhuu4h3kkay1hcc"],"collapsedOptionIds":[],"columnCalculations":{"a8daz81s4xjgke1ww6cwik5w7ye":"sum"},"columnWidths":{"2a5da320-735c-4093-8787-f56e15cdfeed":196,"__title":280,"a8daz81s4xjgke1ww6cwik5w7ye":139,"a972dc7a-5f4c-45d2-8044-8c28c69717f1":141,"d3d682bf-e074-49d9-8df5-7320921c2d23":110},"defaultTemplateId":"czw9es1e89fdpjr7cqptr1xq7qh","filter":{"filters":[],"operation":"and"},"groupById":"","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a972dc7a-5f4c-45d2-8044-8c28c69717f1","d3d682bf-e074-49d9-8df5-7320921c2d23","2a5da320-735c-4093-8787-f56e15cdfeed","a3zsw7xs8sxy7atj8b6totp3mby","axkhqa4jxr3jcqe4k87g8bhmary","a7gdnz8ff8iyuqmzddjgmgo9ery","a8daz81s4xjgke1ww6cwik5w7ye"]},"createAt":1640281242734,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vi49i1138jpnbiqhyd81beme9zy","parentId":"bc41mwxg9ybb69pn9j5zna6d36c","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Task Calendar","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"a3zsw7xs8sxy7atj8b6totp3mby","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1640361708030,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"76q9tmzey4byqdpimsdxeg1gx3h","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247437494,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"79qbaadiuwjgujnz9tgqmmkaaqo","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247440946,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7b1h5q66pkig4mp948z635dejxy","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247334696,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7izro8efd1irwpepfph4uz56bgh","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247447937,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7msorzdb7r3rk3qjncmdxhpqz5o","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247445214,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aepujbmb347ye9j7uikbk3oajqh","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247378401,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"akqkae666a7bnbgib4ykbexjjey","parentId":"c68gyx34srjgjxmrs1z8pj7nbce","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247332262,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"716fy9hw4p38a5mf8rq5ap6txoo","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247170396,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7g1qskptj9i8gimg1aynyqtnwka","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247182126,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7jy54jqerhbnj7r4efpuk3g4cda","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247156773,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7opf3hssh6pn9zyy6toh53r49iw","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247176917,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7q7rkcbuqwfffjgrk57yjkydnry","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247131586,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a66dncm7qppd4tjo9886d5bbsaa","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247135038,"updateAt":1643788318632,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ags74nq3isiywmmkkg8h4tbxcfh","parentId":"c6w7rxrootfdw7j4fsftc5gsyoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247112211,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"76hsxtocpnbnrijxqcfccfkyo1e","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247486848,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7b9uyiog56jr1zgonbutxfd7w3c","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247480724,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7kriyyuos4pgg8k6t8fkcsa7bde","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247352753,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7r3ua3e7w3jrmpqdngzqs74i1go","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247483695,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7xk7xg6yonbn88fpkihigzn8whr","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247478297,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aags5e9sbbfnqtrtf39hoopbxme","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247350239,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"adw7awe3ucp8g781dfq7yw6kfur","parentId":"cdwqxf4b3utbbxdrgbwtmk9y9eo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247399161,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7btyuex8nji8jxn9yieaxgwoe6h","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247342345,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7ekrgkgq67fdofn9gskpe19bkrc","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247459230,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7qmjyww91rj8a38dsgu5b5wu7hr","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247464903,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7qmmpepfm4byqjqo9m16yp7m3no","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247468228,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7ygi1kq3683ya5ydfttuc5rhasr","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247461754,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a34hy46bu8bngxcxpz9woui4afa","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247389505,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a9h4kfaurrprepefrw95i1raoxr","parentId":"cfk8kwmuhcfd8m8qicz5aqw4mar","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247339781,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"719y6x4tkiigd9nwarn1e6ek7ic","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247428974,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"71dm4jiu43byubx7pukjiy19pay","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247425545,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"771bq4ja3ejfwbgaq78cdpgmjih","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247327922,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7e9aj57zouidozb8sf8e1wybywe","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247421647,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7k975b49ni7yrfn3nqg7q4x4wde","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247417179,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a6gowxxpgijgip8qzrsp5rmjwqy","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247325247,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"asdoj8ffhcirh3x3iys3joeox9o","parentId":"ckcntrrmcjbywpciau57gw5suoo","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247365651,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"73a715h3xkiye9jj9px3daujgpa","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 3]","fields":{"value":false},"createAt":1641247243580,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"75afimcsuqby6xxq39wiae9obme","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 2]","fields":{"value":false},"createAt":1641247239940,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7dodh1pgw73yq78pgtmk3ckc9fr","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"divider","title":"","fields":{},"createAt":1641247212754,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7ttgtruigcbfzdmxkhmzt6kp6dh","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"[Subtask 1]","fields":{"value":false},"createAt":1641247226415,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7u7mmiit57b8i8gsp6mc6x7h9he","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"checkbox","title":"...","fields":{"value":false},"createAt":1641247248372,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"adxx8y691qf8btg7w8mx6x78w9y","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*","fields":{},"createAt":1641247210152,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"afatxnq346jbcin9iisryo38grr","parentId":"czw9es1e89fdpjr7cqptr1xq7qh","rootId":"bc41mwxg9ybb69pn9j5zna6d36c","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Checklist","fields":{},"createAt":1641247215942,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
@@ -0,0 +1,12 @@
|
||||
{"type":"board","data":{"id":"bcm39o11e4ib8tye8mt6iyuec9o","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Company Goals \u0026 OKRs","description":"Use this template to plan your company goals and OKRs more efficiently.","icon":"⛳","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"a6amddgmrzakw66cidqzgk6p4ge","name":"Objective","options":[{"color":"propColorGreen","id":"auw3afh3kfhrfgmjr8muiz137jy","value":"Grow Revenue"},{"color":"propColorOrange","id":"apqfjst8massbjjhpcsjs3y1yqa","value":"Delight Customers"},{"color":"propColorPurple","id":"ao9b5pxyt7tkgdohzh9oaustdhr","value":"Drive Product Adoption"}],"type":"select"},{"id":"a17ryhi1jfsboxkwkztwawhmsxe","name":"Status","options":[{"color":"propColorGray","id":"a6robxx81diugpjq5jkezz3j1fo","value":"Not Started"},{"color":"propColorBlue","id":"a8nukezwwmknqwjsygg7eaxs9te","value":"In Progress"},{"color":"propColorYellow","id":"apnt1f7na9rzgk1rt49keg7xbiy","value":"At Risk"},{"color":"propColorRed","id":"axbz3m1amss335wzwf9s7pqjzxr","value":"Missed"},{"color":"propColorGreen","id":"abzfwnn6rmtfzyq5hg8uqmpsncy","value":"Complete 🙌"}],"type":"select"},{"id":"azzbawji5bksj69sekcs4srm1ky","name":"Department","options":[{"color":"propColorBrown","id":"aw5i7hmpadn6mbwbz955ubarhme","value":"Engineering"},{"color":"propColorBlue","id":"afkxpcjqjypu7hhar7banxau91h","value":"Product"},{"color":"propColorOrange","id":"aehoa17cz18rqnrf75g7dwhphpr","value":"Marketing"},{"color":"propColorGreen","id":"agrfeaoj7d8p5ianw5iaf3191ae","value":"Sales"},{"color":"propColorYellow","id":"agm9p6gcq15ueuzqq3wd4be39wy","value":"Support"},{"color":"propColorPink","id":"aucop7kw6xwodcix6zzojhxih6r","value":"Design"},{"color":"propColorPurple","id":"afust91f3g8ht368mkn5x9tgf1o","value":"Finance"},{"color":"propColorGray","id":"acocxxwjurud1jixhp7nowdig7y","value":"Human Resources"}],"type":"select"},{"id":"adp5ft3kgz7r5iqq3tnwg551der","name":"Priority","options":[{"color":"propColorRed","id":"a8zg3rjtf4swh7smsjxpsn743rh","value":"P1 🔥"},{"color":"propColorYellow","id":"as555ipyzopjjpfb5rjtssecw5e","value":"P2"},{"color":"propColorGray","id":"a1ts3ftyr8nocsicui98c89uxjy","value":"P3"}],"type":"select"},{"id":"aqxyzkdrs4egqf7yk866ixkaojc","name":"Quarter","options":[{"color":"propColorBlue","id":"ahfbn1jsmhydym33ygxwg5jt3kh","value":"Q1"},{"color":"propColorBrown","id":"awfu37js3fomfkkczm1zppac57a","value":"Q2"},{"color":"propColorGreen","id":"anruuoyez51r3yjxuoc8zoqnwaw","value":"Q3"},{"color":"propColorPurple","id":"acb6dqqs6yson7bbzx6jk9bghjh","value":"Q4"}],"type":"select"},{"id":"adu6mebzpibq6mgcswk69xxmnqe","name":"Due Date","options":[],"type":"date"},{"id":"asope3bddhm4gpsng5cfu4hf6rh","name":"Assignee","options":[],"type":"multiPerson"},{"id":"ajwxp866f9obs1kutfwaa5ru7fe","name":"Target","options":[],"type":"number"},{"id":"azqnyswk6s1boiwuthscm78qwuo","name":"Actual","options":[],"type":"number"},{"id":"ahz3fmjnaguec8hce7xq3h5cjdr","name":"Completion (%)","options":[],"type":"text"},{"id":"a17bfcgnzmkwhziwa4tr38kiw5r","name":"Note","options":[],"type":"text"}],"createAt":1667430124226,"updateAt":1667431508571,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"vangk4cpd5fgpbr7635tx6oxg7c","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Quarter","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":452,"a17ryhi1jfsboxkwkztwawhmsxe":148,"a6amddgmrzakw66cidqzgk6p4ge":230,"azzbawji5bksj69sekcs4srm1ky":142},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"aqxyzkdrs4egqf7yk866ixkaojc","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a6amddgmrzakw66cidqzgk6p4ge","a17ryhi1jfsboxkwkztwawhmsxe","azzbawji5bksj69sekcs4srm1ky","adp5ft3kgz7r5iqq3tnwg551der","aqxyzkdrs4egqf7yk866ixkaojc","adu6mebzpibq6mgcswk69xxmnqe","asope3bddhm4gpsng5cfu4hf6rh","ajwxp866f9obs1kutfwaa5ru7fe","azqnyswk6s1boiwuthscm78qwuo","ahz3fmjnaguec8hce7xq3h5cjdr","a17bfcgnzmkwhziwa4tr38kiw5r"]},"createAt":1667431291178,"updateAt":1667431333436,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"vr1jnxkxi8pf9z83fhr4qbsbxao","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Objectives","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":387,"a17ryhi1jfsboxkwkztwawhmsxe":134,"a6amddgmrzakw66cidqzgk6p4ge":183,"aqxyzkdrs4egqf7yk866ixkaojc":100},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"a6amddgmrzakw66cidqzgk6p4ge","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["a6amddgmrzakw66cidqzgk6p4ge","a17ryhi1jfsboxkwkztwawhmsxe","azzbawji5bksj69sekcs4srm1ky","adp5ft3kgz7r5iqq3tnwg551der","aqxyzkdrs4egqf7yk866ixkaojc","adu6mebzpibq6mgcswk69xxmnqe","asope3bddhm4gpsng5cfu4hf6rh","ajwxp866f9obs1kutfwaa5ru7fe","azqnyswk6s1boiwuthscm78qwuo","ahz3fmjnaguec8hce7xq3h5cjdr","a17bfcgnzmkwhziwa4tr38kiw5r"]},"createAt":1667431221976,"updateAt":1667431420460,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"c3m6mgymw978wjecydz16io868h","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Improve customer NPS score","fields":{"contentOrder":[],"icon":"💯","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a8nukezwwmknqwjsygg7eaxs9te","a6amddgmrzakw66cidqzgk6p4ge":"apqfjst8massbjjhpcsjs3y1yqa","adp5ft3kgz7r5iqq3tnwg551der":"as555ipyzopjjpfb5rjtssecw5e","ahz3fmjnaguec8hce7xq3h5cjdr":"82%","ajwxp866f9obs1kutfwaa5ru7fe":"8.5","aqxyzkdrs4egqf7yk866ixkaojc":"anruuoyez51r3yjxuoc8zoqnwaw","azqnyswk6s1boiwuthscm78qwuo":"7","azzbawji5bksj69sekcs4srm1ky":"agm9p6gcq15ueuzqq3wd4be39wy"}},"createAt":1667430924551,"updateAt":1667430962900,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"ce9u86wofitrb5ns4qp5w1ij1nh","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Generate more Marketing Qualified Leads (MQLs)","fields":{"contentOrder":[],"icon":"🛣️","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a8nukezwwmknqwjsygg7eaxs9te","a6amddgmrzakw66cidqzgk6p4ge":"auw3afh3kfhrfgmjr8muiz137jy","adp5ft3kgz7r5iqq3tnwg551der":"as555ipyzopjjpfb5rjtssecw5e","ahz3fmjnaguec8hce7xq3h5cjdr":"65%","ajwxp866f9obs1kutfwaa5ru7fe":"100","aqxyzkdrs4egqf7yk866ixkaojc":"ahfbn1jsmhydym33ygxwg5jt3kh","azqnyswk6s1boiwuthscm78qwuo":"65","azzbawji5bksj69sekcs4srm1ky":"aehoa17cz18rqnrf75g7dwhphpr"}},"createAt":1667430791375,"updateAt":1667430832892,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"cjkscjjex6fg8i8aa3umxof9wfc","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Increase customer retention","fields":{"contentOrder":[],"icon":"😀","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a8nukezwwmknqwjsygg7eaxs9te","a6amddgmrzakw66cidqzgk6p4ge":"apqfjst8massbjjhpcsjs3y1yqa","adp5ft3kgz7r5iqq3tnwg551der":"a8zg3rjtf4swh7smsjxpsn743rh","ahz3fmjnaguec8hce7xq3h5cjdr":"66%","ajwxp866f9obs1kutfwaa5ru7fe":"90% customer retention rate","aqxyzkdrs4egqf7yk866ixkaojc":"acb6dqqs6yson7bbzx6jk9bghjh","azqnyswk6s1boiwuthscm78qwuo":"60%","azzbawji5bksj69sekcs4srm1ky":"afkxpcjqjypu7hhar7banxau91h"}},"createAt":1667430973987,"updateAt":1667431007817,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"ckxdhpf5bhf8i7n13fgbs4155ec","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Hit company global sales target","fields":{"contentOrder":[],"icon":"💰","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a6robxx81diugpjq5jkezz3j1fo","a6amddgmrzakw66cidqzgk6p4ge":"auw3afh3kfhrfgmjr8muiz137jy","adp5ft3kgz7r5iqq3tnwg551der":"a8zg3rjtf4swh7smsjxpsn743rh","ahz3fmjnaguec8hce7xq3h5cjdr":"15%","ajwxp866f9obs1kutfwaa5ru7fe":"50MM","aqxyzkdrs4egqf7yk866ixkaojc":"awfu37js3fomfkkczm1zppac57a","azqnyswk6s1boiwuthscm78qwuo":"7.5MM","azzbawji5bksj69sekcs4srm1ky":"agrfeaoj7d8p5ianw5iaf3191ae"}},"createAt":1667430875599,"updateAt":1667430909496,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"cn1x4niym7tnpjg61jf1su67wcr","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Increase user signups by 30%","fields":{"contentOrder":[],"icon":"💳","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"a6robxx81diugpjq5jkezz3j1fo","a6amddgmrzakw66cidqzgk6p4ge":"ao9b5pxyt7tkgdohzh9oaustdhr","adp5ft3kgz7r5iqq3tnwg551der":"as555ipyzopjjpfb5rjtssecw5e","ahz3fmjnaguec8hce7xq3h5cjdr":"0%","ajwxp866f9obs1kutfwaa5ru7fe":"1,000","aqxyzkdrs4egqf7yk866ixkaojc":"acb6dqqs6yson7bbzx6jk9bghjh","azqnyswk6s1boiwuthscm78qwuo":"0","azzbawji5bksj69sekcs4srm1ky":"afkxpcjqjypu7hhar7banxau91h"}},"createAt":1667431085923,"updateAt":1667431132757,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"cpa534b5natgmunis8u1ixb55pw","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Add 10 new customers in the EU","fields":{"contentOrder":[],"icon":"🌍","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"apnt1f7na9rzgk1rt49keg7xbiy","a6amddgmrzakw66cidqzgk6p4ge":"auw3afh3kfhrfgmjr8muiz137jy","adp5ft3kgz7r5iqq3tnwg551der":"a1ts3ftyr8nocsicui98c89uxjy","ahz3fmjnaguec8hce7xq3h5cjdr":"30%","ajwxp866f9obs1kutfwaa5ru7fe":"10","aqxyzkdrs4egqf7yk866ixkaojc":"acb6dqqs6yson7bbzx6jk9bghjh","azqnyswk6s1boiwuthscm78qwuo":"3","azzbawji5bksj69sekcs4srm1ky":"agrfeaoj7d8p5ianw5iaf3191ae"}},"createAt":1667430190782,"updateAt":1667430844747,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"cq4krpnzqqfne3khfyhnn3c6r5r","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Launch 3 key features","fields":{"contentOrder":[],"icon":"🚀","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"apnt1f7na9rzgk1rt49keg7xbiy","a6amddgmrzakw66cidqzgk6p4ge":"ao9b5pxyt7tkgdohzh9oaustdhr","adp5ft3kgz7r5iqq3tnwg551der":"a8zg3rjtf4swh7smsjxpsn743rh","ahz3fmjnaguec8hce7xq3h5cjdr":"33%","ajwxp866f9obs1kutfwaa5ru7fe":"3","aqxyzkdrs4egqf7yk866ixkaojc":"anruuoyez51r3yjxuoc8zoqnwaw","azqnyswk6s1boiwuthscm78qwuo":"1","azzbawji5bksj69sekcs4srm1ky":"aw5i7hmpadn6mbwbz955ubarhme"}},"createAt":1667431144882,"updateAt":1667431177540,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"cugiq6j98utg1zdekbpjpufo51y","parentId":"bcm39o11e4ib8tye8mt6iyuec9o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Reduce bug backlog by 50%","fields":{"contentOrder":[],"icon":"🐞","isTemplate":false,"properties":{"a17ryhi1jfsboxkwkztwawhmsxe":"abzfwnn6rmtfzyq5hg8uqmpsncy","a6amddgmrzakw66cidqzgk6p4ge":"apqfjst8massbjjhpcsjs3y1yqa","adp5ft3kgz7r5iqq3tnwg551der":"a1ts3ftyr8nocsicui98c89uxjy","ahz3fmjnaguec8hce7xq3h5cjdr":"100%","ajwxp866f9obs1kutfwaa5ru7fe":"75","aqxyzkdrs4egqf7yk866ixkaojc":"awfu37js3fomfkkczm1zppac57a","azqnyswk6s1boiwuthscm78qwuo":"75","azzbawji5bksj69sekcs4srm1ky":"aw5i7hmpadn6mbwbz955ubarhme"}},"createAt":1667431018282,"updateAt":1667431070950,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
{"type":"block","data":{"id":"vx4ng6gtakbntt8k98znkzszc1a","parentId":"bm4ubx56krp4zwyfcqh7nxiigbr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Departments","fields":{"cardOrder":["cpa534b5natgmunis8u1ixb55pw"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"azzbawji5bksj69sekcs4srm1ky","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["aw5i7hmpadn6mbwbz955ubarhme","afkxpcjqjypu7hhar7banxau91h","aehoa17cz18rqnrf75g7dwhphpr","agrfeaoj7d8p5ianw5iaf3191ae","agm9p6gcq15ueuzqq3wd4be39wy","aucop7kw6xwodcix6zzojhxih6r","afust91f3g8ht368mkn5x9tgf1o","acocxxwjurud1jixhp7nowdig7y"],"visiblePropertyIds":[]},"createAt":1667430124232,"updateAt":1667431286030,"deleteAt":0,"boardId":"bcm39o11e4ib8tye8mt6iyuec9o"}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"type":"block","data":{"id":"bd65qbzuqupfztpg31dgwgwm5ga","parentId":"","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Personal Goals (NEW)","fields":{"cardProperties":[{"id":"af6fcbb8-ca56-4b73-83eb-37437b9a667d","name":"Status","options":[{"color":"propColorRed","id":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","value":"To Do"},{"color":"propColorYellow","id":"77c539af-309c-4db1-8329-d20ef7e9eacd","value":"Doing"},{"color":"propColorGreen","id":"98bdea27-0cce-4cde-8dc6-212add36e63a","value":"Done 🙌"}],"type":"select"},{"id":"d9725d14-d5a8-48e5-8de1-6f8c004a9680","name":"Category","options":[{"color":"propColorPurple","id":"3245a32d-f688-463b-87f4-8e7142c1b397","value":"Life Skills"},{"color":"propColorGreen","id":"80be816c-fc7a-4928-8489-8b02180f4954","value":"Finance"},{"color":"propColorOrange","id":"ffb3f951-b47f-413b-8f1d-238666728008","value":"Health"}],"type":"select"},{"id":"d6b1249b-bc18-45fc-889e-bec48fce80ef","name":"Target","options":[{"color":"propColorBlue","id":"9a090e33-b110-4268-8909-132c5002c90e","value":"Q1"},{"color":"propColorBrown","id":"0a82977f-52bf-457b-841b-e2b7f76fb525","value":"Q2"},{"color":"propColorGreen","id":"6e7139e4-5358-46bb-8c01-7b029a57b80a","value":"Q3"},{"color":"propColorPurple","id":"d5371c63-66bf-4468-8738-c4dc4bea4843","value":"Q4"}],"type":"select"},{"id":"ajy6xbebzopojaenbnmfpgtdwso","name":"Due Date","options":[],"type":"date"}],"description":"Use this template to set and accomplish new personal goals.","icon":"⛰️","isTemplate":false,"showDescription":true},"createAt":1641246775089,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c76haqhzin78q5dkfko7kwhbjjh","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Start a daily journal","fields":{"contentOrder":[],"icon":"✍️","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","d6b1249b-bc18-45fc-889e-bec48fce80ef":"0a82977f-52bf-457b-841b-e2b7f76fb525","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"3245a32d-f688-463b-87f4-8e7142c1b397"}},"createAt":1641246774828,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ca3byfg7iq3g8zjpg1t8hwa6ekh","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Run 3 times a week","fields":{"contentOrder":[],"icon":"🏃","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","d6b1249b-bc18-45fc-889e-bec48fce80ef":"6e7139e4-5358-46bb-8c01-7b029a57b80a","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"ffb3f951-b47f-413b-8f1d-238666728008"}},"createAt":1641246775039,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ckng5n1ag5f8m5gfdifn7ijof9y","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Learn to paint","fields":{"contentOrder":[],"icon":"🎨","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"77c539af-309c-4db1-8329-d20ef7e9eacd","d6b1249b-bc18-45fc-889e-bec48fce80ef":"9a090e33-b110-4268-8909-132c5002c90e","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"3245a32d-f688-463b-87f4-8e7142c1b397"}},"createAt":1641246774928,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cw9zofoi6dj8x7x8r6ypebpwpuc","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Open retirement account","fields":{"contentOrder":[],"icon":"🏦","isTemplate":false,"properties":{"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","d6b1249b-bc18-45fc-889e-bec48fce80ef":"0a82977f-52bf-457b-841b-e2b7f76fb525","d9725d14-d5a8-48e5-8de1-6f8c004a9680":"80be816c-fc7a-4928-8489-8b02180f4954"}},"createAt":1641246774987,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"v9sj7oekk1jr1pemtf9rps7fate","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"af6fcbb8-ca56-4b73-83eb-37437b9a667d","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["bf52bfe6-ac4c-4948-821f-83eaa1c7b04a","77c539af-309c-4db1-8329-d20ef7e9eacd","98bdea27-0cce-4cde-8dc6-212add36e63a",""],"visiblePropertyIds":["d9725d14-d5a8-48e5-8de1-6f8c004a9680","d6b1249b-bc18-45fc-889e-bec48fce80ef"]},"createAt":1641246774878,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vrpmc8r6nj7fcmdkp18cpcekzco","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Calendar View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"ajy6xbebzopojaenbnmfpgtdwso","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641247726340,"updateAt":1643788318630,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vw9mbn66j97dwb8jhqiq7zuum5e","parentId":"bd65qbzuqupfztpg31dgwgwm5ga","rootId":"bd65qbzuqupfztpg31dgwgwm5ga","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Date","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"d6b1249b-bc18-45fc-889e-bec48fce80ef","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["9a090e33-b110-4268-8909-132c5002c90e","0a82977f-52bf-457b-841b-e2b7f76fb525","6e7139e4-5358-46bb-8c01-7b029a57b80a","d5371c63-66bf-4468-8738-c4dc4bea4843",""],"visiblePropertyIds":["d9725d14-d5a8-48e5-8de1-6f8c004a9680"]},"createAt":1641246775139,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{"type":"board","data":{"id":"bgi1yqiis8t8xdqxgnet8ebutky","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Sprint Planner ","description":"Use this template to plan your sprints and manage your releases more efficiently.","icon":"🗓️","showDescription":true,"isTemplate":false,"templateVersion":4,"properties":{},"cardProperties":[{"id":"50117d52-bcc7-4750-82aa-831a351c44a0","name":"Status","options":[{"color":"propColorGray","id":"aft5bzo7h9aspqgrx3jpy5tzrer","value":"Not Started"},{"color":"propColorOrange","id":"abrfos7e7eczk9rqw6y5abadm1y","value":"Next Up"},{"color":"propColorBlue","id":"ax8wzbka5ahs3zziji3pp4qp9mc","value":"In Progress"},{"color":"propColorYellow","id":"atabdfbdmjh83136d5e5oysxybw","value":"In Review"},{"color":"propColorPink","id":"ace1bzypd586kkyhcht5qqd9eca","value":"Approved"},{"color":"propColorRed","id":"aay656c9m1hzwxc9ch5ftymh3nw","value":"Blocked"},{"color":"propColorGreen","id":"a6ghze4iy441qhsh3eijnc8hwze","value":"Complete 🙌"}],"type":"select"},{"id":"20717ad3-5741-4416-83f1-6f133fff3d11","name":"Type","options":[{"color":"propColorYellow","id":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","value":"Epic ⛰"},{"color":"propColorGray","id":"a5yxq8rbubrpnoommfwqmty138h","value":"Feature 🏗"},{"color":"propColorOrange","id":"apht1nt5ryukdmxkh6fkfn6rgoy","value":"User Story 📖"},{"color":"propColorGreen","id":"aiycbuo3dr5k4xxbfr7coem8ono","value":"Task ⛏"},{"color":"propColorRed","id":"aomnawq4551cbbzha9gxnmb3z5w","value":"Bug 🐞"}],"type":"select"},{"id":"60985f46-3e41-486e-8213-2b987440ea1c","name":"Sprint","options":[{"color":"propColorBrown","id":"c01676ca-babf-4534-8be5-cce2287daa6c","value":"Sprint 1"},{"color":"propColorPurple","id":"ed4a5340-460d-461b-8838-2c56e8ee59fe","value":"Sprint 2"},{"color":"propColorBlue","id":"14892380-1a32-42dd-8034-a0cea32bc7e6","value":"Sprint 3"}],"type":"select"},{"id":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","name":"Priority","options":[{"color":"propColorRed","id":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9","value":"P1 🔥"},{"color":"propColorYellow","id":"e6a7f297-4440-4783-8ab3-3af5ba62ca11","value":"P2"},{"color":"propColorGray","id":"c62172ea-5da7-4dec-8186-37267d8ee9a7","value":"P3"}],"type":"select"},{"id":"aphg37f7zbpuc3bhwhp19s1ribh","name":"Assignee","options":[],"type":"multiPerson"},{"id":"a4378omyhmgj3bex13sj4wbpfiy","name":"Due Date","options":[],"type":"date"},{"id":"ai7ajsdk14w7x5s8up3dwir77te","name":"Story Points","options":[],"type":"number"},{"id":"a1g6i613dpe9oryeo71ex3c86hy","name":"Design Link","options":[],"type":"url"},{"id":"aeomttrbhhsi8bph31jn84sto6h","name":"Created Time","options":[],"type":"createdTime"},{"id":"ax9f8so418s6s65hi5ympd93i6a","name":"Created By","options":[],"type":"createdBy"}],"createAt":1657660691136,"updateAt":1667496289175,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"vdusd7mmojjy7dqtcews89kbawe","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Sprint","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{"ai7ajsdk14w7x5s8up3dwir77te":"count"},"columnWidths":{"20717ad3-5741-4416-83f1-6f133fff3d11":128,"50117d52-bcc7-4750-82aa-831a351c44a0":126,"__title":280,"a1g6i613dpe9oryeo71ex3c86hy":159,"aeomttrbhhsi8bph31jn84sto6h":141,"ax9f8so418s6s65hi5ympd93i6a":183,"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":100},"defaultTemplateId":"c9pwabyseiibumq71b9ykxsotqe","filter":{"filters":[],"operation":"and"},"groupById":"60985f46-3e41-486e-8213-2b987440ea1c","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["50117d52-bcc7-4750-82aa-831a351c44a0","20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","aphg37f7zbpuc3bhwhp19s1ribh","a4378omyhmgj3bex13sj4wbpfiy","ai7ajsdk14w7x5s8up3dwir77te","a1g6i613dpe9oryeo71ex3c86hy","aeomttrbhhsi8bph31jn84sto6h","ax9f8so418s6s65hi5ympd93i6a"]},"createAt":1667495373961,"updateAt":1667507826320,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"c9pwabyseiibumq71b9ykxsotqe","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"User Story","fields":{"contentOrder":["anfmjd4qmxffj3bckd9nei61ioe"],"icon":"📖","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"apht1nt5ryukdmxkh6fkfn6rgoy","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer"}},"createAt":1667496557683,"updateAt":1667496593762,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"c9rh5kubchfy1tejtiwbsw6z5xr","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Horizontal scroll issue","fields":{"contentOrder":["aiazua9893f8tmgn5jcn476ieay","ayko7csybxpgg7ejnybqoimp6co","7n75owjmi1bfnbcdswmscqpon5r"],"icon":"〰️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"aomnawq4551cbbzha9gxnmb3z5w","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","ai7ajsdk14w7x5s8up3dwir77te":"1","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691350,"updateAt":1667495472233,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"cc98t3whwhbnd5mx4qehmg43wpy","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Login screen not loading","fields":{"contentOrder":["ahaytdn7aajy63dsca6dhmzew6e","awcedibyeufyazxdy6x83wiqtne","73u5teq68rbrsfensjkigjfsk3h"],"icon":"🖥️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"aomnawq4551cbbzha9gxnmb3z5w","50117d52-bcc7-4750-82aa-831a351c44a0":"abrfos7e7eczk9rqw6y5abadm1y","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","ai7ajsdk14w7x5s8up3dwir77te":"1","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9"}},"createAt":1657660691556,"updateAt":1667495472221,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"cfb7jed1iz3ntx8rrcc5pphaixc","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Move cards across boards","fields":{"contentOrder":["aqm83zjjchi8a8nramuatg88cer"],"icon":"🚚","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"abrfos7e7eczk9rqw6y5abadm1y","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"2","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691670,"updateAt":1667495472240,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"cg1ausqdw9bdpbgx1aaoas6umaa","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Cross-team collaboration","fields":{"contentOrder":["aanatt8ay8iyj7n4gxstxijiber"],"icon":"🤝","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","60985f46-3e41-486e-8213-2b987440ea1c":"14892380-1a32-42dd-8034-a0cea32bc7e6","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"3","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"c62172ea-5da7-4dec-8186-37267d8ee9a7"}},"createAt":1657660691791,"updateAt":1667495472239,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"ci1zytcoz1jrj3qncas3fjc9ruo","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Bug","fields":{"contentOrder":["ajgyboqst3fy1zb989wkuqiaz5o","akzfz1eh8uj87mfkgucgmtdzwzw","7zbiceo9toidtbjya5xxo6fcsow"],"icon":"🐞","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"aomnawq4551cbbzha9gxnmb3z5w","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer"}},"createAt":1667507786809,"updateAt":1667507806029,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"cs7rqsonyr7gofepxn84ui8niyy","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Standard properties","fields":{"contentOrder":["ax5npjmoqo7b87fzjo518ahfdkc"],"icon":"🏷️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","60985f46-3e41-486e-8213-2b987440ea1c":"14892380-1a32-42dd-8034-a0cea32bc7e6","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"3","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691454,"updateAt":1667495472304,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"cxi14orfaajfsjjpgok167kc78y","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Epic","fields":{"contentOrder":["aoer81hcfmt818d1awj3bnntkzh"],"icon":"🤝","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"3","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"c62172ea-5da7-4dec-8186-37267d8ee9a7"}},"createAt":1667496390689,"updateAt":1667496493419,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"cxmfbp7wdoifdzdztkrurxe3pgh","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Global templates","fields":{"contentOrder":["a6r3jdde39ibbury8s8zib5prjy"],"icon":"🖼️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"a6ghze4iy441qhsh3eijnc8hwze","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","a1g6i613dpe9oryeo71ex3c86hy":"https://mattermost.com/boards/","ai7ajsdk14w7x5s8up3dwir77te":"2","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1657660691245,"updateAt":1667496491020,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"cxom5chmr5tna9ru4na34dbhmur","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Feature","fields":{"contentOrder":["ad9bf7wpdwbnwbebkptg3puwu4c"],"icon":"🏗️","isTemplate":true,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"a5yxq8rbubrpnoommfwqmty138h","50117d52-bcc7-4750-82aa-831a351c44a0":"aft5bzo7h9aspqgrx3jpy5tzrer"}},"createAt":1667496496593,"updateAt":1667496522591,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"v4fpda1kk3jgy8ctqyw9ey4fwye","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":["cxmfbp7wdoifdzdztkrurxe3pgh","c9rh5kubchfy1tejtiwbsw6z5xr","cfb7jed1iz3ntx8rrcc5pphaixc","cc98t3whwhbnd5mx4qehmg43wpy","cs7rqsonyr7gofepxn84ui8niyy","cg1ausqdw9bdpbgx1aaoas6umaa"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cidz4imnqhir48brz6e8hxhfrhy","filter":{"filters":[],"operation":"and"},"groupById":"50117d52-bcc7-4750-82aa-831a351c44a0","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["aft5bzo7h9aspqgrx3jpy5tzrer","abrfos7e7eczk9rqw6y5abadm1y","ax8wzbka5ahs3zziji3pp4qp9mc","atabdfbdmjh83136d5e5oysxybw","ace1bzypd586kkyhcht5qqd9eca","aay656c9m1hzwxc9ch5ftymh3nw","a6ghze4iy441qhsh3eijnc8hwze"],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1657660691994,"updateAt":1667496285840,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"vj3bern6637nt7c5edfx8qx6b6h","parentId":"bgi1yqiis8t8xdqxgnet8ebutky","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Type","fields":{"cardOrder":["cc98t3whwhbnd5mx4qehmg43wpy","cfb7jed1iz3ntx8rrcc5pphaixc","cs7rqsonyr7gofepxn84ui8niyy","c9rh5kubchfy1tejtiwbsw6z5xr","cxmfbp7wdoifdzdztkrurxe3pgh","cg1ausqdw9bdpbgx1aaoas6umaa"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cidz4imnqhir48brz6e8hxhfrhy","filter":{"filters":[],"operation":"and"},"groupById":"20717ad3-5741-4416-83f1-6f133fff3d11","hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["424ea5e3-9aa1-4075-8c5c-01b44b66e634","a5yxq8rbubrpnoommfwqmty138h","apht1nt5ryukdmxkh6fkfn6rgoy","aiycbuo3dr5k4xxbfr7coem8ono","aomnawq4551cbbzha9gxnmb3z5w"],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1657660691890,"updateAt":1667496327144,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"anfmjd4qmxffj3bckd9nei61ioe","parentId":"c9pwabyseiibumq71b9ykxsotqe","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1667496557692,"updateAt":1667496557692,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"7n75owjmi1bfnbcdswmscqpon5r","parentId":"c9rh5kubchfy1tejtiwbsw6z5xr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7tmfu5iqju3n1mdfwi5gru89qmw.png"},"createAt":1657660690017,"updateAt":1657660690017,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"aiazua9893f8tmgn5jcn476ieay","parentId":"c9rh5kubchfy1tejtiwbsw6z5xr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1657660691037,"updateAt":1657660691037,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"ayko7csybxpgg7ejnybqoimp6co","parentId":"c9rh5kubchfy1tejtiwbsw6z5xr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1657660690831,"updateAt":1657660690831,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"73u5teq68rbrsfensjkigjfsk3h","parentId":"cc98t3whwhbnd5mx4qehmg43wpy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7b9xk9boj3fbqfm3umeaaizp8qr.png"},"createAt":1657660690116,"updateAt":1657660690116,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"ahaytdn7aajy63dsca6dhmzew6e","parentId":"cc98t3whwhbnd5mx4qehmg43wpy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1657660690422,"updateAt":1657660690422,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"awcedibyeufyazxdy6x83wiqtne","parentId":"cc98t3whwhbnd5mx4qehmg43wpy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1657660690318,"updateAt":1657660690318,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"aqm83zjjchi8a8nramuatg88cer","parentId":"cfb7jed1iz3ntx8rrcc5pphaixc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1657660690521,"updateAt":1657660690521,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"aumokx4tdmjrgxgy4o8s3jow8ha","parentId":"cfmk7771httynm8r7rm8cbrmrya","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1657729295838,"updateAt":1657729295838,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"aydywea4hq3rytf3k7a9y4iqtbe","parentId":"cfmk7771httynm8r7rm8cbrmrya","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1657729295724,"updateAt":1657729295724,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"aanatt8ay8iyj7n4gxstxijiber","parentId":"cg1ausqdw9bdpbgx1aaoas6umaa","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n\n## Motivation\n*[Brief description on why this is needed]*\n\n## Acceptance Criteria\n - *[Criteron 1]*\n - *[Criteron 2]*\n - ...\n\n## Personas\n - *[Persona A]*\n - *[Persona B]*\n - ...\n\n## Reference Materials\n - *[Links to other relevant documents as needed]*\n - ...","fields":{},"createAt":1657660690218,"updateAt":1657660690218,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"7zbiceo9toidtbjya5xxo6fcsow","parentId":"ci1zytcoz1jrj3qncas3fjc9ruo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7tmfu5iqju3n1mdfwi5gru89qmw.png"},"createAt":1667507786817,"updateAt":1667507786817,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"ajgyboqst3fy1zb989wkuqiaz5o","parentId":"ci1zytcoz1jrj3qncas3fjc9ruo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1667507786830,"updateAt":1667507786830,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"akzfz1eh8uj87mfkgucgmtdzwzw","parentId":"ci1zytcoz1jrj3qncas3fjc9ruo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1667507786823,"updateAt":1667507786823,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"ax5npjmoqo7b87fzjo518ahfdkc","parentId":"cs7rqsonyr7gofepxn84ui8niyy","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1657660690723,"updateAt":1657660690723,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"aoer81hcfmt818d1awj3bnntkzh","parentId":"cxi14orfaajfsjjpgok167kc78y","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n\n## Motivation\n*[Brief description on why this is needed]*\n\n## Acceptance Criteria\n - *[Criteron 1]*\n - *[Criteron 2]*\n - ...\n\n## Personas\n - *[Persona A]*\n - *[Persona B]*\n - ...\n\n## Reference Materials\n - *[Links to other relevant documents as needed]*\n - ...","fields":{},"createAt":1667496390699,"updateAt":1667496390699,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"a6r3jdde39ibbury8s8zib5prjy","parentId":"cxmfbp7wdoifdzdztkrurxe3pgh","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1657660690935,"updateAt":1657660690935,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
{"type":"block","data":{"id":"ad9bf7wpdwbnwbebkptg3puwu4c","parentId":"cxom5chmr5tna9ru4na34dbhmur","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1667496496600,"updateAt":1667496496600,"deleteAt":0,"boardId":"bgi1yqiis8t8xdqxgnet8ebutky"}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{"type":"board","data":{"id":"bh4pkixqsjift58e1qy6htrgeay","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"User Research Sessions","description":"Use this template to manage and keep track of all your user research sessions.","icon":"🔬","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"aaebj5fyx493eezx6ukxiwydgty","name":"Status","options":[{"color":"propColorGray","id":"af6hjb3ysuaxbwnfqpby4wwnkdr","value":"Backlog 📒"},{"color":"propColorYellow","id":"aotxum1p5bw3xuzqz3ctjw66yww","value":"Contacted 📞"},{"color":"propColorBlue","id":"a7yq89whddzob1futao4rxk3yzc","value":"Scheduled 📅"},{"color":"propColorRed","id":"aseqq9hrsua56r3s6nbuirj9eec","value":"Cancelled 🚫"},{"color":"propColorGreen","id":"ap93ysuzy1xa7z818r6myrn4h4y","value":"Completed ✔️"}],"type":"select"},{"id":"akrxgi7p7w14fym3gbynb98t9fh","name":"Interview Date","options":[],"type":"date"},{"id":"atg9qu6oe4bjm8jczzsn71ff5me","name":"Product Area","options":[{"color":"propColorGreen","id":"ahn89mqg9u4igk6pdm7333t8i5h","value":"Desktop App"},{"color":"propColorPurple","id":"aehc83ffays3gh8myz16a8j7k4e","value":"Web App"},{"color":"propColorBlue","id":"a1sxagjgaadym5yrjak6tcup1oa","value":"Mobile App"}],"type":"select"},{"id":"acjq4t5ymytu8x1f68wkggm7ypc","name":"Email","options":[],"type":"email"},{"id":"aphio1s5gkmpdbwoxynim7acw3e","name":"Interviewer","options":[],"type":"multiPerson"},{"id":"aqafzdeekpyncwz7m7i54q3iqqy","name":"Recording URL","options":[],"type":"url"},{"id":"aify3r761b9w43bqjtskrzi68tr","name":"Passcode","options":[],"type":"text"}],"createAt":1667410119064,"updateAt":1667504168497,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"vtibhoxpq67f1xmh8a8kxh39nka","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"All Users","fields":{"cardOrder":["ccsa77z7ubbbhbd3jq8xyx4hq8r"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":280,"aaebj5fyx493eezx6ukxiwydgty":146,"acjq4t5ymytu8x1f68wkggm7ypc":222,"akrxgi7p7w14fym3gbynb98t9fh":131,"atg9qu6oe4bjm8jczzsn71ff5me":131},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"akrxgi7p7w14fym3gbynb98t9fh","reversed":false}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["aaebj5fyx493eezx6ukxiwydgty","akrxgi7p7w14fym3gbynb98t9fh","atg9qu6oe4bjm8jczzsn71ff5me","acjq4t5ymytu8x1f68wkggm7ypc","aphio1s5gkmpdbwoxynim7acw3e","aqafzdeekpyncwz7m7i54q3iqqy","aify3r761b9w43bqjtskrzi68tr"]},"createAt":1667410162023,"updateAt":1667410900827,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"ccsa77z7ubbbhbd3jq8xyx4hq8r","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Frank Nash","fields":{"contentOrder":["aiqaqwzhe1tn9umunms95414kzo"],"icon":"👨💼","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"ap93ysuzy1xa7z818r6myrn4h4y","acjq4t5ymytu8x1f68wkggm7ypc":"frank.nash@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1669896000000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"aehc83ffays3gh8myz16a8j7k4e"}},"createAt":1667410176348,"updateAt":1667410539559,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"cdus79hea7ib6tb6nhic4bzcjbc","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Richard Parsons","fields":{"contentOrder":["aodtggcnfuby6fq8ehxg4koafgr"],"icon":"👨🦱","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"a7yq89whddzob1futao4rxk3yzc","acjq4t5ymytu8x1f68wkggm7ypc":"richard.parsons@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1671019200000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"a1sxagjgaadym5yrjak6tcup1oa"}},"createAt":1667410640657,"updateAt":1667417523845,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"cewmyr3nbybdombzmi83arq3koo","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Claire Hart","fields":{"contentOrder":["ahkkrf9xn8tfq9y98to8pbt6qnw"],"icon":"👩🦰","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"aseqq9hrsua56r3s6nbuirj9eec","acjq4t5ymytu8x1f68wkggm7ypc":"claire.hart@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1670500800000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"ahn89mqg9u4igk6pdm7333t8i5h"}},"createAt":1667410785750,"updateAt":1667410805030,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"cix9xfgh48ir55y7fdjftuje3za","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Olivia Alsop","fields":{"contentOrder":["a8xz4ead8k7budxknwhjxm9n3uc"],"icon":"👩💼","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"a7yq89whddzob1futao4rxk3yzc","acjq4t5ymytu8x1f68wkggm7ypc":"olivia.alsop@email.com","aify3r761b9w43bqjtskrzi68tr":"Password123","akrxgi7p7w14fym3gbynb98t9fh":"{\"from\":1671192000000}","aqafzdeekpyncwz7m7i54q3iqqy":"https://user-images.githubusercontent.com/46905241/121941290-ee355280-cd03-11eb-9b9f-f6f524e4103e.gif","atg9qu6oe4bjm8jczzsn71ff5me":"a1sxagjgaadym5yrjak6tcup1oa"}},"createAt":1667410730577,"updateAt":1667410775912,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"cn3skudjd9tbp5md9bocifnazpw","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Bernadette Powell","fields":{"contentOrder":["au67hjd7es7y6jkumo4ysrudwfa"],"icon":"🧑💼","isTemplate":false,"properties":{"aaebj5fyx493eezx6ukxiwydgty":"af6hjb3ysuaxbwnfqpby4wwnkdr","acjq4t5ymytu8x1f68wkggm7ypc":"bernadette.powell@email.com"}},"createAt":1667410584181,"updateAt":1667410629860,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"vqi9zpn3h43bkbfc8c8jc7ci1hr","parentId":"bh4pkixqsjift58e1qy6htrgeay","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Date","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"akrxgi7p7w14fym3gbynb98t9fh","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1667410845935,"updateAt":1667410849497,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"vdbpwgay6bbn8581n39yjiyxrxo","parentId":"bixohg18tt11in4qbtinimk974y","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["af6hjb3ysuaxbwnfqpby4wwnkdr","aotxum1p5bw3xuzqz3ctjw66yww","a7yq89whddzob1futao4rxk3yzc","aseqq9hrsua56r3s6nbuirj9eec","ap93ysuzy1xa7z818r6myrn4h4y"],"visiblePropertyIds":[]},"createAt":1667410119073,"updateAt":1667417470776,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"aiqaqwzhe1tn9umunms95414kzo","parentId":"ccsa77z7ubbbhbd3jq8xyx4hq8r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410410824,"updateAt":1667410422875,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"aodtggcnfuby6fq8ehxg4koafgr","parentId":"cdus79hea7ib6tb6nhic4bzcjbc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410640663,"updateAt":1667410640663,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"ahkkrf9xn8tfq9y98to8pbt6qnw","parentId":"cewmyr3nbybdombzmi83arq3koo","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410785755,"updateAt":1667410785755,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"a8xz4ead8k7budxknwhjxm9n3uc","parentId":"cix9xfgh48ir55y7fdjftuje3za","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410730582,"updateAt":1667410730582,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
{"type":"block","data":{"id":"au67hjd7es7y6jkumo4ysrudwfa","parentId":"cn3skudjd9tbp5md9bocifnazpw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Interview Notes\n- ...\n- ...\n- ... ","fields":{},"createAt":1667410584187,"updateAt":1667410584187,"deleteAt":0,"boardId":"bh4pkixqsjift58e1qy6htrgeay"}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{"type":"board","data":{"id":"bkqk6hpfx7pbsucue7jan5n1o1o","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Competitive Analysis","description":"Use this template to track and stay ahead of the competition.","icon":"🗂️","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"ahzspe59iux8wigra8bg6cg18nc","name":"Website","options":[],"type":"url"},{"id":"aozntq4go4nkab688j1s7stqtfc","name":"Location","options":[],"type":"text"},{"id":"aiefo7nh9jwisn8b4cgakowithy","name":"Revenue","options":[],"type":"text"},{"id":"a6cwaq79b1pdpb97wkanmeyy4er","name":"Employees","options":[],"type":"number"},{"id":"an1eerzscfxn6awdfajbg41uz3h","name":"Founded","options":[],"type":"text"},{"id":"a1semdhszu1rq17d7et5ydrqqio","name":"Market Position","options":[{"color":"propColorYellow","id":"arfjpz9by5car71tz3behba8yih","value":"Leader"},{"color":"propColorRed","id":"abajmr34b8g1916w495xjb35iko","value":"Challenger"},{"color":"propColorBlue","id":"abt79uxg5edqojsrrefcnr4eruo","value":"Follower"},{"color":"propColorBrown","id":"aipf3qfgjtkheiayjuxrxbpk9wa","value":"Nicher"}],"type":"select"},{"id":"aapogff3xoa8ym7xf56s87kysda","name":"Last updated time","options":[],"type":"updatedTime"},{"id":"az3jkw3ynd3mqmart7edypey15e","name":"Last updated by","options":[],"type":"updatedBy"}],"createAt":1667337304886,"updateAt":1667352513150,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"vfzq8kedf3bnt7qkrsom658j6io","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Competitor List","fields":{"cardOrder":["c96bjeqk6zjrm5qtyoenexh3f8e","chg7cdun9hjbf5pue6zc1gxm8rw","cn9chs8a4zjyqzqez7qor63s8uc","ctkqr4ce3zjrzur3q4mn47eeuuc","cnam59x954idsxpbbfp8bmsigtr"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":210,"a1semdhszu1rq17d7et5ydrqqio":121,"aapogff3xoa8ym7xf56s87kysda":194,"ahzspe59iux8wigra8bg6cg18nc":156,"aiefo7nh9jwisn8b4cgakowithy":155,"aozntq4go4nkab688j1s7stqtfc":151,"az3jkw3ynd3mqmart7edypey15e":145},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["ahzspe59iux8wigra8bg6cg18nc","aozntq4go4nkab688j1s7stqtfc","aiefo7nh9jwisn8b4cgakowithy","a6cwaq79b1pdpb97wkanmeyy4er","an1eerzscfxn6awdfajbg41uz3h","a1semdhszu1rq17d7et5ydrqqio","aapogff3xoa8ym7xf56s87kysda","az3jkw3ynd3mqmart7edypey15e"]},"createAt":1667339411936,"updateAt":1667399926321,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"vr158bbbsetn5ffm1gebhduhx5a","parentId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Market Position","fields":{"cardOrder":["cip8b4jcomfr7by9gtizebikfke","cacs91js1hb887ds41r6dwnd88c","ca3u8edwrof89i8obxffnz4xw3a"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["arfjpz9by5car71tz3behba8yih","abajmr34b8g1916w495xjb35iko","abt79uxg5edqojsrrefcnr4eruo","aipf3qfgjtkheiayjuxrxbpk9wa"],"visiblePropertyIds":[]},"createAt":1667351648812,"updateAt":1667352684324,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"c96bjeqk6zjrm5qtyoenexh3f8e","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Liminary Corp.","fields":{"contentOrder":["ainmysbw6xpyczm3p5xayocpm3e"],"icon":"🌧","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"abt79uxg5edqojsrrefcnr4eruo","a6cwaq79b1pdpb97wkanmeyy4er":"300","ahzspe59iux8wigra8bg6cg18nc":"liminarycorp.com","aiefo7nh9jwisn8b4cgakowithy":"$25,000,000","an1eerzscfxn6awdfajbg41uz3h":"2017","aozntq4go4nkab688j1s7stqtfc":"Toronto, Canada"}},"createAt":1667338157613,"updateAt":1667351630721,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"chg7cdun9hjbf5pue6zc1gxm8rw","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Helx Industries","fields":{"contentOrder":["an5k8g7ntz7bgppfx9cuk9oyaja"],"icon":"📦","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"abt79uxg5edqojsrrefcnr4eruo","a6cwaq79b1pdpb97wkanmeyy4er":"650","ahzspe59iux8wigra8bg6cg18nc":"helxindustries.com","aiefo7nh9jwisn8b4cgakowithy":"$50,000,000","an1eerzscfxn6awdfajbg41uz3h":"2009","aozntq4go4nkab688j1s7stqtfc":"New York, NY"}},"createAt":1667338444580,"updateAt":1667351626493,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"cn9chs8a4zjyqzqez7qor63s8uc","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Kadera Global","fields":{"contentOrder":["aup87xiwr9bye8fpshibuh156ih"],"icon":"🛡","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"aipf3qfgjtkheiayjuxrxbpk9wa","a6cwaq79b1pdpb97wkanmeyy4er":"150","ahzspe59iux8wigra8bg6cg18nc":"kaderaglobal.com","aiefo7nh9jwisn8b4cgakowithy":"$12,000,000","an1eerzscfxn6awdfajbg41uz3h":"2015","aozntq4go4nkab688j1s7stqtfc":"Seattle, OR"}},"createAt":1667338227718,"updateAt":1667351623847,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"cnam59x954idsxpbbfp8bmsigtr","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Ositions Inc.","fields":{"contentOrder":["aukdnjj7mw3ggudoe88wmmpgore"],"icon":"🌃","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"abajmr34b8g1916w495xjb35iko","a6cwaq79b1pdpb97wkanmeyy4er":"2,700","ahzspe59iux8wigra8bg6cg18nc":"ositionsinc.com","aiefo7nh9jwisn8b4cgakowithy":"$125,000,000","an1eerzscfxn6awdfajbg41uz3h":"2004","aozntq4go4nkab688j1s7stqtfc":"Berlin, Germany"}},"createAt":1667337634942,"updateAt":1667351619186,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"ctkqr4ce3zjrzur3q4mn47eeuuc","parentId":"bkqk6hpfx7pbsucue7jan5n1o1o","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Afformance Ltd.","fields":{"contentOrder":["a1c857fph4byaxdqf88kw1wrwyo"],"icon":"⚡","isTemplate":false,"properties":{"a1semdhszu1rq17d7et5ydrqqio":"arfjpz9by5car71tz3behba8yih","a6cwaq79b1pdpb97wkanmeyy4er":"1,800","ahzspe59iux8wigra8bg6cg18nc":"afformanceltd.com","aiefo7nh9jwisn8b4cgakowithy":"$200,000,000","an1eerzscfxn6awdfajbg41uz3h":"2002","aozntq4go4nkab688j1s7stqtfc":"Palo Alto, CA"}},"createAt":1667338608746,"updateAt":1667351615526,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"ainmysbw6xpyczm3p5xayocpm3e","parentId":"c96bjeqk6zjrm5qtyoenexh3f8e","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339042969,"updateAt":1667340672945,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"an5k8g7ntz7bgppfx9cuk9oyaja","parentId":"chg7cdun9hjbf5pue6zc1gxm8rw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339057076,"updateAt":1667340666993,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"aup87xiwr9bye8fpshibuh156ih","parentId":"cn9chs8a4zjyqzqez7qor63s8uc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339054835,"updateAt":1667340086915,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"aukdnjj7mw3ggudoe88wmmpgore","parentId":"cnam59x954idsxpbbfp8bmsigtr","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n\n## Weaknesses\n- ...\n- ...\n\n## Opportunities\n- ...\n- ...\n\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339032796,"updateAt":1667340679120,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
{"type":"block","data":{"id":"a1c857fph4byaxdqf88kw1wrwyo","parentId":"ctkqr4ce3zjrzur3q4mn47eeuuc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Duis fermentum aliquet massa in ornare. Pellentesque mollis nisl efficitur, eleifend nisi congue, scelerisque nunc. Aliquam lorem quam, commodo id nunc nec, congue bibendum velit. Vivamus sed mattis libero, et iaculis diam. Suspendisse euismod hendrerit nisl, quis ornare ipsum gravida in.\n## Strengths\n- ...\n- ...\n## Weaknesses\n- ...\n- ...\n## Opportunities\n- ...\n- ...\n## Threats\n- ...\n- ...","fields":{},"createAt":1667339061925,"updateAt":1667340648719,"deleteAt":0,"boardId":"bkqk6hpfx7pbsucue7jan5n1o1o"}}
|
||||
Двоичные данные
server/boards/assets/templates-boardarchive/brs9cdimfw7fodyi7erqt747rhc/7y5kr8x8ybpnwdykjfuz57rggrh.png
Обычный файл
Двоичные данные
server/boards/assets/templates-boardarchive/brs9cdimfw7fodyi7erqt747rhc/7y5kr8x8ybpnwdykjfuz57rggrh.png
Обычный файл
Двоичный файл не отображается.
|
После Ширина: | Высота: | Размер: 57 KiB |
@@ -0,0 +1,18 @@
|
||||
{"type":"block","data":{"id":"brs9cdimfw7fodyi7erqt747rhc","parentId":"","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Content Calendar (NEW)","fields":{"cardProperties":[{"id":"ae9ar615xoknd8hw8py7mbyr7zo","name":"Status","options":[{"color":"propColorGray","id":"awna1nuarjca99m9s4uiy9kwj5h","value":"Idea 💡"},{"color":"propColorOrange","id":"a9ana1e9w673o5cp8md4xjjwfto","value":"Draft"},{"color":"propColorPurple","id":"apy9dcd7zmand615p3h53zjqxjh","value":"In Review"},{"color":"propColorBlue","id":"acri4cm3bmay55f7ksztphmtnga","value":"Ready to Publish"},{"color":"propColorGreen","id":"amsowcd9a8e1kid317r7ttw6uzh","value":"Published 🎉"}],"type":"select"},{"id":"aysx3atqexotgwp5kx6h5i5ancw","name":"Type","options":[{"color":"propColorOrange","id":"aywiofmmtd3ofgzj95ysky4pjga","value":"Press Release"},{"color":"propColorGreen","id":"apqdgjrmsmx8ngmp7zst51647de","value":"Sponsored Post"},{"color":"propColorPurple","id":"a3woynbjnb7j16e74uw3pubrytw","value":"Customer Story"},{"color":"propColorRed","id":"aq36k5pkpfcypqb3idw36xdi1fh","value":"Product Release"},{"color":"propColorGray","id":"azn66pmk34adygnizjqhgiac4ia","value":"Partnership"},{"color":"propColorBlue","id":"aj8y675weso8kpb6eceqbpj4ruw","value":"Feature Announcement"},{"color":"propColorYellow","id":"a3xky7ygn14osr1mokerbfah5cy","value":"Article"}],"type":"select"},{"id":"ab6mbock6styfe6htf815ph1mhw","name":"Channel","options":[{"color":"propColorBrown","id":"a8xceonxiu4n3c43szhskqizicr","value":"Website"},{"color":"propColorGreen","id":"a3pdzi53kpbd4okzdkz6khi87zo","value":"Blog"},{"color":"propColorOrange","id":"a3d9ux4fmi3anyd11kyipfbhwde","value":"Email"},{"color":"propColorRed","id":"a8cbbfdwocx73zn3787cx6gacsh","value":"Podcast"},{"color":"propColorPink","id":"aigjtpcaxdp7d6kmctrwo1ztaia","value":"Print"},{"color":"propColorBlue","id":"af1wsn13muho59e7ghwaogxy5ey","value":"Facebook"},{"color":"propColorGray","id":"a47zajfxwhsg6q8m7ppbiqs7jge","value":"LinkedIn"},{"color":"propColorYellow","id":"az8o8pfe9hq6s7xaehoqyc3wpyc","value":"Twitter"}],"type":"multiSelect"},{"id":"ao44fz8nf6z6tuj1x31t9yyehcc","name":"Assignee","options":[],"type":"person"},{"id":"a39x5cybshwrbjpc3juaakcyj6e","name":"Due Date","options":[],"type":"date"},{"id":"agqsoiipowmnu9rdwxm57zrehtr","name":"Publication Date","options":[],"type":"date"},{"id":"ap4e7kdg7eip7j3c3oyiz39eaoc","name":"Link","options":[],"type":"url"}],"description":"Use this template to plan and organize your editorial content.","icon":"📅","isTemplate":false,"showDescription":true},"createAt":1641618112737,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c3pxiqf156fnhjfazwwpo79rt6w","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"New Project and Workflow Management Solutions for Developers","fields":{"contentOrder":["71qhnzuec6esdi6fnynwpze4xya","aianjmrimwfyr7jiiju1oi77kiw"],"icon":"🎯","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1645790400000}","ab6mbock6styfe6htf815ph1mhw":["a8xceonxiu4n3c43szhskqizicr","a3pdzi53kpbd4okzdkz6khi87zo","a3d9ux4fmi3anyd11kyipfbhwde"],"ae9ar615xoknd8hw8py7mbyr7zo":"awna1nuarjca99m9s4uiy9kwj5h","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://mattermost.com/newsroom/press-releases/mattermost-launches-new-project-and-workflow-management-solutions-for-developers/","aysx3atqexotgwp5kx6h5i5ancw":"aywiofmmtd3ofgzj95ysky4pjga"}},"createAt":1641618113009,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cemyj9s9nwtgzieowpufrd1oo5h","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"[Tweet] Mattermost v6.1 includes card @-mention notifications in Boards","fields":{"contentOrder":["7i96m7nbsdsex8n6hzuzrmdfjuy","7ed5bwp3gr8yax3mhtuwiaa9gjy","a8egmu8gsqp8dzfk9pgpq5mm4ta","awyawmyjtj3nfffu4aphaqy9bgy","abdasiyq4k7ndtfrdadrias8sjy","71ppnm4bcmbrbpn73nefjkao17r"],"icon":"🐤","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1639051200000}","ab6mbock6styfe6htf815ph1mhw":["az8o8pfe9hq6s7xaehoqyc3wpyc"],"ae9ar615xoknd8hw8py7mbyr7zo":"a9ana1e9w673o5cp8md4xjjwfto","agqsoiipowmnu9rdwxm57zrehtr":"{\"from\":1637668800000}","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://twitter.com/Mattermost/status/1463145633162969097?s=20","aysx3atqexotgwp5kx6h5i5ancw":"aj8y675weso8kpb6eceqbpj4ruw"}},"createAt":1641618112896,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cp963ioyx63rz98q8gs19nxxm7w","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Top 10 Must-Have DevOps Tools in 2021","fields":{"contentOrder":["7fo1utqc8x1z1z6hzg33hes1ktc","ajm6ykd3633dbxdq6j76wtthbia"],"icon":"🛠️","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1636113600000}","ab6mbock6styfe6htf815ph1mhw":["a8xceonxiu4n3c43szhskqizicr"],"ae9ar615xoknd8hw8py7mbyr7zo":"a9ana1e9w673o5cp8md4xjjwfto","agqsoiipowmnu9rdwxm57zrehtr":"{\"from\":1637323200000}","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://www.toolbox.com/tech/devops/articles/best-devops-tools/","aysx3atqexotgwp5kx6h5i5ancw":"a3xky7ygn14osr1mokerbfah5cy"}},"createAt":1641618112796,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"crrwzx9z4dfbsiki6suzwj3mqfw","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Unblocking Workflows: The Guide to Developer Productivity","fields":{"contentOrder":["77tz16jtz5x73ncs3dxc3fp1d7h","asmp1ztc1gjyh3k8og8yyizu5jy"],"icon":"💻","isTemplate":false,"properties":{"a39x5cybshwrbjpc3juaakcyj6e":"{\"from\":1638532800000}","ab6mbock6styfe6htf815ph1mhw":["a3pdzi53kpbd4okzdkz6khi87zo"],"ae9ar615xoknd8hw8py7mbyr7zo":"apy9dcd7zmand615p3h53zjqxjh","agqsoiipowmnu9rdwxm57zrehtr":"{\"from\":1639483200000}","ap4e7kdg7eip7j3c3oyiz39eaoc":"https://mattermost.com/newsroom/press-releases/mattermost-unveils-definitive-report-on-the-state-of-developer-productivity-unblocking-workflows-the-guide-to-developer-productivity-2022-edition/","aysx3atqexotgwp5kx6h5i5ancw":"a3xky7ygn14osr1mokerbfah5cy"}},"createAt":1641618112846,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vaiuu5bg4ofdn8j4whttdgtus4w","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"By Status","fields":{"cardOrder":[null,"cdbfkd15d6iy18rgx1tskmfsr6c","cn8yofg9rtkgmzgmb5xdi56p3ic","csgsnnywpuqzs5jgq87snk9x17e","cqwaytore5y487wdu8zffppqnea",null],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["awna1nuarjca99m9s4uiy9kwj5h","a9ana1e9w673o5cp8md4xjjwfto","apy9dcd7zmand615p3h53zjqxjh","acri4cm3bmay55f7ksztphmtnga","amsowcd9a8e1kid317r7ttw6uzh",""],"visiblePropertyIds":["ab6mbock6styfe6htf815ph1mhw"]},"createAt":1641618113176,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vgbzazskupjrq7gnrwqqk51adsh","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Due Date Calendar","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"a39x5cybshwrbjpc3juaakcyj6e","defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641618113068,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vkk4dm1tnzb8fbmr5gxhibr63te","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Publication Calendar","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"agqsoiipowmnu9rdwxm57zrehtr","defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1641618113123,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vpsefkithi7gq3rfyignqxa9cze","parentId":"brs9cdimfw7fodyi7erqt747rhc","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Content List","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"__title":322,"ab6mbock6styfe6htf815ph1mhw":229,"aysx3atqexotgwp5kx6h5i5ancw":208},"defaultTemplateId":"cff1jmrxfrirgbeebhr9qd7nida","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"a39x5cybshwrbjpc3juaakcyj6e","reversed":false}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["ae9ar615xoknd8hw8py7mbyr7zo","aysx3atqexotgwp5kx6h5i5ancw","ab6mbock6styfe6htf815ph1mhw","ao44fz8nf6z6tuj1x31t9yyehcc","a39x5cybshwrbjpc3juaakcyj6e","agqsoiipowmnu9rdwxm57zrehtr","ap4e7kdg7eip7j3c3oyiz39eaoc"]},"createAt":1641618243042,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aianjmrimwfyr7jiiju1oi77kiw","parentId":"c3pxiqf156fnhjfazwwpo79rt6w","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618141074,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"71ppnm4bcmbrbpn73nefjkao17r","parentId":"cemyj9s9nwtgzieowpufrd1oo5h","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7y5kr8x8ybpnwdykjfuz57rggrh.png"},"createAt":1641618185785,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a8egmu8gsqp8dzfk9pgpq5mm4ta","parentId":"cemyj9s9nwtgzieowpufrd1oo5h","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618157625,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"awyawmyjtj3nfffu4aphaqy9bgy","parentId":"cemyj9s9nwtgzieowpufrd1oo5h","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Media","fields":{},"createAt":1641618160634,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a4uyug1msrtrkdfy5fwu8shf7so","parentId":"cff1jmrxfrirgbeebhr9qd7nida","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618338368,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"abztjcgndkffd3gybef6phr14so","parentId":"cff1jmrxfrirgbeebhr9qd7nida","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n- ...\n\n## Notes\n- ...\n- ...\n- ...","fields":{},"createAt":1641618112322,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"azczyg4pfj3ysjpxf4hjtu666ne","parentId":"cff1jmrxfrirgbeebhr9qd7nida","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618112527,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ajm6ykd3633dbxdq6j76wtthbia","parentId":"cp963ioyx63rz98q8gs19nxxm7w","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618208454,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"asmp1ztc1gjyh3k8og8yyizu5jy","parentId":"crrwzx9z4dfbsiki6suzwj3mqfw","rootId":"brs9cdimfw7fodyi7erqt747rhc","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Research\n- ...\n- ...\n\n## Plan\n- ...\n- ...\n\n## Notes\n- ...\n- ...","fields":{},"createAt":1641618224780,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"type":"board","data":{"id":"bsjd59qtpbf888mqez3ge77domw","teamId":"qghzt68dq7bopgqamcnziq69ao","channelId":"","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","type":"P","minimumRole":"","title":"Team Retrospective","description":"Use this template at the end of your project or sprint to identify what worked well and what can be improved for the future.","icon":"🧭","showDescription":true,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"adjckpdotpgkz7c6wixzw9ipb1e","name":"Category","options":[{"color":"propColorGray","id":"aok6pgecm85qe9k5kcphzoe63ma","value":"To Discuss 📣"},{"color":"propColorGreen","id":"aq1dwbf661yx337hjcd5q3sbxwa","value":"Went Well 👍"},{"color":"propColorRed","id":"ar87yh5xmsswqkxmjq1ipfftfpc","value":"Didn't Go Well 🚫"},{"color":"propColorBlue","id":"akj3fkmxq7idma55mdt8sqpumyw","value":"Action Items ✅"}],"type":"select"},{"id":"aspaay76a5wrnuhtqgm97tt3rer","name":"Details","options":[],"type":"text"},{"id":"arzsm76s376y7suuhao3tu6efoc","name":"Created By","options":[],"type":"createdBy"},{"id":"a8anbe5fpa668sryatcdsuuyh8a","name":"Created Time","options":[],"type":"createdTime"}],"createAt":1667494395151,"updateAt":1667508014713,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"v56n9ixhhbpn79m6eb4xwpo6dih","parentId":"bjbhs6bos3m8zjouf78xceg9nqw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board view","fields":{"cardOrder":["cniwb8xwcqtbstbcm3sdfrr854h","cs4qwpzr65fgttd7364dicskanh","c9s78pzbdg3g4jkcdjqahtnfejc","c8utmazns878jtfgtf7exyi9pee","cnobejmb6bf8e3c1w7em5z4pwyh"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[""],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":["aok6pgecm85qe9k5kcphzoe63ma","aq1dwbf661yx337hjcd5q3sbxwa","ar87yh5xmsswqkxmjq1ipfftfpc","akj3fkmxq7idma55mdt8sqpumyw"],"visiblePropertyIds":["aspaay76a5wrnuhtqgm97tt3rer"]},"createAt":1667494395162,"updateAt":1667508040536,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}}
|
||||
{"type":"block","data":{"id":"c8utmazns878jtfgtf7exyi9pee","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Tight deadline","fields":{"contentOrder":[],"icon":"📅","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"ar87yh5xmsswqkxmjq1ipfftfpc"}},"createAt":1667495008197,"updateAt":1667495012284,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}}
|
||||
{"type":"block","data":{"id":"c9s78pzbdg3g4jkcdjqahtnfejc","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Team communication","fields":{"contentOrder":[],"icon":"💬","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"aq1dwbf661yx337hjcd5q3sbxwa"}},"createAt":1667494992121,"updateAt":1667494995438,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}}
|
||||
{"type":"block","data":{"id":"cniwb8xwcqtbstbcm3sdfrr854h","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Reschedule planning meeting","fields":{"contentOrder":[],"icon":"🗓️","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"aok6pgecm85qe9k5kcphzoe63ma"}},"createAt":1667494810631,"updateAt":1667495048934,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}}
|
||||
{"type":"block","data":{"id":"cnobejmb6bf8e3c1w7em5z4pwyh","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Schedule more time for testing","fields":{"contentOrder":[],"icon":"🧪","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"akj3fkmxq7idma55mdt8sqpumyw"}},"createAt":1667495025505,"updateAt":1667495032672,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}}
|
||||
{"type":"block","data":{"id":"cs4qwpzr65fgttd7364dicskanh","parentId":"bsjd59qtpbf888mqez3ge77domw","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Positive user feedback","fields":{"contentOrder":[],"icon":"🥰","isTemplate":false,"properties":{"adjckpdotpgkz7c6wixzw9ipb1e":"aq1dwbf661yx337hjcd5q3sbxwa"}},"createAt":1667494972061,"updateAt":1667494978637,"deleteAt":0,"boardId":"bsjd59qtpbf888mqez3ge77domw"}}
|
||||
Двоичные данные
server/boards/assets/templates-boardarchive/bui5izho7dtn77xg3thkiqprc9r/77pe9r4ckbin438ph3f18bpatua.png
Обычный файл
Двоичные данные
server/boards/assets/templates-boardarchive/bui5izho7dtn77xg3thkiqprc9r/77pe9r4ckbin438ph3f18bpatua.png
Обычный файл
Двоичный файл не отображается.
|
После Ширина: | Высота: | Размер: 2.4 KiB |
Двоичные данные
server/boards/assets/templates-boardarchive/bui5izho7dtn77xg3thkiqprc9r/7pbp4qg415pbstc6enzeicnu3qh.png
Обычный файл
Двоичные данные
server/boards/assets/templates-boardarchive/bui5izho7dtn77xg3thkiqprc9r/7pbp4qg415pbstc6enzeicnu3qh.png
Обычный файл
Двоичный файл не отображается.
|
После Ширина: | Высота: | Размер: 110 KiB |
@@ -0,0 +1,30 @@
|
||||
{"type":"block","data":{"id":"bui5izho7dtn77xg3thkiqprc9r","parentId":"","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"board","title":"Roadmap (NEW)","fields":{"cardProperties":[{"id":"50117d52-bcc7-4750-82aa-831a351c44a0","name":"Status","options":[{"color":"propColorGray","id":"8c557f69-b0ed-46ec-83a3-8efab9d47ef5","value":"Not Started"},{"color":"propColorYellow","id":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","value":"In Progress"},{"color":"propColorGreen","id":"849766ba-56a5-48d1-886f-21672f415395","value":"Complete 🙌"}],"type":"select"},{"id":"20717ad3-5741-4416-83f1-6f133fff3d11","name":"Type","options":[{"color":"propColorYellow","id":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","value":"Epic ⛰"},{"color":"propColorGreen","id":"6eea96c9-4c61-4968-8554-4b7537e8f748","value":"Task 🔨"},{"color":"propColorRed","id":"1fdbb515-edd2-4af5-80fc-437ed2211a49","value":"Bug 🐞"}],"type":"select"},{"id":"60985f46-3e41-486e-8213-2b987440ea1c","name":"Sprint","options":[{"color":"propColorBrown","id":"c01676ca-babf-4534-8be5-cce2287daa6c","value":"Sprint 1"},{"color":"propColorPurple","id":"ed4a5340-460d-461b-8838-2c56e8ee59fe","value":"Sprint 2"},{"color":"propColorBlue","id":"14892380-1a32-42dd-8034-a0cea32bc7e6","value":"Sprint 3"}],"type":"select"},{"id":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","name":"Priority","options":[{"color":"propColorRed","id":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9","value":"P1 🔥"},{"color":"propColorYellow","id":"e6a7f297-4440-4783-8ab3-3af5ba62ca11","value":"P2"},{"color":"propColorGray","id":"c62172ea-5da7-4dec-8186-37267d8ee9a7","value":"P3"}],"type":"select"},{"id":"aphg37f7zbpuc3bhwhp19s1ribh","name":"Assignee","options":[],"type":"person"},{"id":"a4378omyhmgj3bex13sj4wbpfiy","name":"Due Date","options":[],"type":"date"},{"id":"a36o9q1yik6nmar6ri4q4uca7ey","name":"Created Date","options":[],"type":"createdTime"},{"id":"ai7ajsdk14w7x5s8up3dwir77te","name":"Design Link","options":[],"type":"url"}],"description":"Use this template to plan your roadmap and manage your releases more efficiently.","icon":"🗺️","isTemplate":false,"showDescription":true},"createAt":1640363551156,"updateAt":1643788318628,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c3jawn6e4fbr3jctthy9xxkdsqe","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"App crashing","fields":{"contentOrder":["79t7rkiuspeneqi9xurou9tqzwh","a4d68ftemrbfsfykur6eh6nrogh","ae54fbyywubnbtr3s4yhgns4nye","7o9ktgofg37yc7gma9s3jd9bd3a"],"icon":"📉","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"1fdbb515-edd2-4af5-80fc-437ed2211a49","50117d52-bcc7-4750-82aa-831a351c44a0":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"cb8ecdac-38be-4d36-8712-c4d58cc8a8e9"}},"createAt":1641589357560,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c5trb4319wi8n3x4r4f7f83ytdc","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Calendar view","fields":{"contentOrder":["7df11783ny67mdnognqae31ax6y","ag9rxpgbwqid1mm5hgg8b9yhf6o"],"icon":"📆","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"6eea96c9-4c61-4968-8554-4b7537e8f748","50117d52-bcc7-4750-82aa-831a351c44a0":"849766ba-56a5-48d1-886f-21672f415395","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1641590072588,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"c9p4bdasriifc7qgihzhjm63ugy","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Standard templates","fields":{"contentOrder":["7uonmjk41nipnrsi6tz8wau5ssh","afz66z155b7fhik9p6opysjneha"],"icon":"🗺️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"6eea96c9-4c61-4968-8554-4b7537e8f748","50117d52-bcc7-4750-82aa-831a351c44a0":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1641589960934,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"chfrdo1nb3p8ofnbftyinr6949o","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Import / Export","fields":{"contentOrder":["aw66wjze7qfr1ukqs8gw53qa5qw"],"icon":"🚢","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"6eea96c9-4c61-4968-8554-4b7537e8f748","50117d52-bcc7-4750-82aa-831a351c44a0":"ec6d2bc5-df2b-4f77-8479-e59ceb039946","60985f46-3e41-486e-8213-2b987440ea1c":"c01676ca-babf-4534-8be5-cce2287daa6c","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1640363550923,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cp1m1wrpfatdxikhwkf58oo5k3o","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Review API design","fields":{"contentOrder":["ahsamufik97nsfxjgx9cs6cmzme"],"icon":"🛣️","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"424ea5e3-9aa1-4075-8c5c-01b44b66e634","50117d52-bcc7-4750-82aa-831a351c44a0":"8c557f69-b0ed-46ec-83a3-8efab9d47ef5","60985f46-3e41-486e-8213-2b987440ea1c":"14892380-1a32-42dd-8034-a0cea32bc7e6","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"c62172ea-5da7-4dec-8186-37267d8ee9a7"}},"createAt":1640363550754,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"cqfy6g434pigk3p7j3gq55trq9o","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"card","title":"Icons don't display","fields":{"contentOrder":["axfkn6tuy4igubj3ka99tbymb8o","acbpep9wxdtyg8gg3fi6h1hgoro","7tedfdyq4p7g77dmkrebryh4jor"],"icon":"💻","isTemplate":false,"properties":{"20717ad3-5741-4416-83f1-6f133fff3d11":"1fdbb515-edd2-4af5-80fc-437ed2211a49","50117d52-bcc7-4750-82aa-831a351c44a0":"8c557f69-b0ed-46ec-83a3-8efab9d47ef5","60985f46-3e41-486e-8213-2b987440ea1c":"ed4a5340-460d-461b-8838-2c56e8ee59fe","ai7ajsdk14w7x5s8up3dwir77te":"https://mattermost.com/boards/","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e":"e6a7f297-4440-4783-8ab3-3af5ba62ca11"}},"createAt":1640363550868,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"v1uubwdzrw7fsxnd6pss1dyhh5e","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Calendar View","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"dateDisplayPropertyId":"a4378omyhmgj3bex13sj4wbpfiy","defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"calendar","visibleOptionIds":[],"visiblePropertyIds":["__title"]},"createAt":1640379248049,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"v7n4sc9cre7gsbq9yydsuekpg8a","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board: Sprints","fields":{"cardOrder":["c3jawn6e4fbr3jctthy9xxkdsqe","c5trb4319wi8n3x4r4f7f83ytdc","c9p4bdasriifc7qgihzhjm63ugy","cqfy6g434pigk3p7j3gq55trq9o","chfrdo1nb3p8ofnbftyinr6949o","cp1m1wrpfatdxikhwkf58oo5k3o"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"groupById":"60985f46-3e41-486e-8213-2b987440ea1c","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["c01676ca-babf-4534-8be5-cce2287daa6c","ed4a5340-460d-461b-8838-2c56e8ee59fe","14892380-1a32-42dd-8034-a0cea32bc7e6",""],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363550811,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"v8sa3mo81d38rbmd8bz4n6dg7qc","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"List: Tasks 🔨","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"50117d52-bcc7-4750-82aa-831a351c44a0":139,"__title":280},"defaultTemplateId":"","filter":{"filters":[{"condition":"includes","propertyId":"20717ad3-5741-4416-83f1-6f133fff3d11","values":["6eea96c9-4c61-4968-8554-4b7537e8f748"]}],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"50117d52-bcc7-4750-82aa-831a351c44a0","reversed":true}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["50117d52-bcc7-4750-82aa-831a351c44a0","20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363550980,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vi43bqxsho3fmjbu1oa8qafwo4c","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"Board: Status","fields":{"cardOrder":["c3jawn6e4fbr3jctthy9xxkdsqe","cm4w7cc3aac6s9jdcujbs4j8f4r","c6egh6cpnj137ixdoitsoxq17oo","cct9u78utsdyotmejbmwwg66ihr","cmft87it1q7yebbd51ij9k65xbw","c9fe77j9qcruxf4itzib7ag6f1c","coup7afjknqnzbdwghiwbsq541w","c5ex1hndz8qyc8gx6ofbfeksftc"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"cidz4imnqhir48brz6e8hxhfrhy","filter":{"filters":[],"operation":"and"},"groupById":"50117d52-bcc7-4750-82aa-831a351c44a0","hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"board","visibleOptionIds":["8c557f69-b0ed-46ec-83a3-8efab9d47ef5","ec6d2bc5-df2b-4f77-8479-e59ceb039946","849766ba-56a5-48d1-886f-21672f415395",""],"visiblePropertyIds":["20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363551099,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"vod5de87tz7nxpji31oou4ine3c","parentId":"bui5izho7dtn77xg3thkiqprc9r","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"view","title":"List: Bugs 🐞","fields":{"cardOrder":[],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{"50117d52-bcc7-4750-82aa-831a351c44a0":145,"__title":280},"defaultTemplateId":"","filter":{"filters":[{"condition":"includes","propertyId":"20717ad3-5741-4416-83f1-6f133fff3d11","values":["1fdbb515-edd2-4af5-80fc-437ed2211a49"]}],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[{"propertyId":"f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e","reversed":false}],"viewType":"table","visibleOptionIds":[],"visiblePropertyIds":["50117d52-bcc7-4750-82aa-831a351c44a0","20717ad3-5741-4416-83f1-6f133fff3d11","60985f46-3e41-486e-8213-2b987440ea1c","f7f3ad42-b31a-4ac2-81f0-28ea80c5b34e"]},"createAt":1640363550690,"updateAt":1643788318631,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7o9ktgofg37yc7gma9s3jd9bd3a","parentId":"c3jawn6e4fbr3jctthy9xxkdsqe","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"77pe9r4ckbin438ph3f18bpatua.png"},"createAt":1641589687567,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a4d68ftemrbfsfykur6eh6nrogh","parentId":"c3jawn6e4fbr3jctthy9xxkdsqe","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1641589386414,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ae54fbyywubnbtr3s4yhgns4nye","parentId":"c3jawn6e4fbr3jctthy9xxkdsqe","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1641589472988,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ag9rxpgbwqid1mm5hgg8b9yhf6o","parentId":"c5trb4319wi8n3x4r4f7f83ytdc","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1641590081840,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"afz66z155b7fhik9p6opysjneha","parentId":"c9p4bdasriifc7qgihzhjm63ugy","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1641589969935,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"73dpuy7r9qpfymrp67c9n3krrsc","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7pbp4qg415pbstc6enzeicnu3qh.png"},"createAt":1640379104209,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aabek71yr1trxmjudty7efncp3r","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\nIf applicable, add screenshots to elaborate on the problem.","fields":{},"createAt":1640379104369,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"asqzoizq31b81dpyzm1tnm8wyxc","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n\nA clear and concise description of what you expected to happen.\n\n## Edition and Platform\n\n - Edition: Personal Desktop / Personal Server / Mattermost plugin\n - Version: [e.g. v0.9.0]\n - Browser and OS: [e.g. Chrome 91 on macOS, Edge 93 on Windows]\n\n## Additional context\n\nAdd any other context about the problem here.","fields":{},"createAt":1640379104459,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"auefo9xa6sffatbeqzya56bhebo","parentId":"cfefgwjke6bbxpjpig618g9bpte","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n\n*[A clear and concise description of what you expected to happen.]*\n\n## Screenshots\n\n*[If applicable, add screenshots to elaborate on the problem.]*\n\n## Edition and Platform\n\n - Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n - Version: *[e.g. v0.9.0]*\n - Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n\n*[Add any other context about the problem here.]*","fields":{},"createAt":1640379139361,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"aw66wjze7qfr1ukqs8gw53qa5qw","parentId":"chfrdo1nb3p8ofnbftyinr6949o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1640380216220,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"anppzrbx3i7b47n17b6jje6e1yc","parentId":"cidz4imnqhir48brz6e8hxhfrhy","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Description\n*[Brief description of this task]*\n\n## Requirements\n- *[Requirement 1]*\n- *[Requirement 2]*\n- ...","fields":{},"createAt":1640380239894,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"azyfnyszy6jb9iys9izfz1bhbdw","parentId":"cidz4imnqhir48brz6e8hxhfrhy","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Requirements\n- [Requirement 1]\n- [Requirement 2]\n- ...","fields":{},"createAt":1640380231316,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"ahsamufik97nsfxjgx9cs6cmzme","parentId":"cp1m1wrpfatdxikhwkf58oo5k3o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n\n## Motivation\n*[Brief description on why this is needed]*\n\n## Acceptance Criteria\n - *[Criteron 1]*\n - *[Criteron 2]*\n - ...\n\n## Personas\n - *[Persona A]*\n - *[Persona B]*\n - ...\n\n## Reference Materials\n - *[Links to other relevant documents as needed]*\n - ...","fields":{},"createAt":1640380010492,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"7tedfdyq4p7g77dmkrebryh4jor","parentId":"cqfy6g434pigk3p7j3gq55trq9o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"image","title":"","fields":{"fileId":"7pbp4qg415pbstc6enzeicnu3qh.png"},"createAt":1640379056342,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"acbpep9wxdtyg8gg3fi6h1hgoro","parentId":"cqfy6g434pigk3p7j3gq55trq9o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Screenshots\n*[If applicable, add screenshots to elaborate on the problem.]*","fields":{},"createAt":1640378826029,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"axfkn6tuy4igubj3ka99tbymb8o","parentId":"cqfy6g434pigk3p7j3gq55trq9o","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Steps to reproduce the behavior\n1. Go to ...\n2. Select ...\n3. Scroll down to ...\n4. See error\n\n## Expected behavior\n*[A clear and concise description of what you expected to happen.]*\n\n## Edition and Platform\n- Edition: *[e.g. Personal Desktop / Personal Server / Mattermost plugin]*\n- Version: *[e.g. v0.9.0]*\n- Browser and OS: *[e.g. Chrome 91 on macOS, Edge 93 on Windows]*\n\n## Additional context\n*[Add any other context about the problem here.]*","fields":{},"createAt":1640378803642,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a58i6xsb3abdhm87oezaum6ehhc","parentId":"cwrq9ag3p5pgzzy98nfd3wwra1w","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n*[Brief description of what this epic is about]*\n## Motivation\n*[Brief description on why this is needed]*\n## Acceptance Criteria\n- *[Criteron 1]*\n- *[Criteron 2]*\n- ...\n## Personas\n- *[Persona A]*\n- *[Persona B]*\n- ...\n## Reference Materials\n- *[Links to other relevant documents as needed]*\n- ...","fields":{},"createAt":1640380125209,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
{"type":"block","data":{"id":"a799597ibbjb17yxy1c3zjias1w","parentId":"cwrq9ag3p5pgzzy98nfd3wwra1w","rootId":"bui5izho7dtn77xg3thkiqprc9r","createdBy":"edrkkih4cinzf8ueeszh6rmfoo","modifiedBy":"edrkkih4cinzf8ueeszh6rmfoo","schema":1,"type":"text","title":"## Summary\n[Brief description of what this epic is about]\n\n## Motivation\n[Brief description on why this is needed]\n\n## Acceptance Criteria\n - [Criteron 1]\n - [Criteron 2]\n - ...\n\n## Personas\n - [Persona A]\n - [Persona B]\n - ...\n\n## Reference Materials\n - [Links to other relevant documents as needed]\n - ...","fields":{},"createAt":1640380118322,"updateAt":1643788318633,"deleteAt":0,"workspaceId":"855b3j34ojn5p8f36yhu8336fe"}}
|
||||
Двоичные данные
server/boards/assets/templates-boardarchive/buixxjic3xjfkieees4iafdrznc/74nt9eqzea3ydjjpgjtsxcjgrxc.gif
Обычный файл
Двоичные данные
server/boards/assets/templates-boardarchive/buixxjic3xjfkieees4iafdrznc/74nt9eqzea3ydjjpgjtsxcjgrxc.gif
Обычный файл
Двоичный файл не отображается.
|
После Ширина: | Высота: | Размер: 1.1 MiB |
Двоичные данные
server/boards/assets/templates-boardarchive/buixxjic3xjfkieees4iafdrznc/74uia99m9btr8peydw7oexn37tw.gif
Обычный файл
Двоичные данные
server/boards/assets/templates-boardarchive/buixxjic3xjfkieees4iafdrznc/74uia99m9btr8peydw7oexn37tw.gif
Обычный файл
Двоичный файл не отображается.
|
После Ширина: | Высота: | Размер: 1.0 MiB |
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user