Remove boards product references (#23855)

Automatic Merge
This commit is contained in:
Miguel de la Cruz 2023-07-18 14:17:29 +02:00 committed by GitHub
parent 62495f16bd
commit 150c6e7aef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1371 changed files with 17 additions and 264248 deletions

View File

@ -3,6 +3,5 @@
export const appsPluginId = 'com.mattermost.apps';
export const boardsPluginId = 'focalboard';
export const boardsProductId = 'boards';
export const callsPluginId = 'com.mattermost.calls';
export const playbooksPluginId = 'playbooks';

View File

@ -1,8 +0,0 @@
#!/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'" }'

View File

@ -1,60 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,246 +0,0 @@
// 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/v8/boards/app"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/v8/boards/services/permissions"
"github.com/mattermost/mattermost/server/public/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)
}

View File

@ -1,79 +0,0 @@
// 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/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/boards/model"
)
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)
})
}
}

View File

@ -1,258 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
mm_model "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,36 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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
}

View File

@ -1,416 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/v8/boards/services/auth"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/public/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, &registerData)
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)
}
}

View File

@ -1,785 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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")
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("blockID", blockID)
var blocks []*model.Block
var block *model.Block
switch {
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:
opts := model.QueryBlocksOptions{
BoardID: boardID,
ParentID: parentID,
BlockType: model.BlockType(blockType),
}
blocks, err = a.app.GetBlocks(opts)
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.CopyAndUpdateCardFiles(sourceBoardID, userID, blocks, false); 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()
}

View File

@ -1,679 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,414 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,393 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,676 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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()
}

View File

@ -1,110 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
mm_model "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,451 +0,0 @@
// 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/v8/boards/model"
mm_model "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/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)
}

View File

@ -1,44 +0,0 @@
// 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)
}

View File

@ -1,107 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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()
}

View File

@ -1,32 +0,0 @@
// 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)
}

View File

@ -1,335 +0,0 @@
// 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/v8/boards/app"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/v8/boards/model"
mm_model "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/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)
}
if err != nil {
// if err is still not nil then it is an error other than `not found` so we must
// return the error to the requestor. fileReader and Fileinfo are nil in this case.
a.errorResponse(w, r, err)
}
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, board.IsTemplate)
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()
}

View File

@ -1,262 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
mm_model "github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,91 +0,0 @@
// 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/v8/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, "{}")
}
}

View File

@ -1,549 +0,0 @@
// 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/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
)
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()
}

View File

@ -1,87 +0,0 @@
// 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/v8/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)
}

View File

@ -1,355 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,184 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,74 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/v8/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)
}

View File

@ -1,241 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,60 +0,0 @@
// 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))
}

View File

@ -1,146 +0,0 @@
// 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/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/boards/app"
"github.com/mattermost/mattermost/server/v8/boards/model"
)
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)
}
})
}

View File

@ -1,367 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/v8/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
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, err = a.app.GetUsersList(userIDs)
if err != nil {
a.errorResponse(w, r, err)
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()
}

View File

@ -1,104 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,407 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/audit"
"github.com/mattermost/mattermost/server/v8/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
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, err = a.app.GetUsersList(userIDs)
if err != nil {
a.errorResponse(w, r, err)
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()
}

View File

@ -1,119 +0,0 @@
// 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/v8/boards/auth"
"github.com/mattermost/mattermost/server/v8/boards/services/config"
"github.com/mattermost/mattermost/server/v8/boards/services/metrics"
"github.com/mattermost/mattermost/server/v8/boards/services/notify"
"github.com/mattermost/mattermost/server/v8/boards/services/permissions"
"github.com/mattermost/mattermost/server/v8/boards/services/store"
"github.com/mattermost/mattermost/server/v8/boards/services/webhook"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/v8/boards/ws"
mm_model "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
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()
}

View File

@ -1,26 +0,0 @@
// 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/v8/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)
})
}

View File

@ -1,234 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/auth"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/public/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
}

View File

@ -1,192 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/auth"
"github.com/mattermost/mattermost/server/v8/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)
}
})
}
}

View File

@ -1,489 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"path/filepath"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/notify"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards")
func (a *App) GetBlocks(opts model.QueryBlocksOptions) ([]*model.Block, error) {
if opts.BoardID == "" {
return []*model.Block{}, nil
}
return a.store.GetBlocks(opts)
}
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
}
err = a.CopyAndUpdateCardFiles(boardID, userID, blocks, 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) 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) 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
}

View File

@ -1,466 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/v8/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),
},
}
opts := model.QueryBlocksOptions{
BoardID: "board_id",
ParentID: "parent_id",
BlockType: model.BlockType("view"),
}
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().GetBlocks(opts).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),
},
}
opts := model.QueryBlocksOptions{
BoardID: "board_id",
ParentID: "parent_id",
BlockType: model.BlockType("view"),
}
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().GetBlocks(opts).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),
},
}
opts := model.QueryBlocksOptions{
BoardID: "board_id",
ParentID: "parent_id",
BlockType: model.BlockType("view"),
}
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().GetBlocks(opts).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),
},
}
opts := model.QueryBlocksOptions{
BoardID: "board_id",
ParentID: "parent_id",
BlockType: model.BlockType("view"),
}
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().GetBlocks(opts).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),
},
}
opts := model.QueryBlocksOptions{
BoardID: "test-board-id",
ParentID: "parent_id",
BlockType: model.BlockType("view"),
}
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().GetBlocks(opts).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),
},
}
opts := model.QueryBlocksOptions{
BoardID: "test-board-id",
ParentID: "parent_id",
BlockType: model.BlockType("view"),
}
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().GetBlocks(opts).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),
},
}
opts := model.QueryBlocksOptions{
BoardID: "test-board-id",
ParentID: "parent_id",
BlockType: model.BlockType("view"),
}
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().GetBlocks(opts).Return([]*model.Block{{}}, nil).Times(2)
_, err := th.App.InsertBlocks([]*model.Block{view1, view2}, "user-id-1")
require.Error(t, err)
})
}

View File

@ -1,726 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/notify"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/public/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.
err = a.CopyAndUpdateCardFiles(boardID, userID, bab.Blocks, asTemplate)
if 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)
}
if !asTemplate {
for _, board := range bab.Boards {
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil {
return nil, nil, categoryErr
}
}
}
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
}

View File

@ -1,170 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/services/notify"
"github.com/mattermost/mattermost/server/public/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
}

View File

@ -1,780 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/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) {
t.Skip("MM-51699")
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)
})
}

View File

@ -1,96 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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
}

View File

@ -1,270 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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
}

View File

@ -1,248 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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
}

View File

@ -1,282 +0,0 @@
// 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/v8/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
}

View File

@ -1,340 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/v8/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)
})
}

View File

@ -1,497 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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)
})
}

View File

@ -1,19 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/v8/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,
}
}

View File

@ -1,36 +0,0 @@
// 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/v8/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)
})
}

View File

@ -1,334 +0,0 @@
// 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/public/shared/mlog"
mm_model "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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
}

View File

@ -1,780 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/v8/boards/model"
mockservicesapi "github.com/mattermost/mattermost/server/v8/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)
})
}

View File

@ -1,18 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import "github.com/mattermost/mattermost/server/v8/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)
}

View File

@ -1,85 +0,0 @@
// 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/v8/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
}

View File

@ -1,194 +0,0 @@
// 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/v8/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)
}
})
}
}

View File

@ -1,259 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/public/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.GetBlocks(model.QueryBlocksOptions{BoardID: 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 || block.Type == model.TypeAttachment {
filename, err2 := extractFilename(block)
if err2 != nil {
return err2
}
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
}
_, fileReader, err := a.GetFile(opt.TeamID, boardID, filename)
if err != nil && !model.IsErrNotFound(err) {
return err
}
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 fileReader.Close()
_, err = io.Copy(dest, fileReader)
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 extractFilename(block *model.Block) (string, error) {
f, ok := block.Fields["fileId"]
if !ok {
f, ok = block.Fields["attachmentId"]
if !ok {
return "", model.ErrInvalidImageBlock
}
}
filename, ok := f.(string)
if !ok {
return "", model.ErrInvalidImageBlock
}
return filename, nil
}

View File

@ -1,298 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed")
var ErrFileNotFound = errors.New("file not found")
func (a *App) SaveFile(reader io.Reader, teamID, boardID, filename string, asTemplate bool) (string, error) {
// NOTE: File extension includes the dot
fileExtension := strings.ToLower(filepath.Ext(filename))
if fileExtension == ".jpeg" {
fileExtension = ".jpg"
}
createdFilename := utils.NewID(utils.IDTypeNone)
newFileName := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
if asTemplate {
newFileName = filename
}
filePath := getDestinationFilePath(asTemplate, teamID, boardID, newFileName)
fileSize, appErr := a.filesBackend.WriteFile(reader, filePath)
if appErr != nil {
return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr)
}
fileInfo := model.NewFileInfo(filename)
fileInfo.Id = getFileInfoID(createdFilename)
fileInfo.Path = filePath
fileInfo.Size = fileSize
err := a.store.SaveFileInfo(fileInfo)
if err != nil {
return "", err
}
return newFileName, 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.
fileInfoID := getFileInfoID(strings.Split(filename, ".")[0])
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, filePath, err := a.GetFilePath(teamID, rootID, fileName)
if err != nil {
a.logger.Error("GetFile: Failed to GetFilePath.", mlog.String("Team", teamID), mlog.String("board", rootID), mlog.String("filename", fileName), mlog.Err(err))
return nil, nil, err
}
exists, err := a.filesBackend.FileExists(filePath)
if err != nil {
a.logger.Error("GetFile: Failed to check if file exists as path. ", mlog.String("Path", filePath), mlog.Err(err))
return nil, nil, err
}
if !exists {
return nil, nil, ErrFileNotFound
}
reader, err := a.filesBackend.Reader(filePath)
if err != nil {
a.logger.Error("GetFile: Failed to get file reader of existing file at path", mlog.String("Path", filePath), mlog.Err(err))
return nil, nil, err
}
return fileInfo, reader, nil
}
func (a *App) GetFilePath(teamID, rootID, fileName string) (*mm_model.FileInfo, string, error) {
fileInfo, err := a.GetFileInfo(fileName)
if err != nil && !model.IsErrNotFound(err) {
return nil, "", err
}
var filePath string
if fileInfo != nil && fileInfo.Path != "" {
filePath = fileInfo.Path
} else {
filePath = filepath.Join(teamID, rootID, fileName)
}
return fileInfo, filePath, nil
}
func getDestinationFilePath(isTemplate bool, teamID, boardID, filename string) string {
// if saving a file for a template, save using the "old method" that is /teamID/boardID/fileName
// this will prevent template files from being deleted by DataRetention,
// which deletes all files inside the "date" subdirectory
if isTemplate {
return filepath.Join(teamID, boardID, filename)
}
return filepath.Join(utils.GetBaseFilePath(), filename)
}
func getFileInfoID(fileName string) string {
// Boards ids are 27 characters long with a prefix character.
// removing the prefix, returns the 26 character uuid
return fileName[1:]
}
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
}
func (a *App) CopyAndUpdateCardFiles(boardID, userID string, blocks []*model.Block, asTemplate bool) error {
newFileNames, err := a.CopyCardFiles(boardID, blocks, asTemplate)
if err != nil {
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
}
// blocks now has updated file ids for any blocks containing files. We need to update the database for them.
blockIDs := make([]string, 0)
blockPatches := make([]model.BlockPatch, 0)
for _, block := range blocks {
if block.Type == model.TypeImage || block.Type == model.TypeAttachment {
if fileID, ok := block.Fields["fileId"].(string); ok {
blockIDs = append(blockIDs, block.ID)
blockPatches = append(blockPatches, model.BlockPatch{
UpdatedFields: map[string]interface{}{
"fileId": newFileNames[fileID],
},
DeletedFields: []string{"attachmentId"},
})
}
}
}
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 {
return fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err)
}
}
return nil
}
func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block, asTemplate bool) (map[string]string, 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 nil, fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err)
}
var destBoard *model.Board
newFileNames := make(map[string]string)
for _, block := range copiedBlocks {
if block.Type != model.TypeImage && block.Type != model.TypeAttachment {
continue
}
fileId, isOk := block.Fields["fileId"].(string)
if !isOk {
fileId, isOk = block.Fields["attachmentId"].(string)
if !isOk {
continue
}
}
// create unique filename
ext := filepath.Ext(fileId)
fileInfoID := utils.NewID(utils.IDTypeNone)
destFilename := fileInfoID + ext
if destBoard == nil || block.BoardID != destBoard.ID {
destBoard = sourceBoard
if block.BoardID != destBoard.ID {
destBoard, err = a.GetBoard(block.BoardID)
if err != nil {
return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
}
}
}
// GetFilePath will retrieve the correct path
// depending on whether FileInfo table is used for the file.
fileInfo, sourceFilePath, err := a.GetFilePath(sourceBoard.TeamID, sourceBoard.ID, fileId)
if err != nil {
return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
}
destinationFilePath := getDestinationFilePath(asTemplate, destBoard.TeamID, destBoard.ID, destFilename)
if fileInfo == nil {
fileInfo = model.NewFileInfo(destFilename)
}
fileInfo.Id = getFileInfoID(fileInfoID)
fileInfo.Path = destinationFilePath
err = a.store.SaveFileInfo(fileInfo)
if err != nil {
return nil, fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err)
}
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),
)
}
newFileNames[fileId] = destFilename
}
return newFileNames, nil
}

View File

@ -1,569 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/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, false)
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, false)
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, false)
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("happy path, no errors", 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 GetFilePath() throws error", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, errDummy)
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
assert.Error(t, err)
assert.Nil(t, fileInfo)
assert.Nil(t, seeker)
})
t.Run("when FileExists returns false", 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
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(false, nil)
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
assert.Error(t, err)
assert.Nil(t, fileInfo)
assert.Nil(t, seeker)
})
t.Run("when FileReader throws error", 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
mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(nil, errDummy)
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(true, nil)
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
assert.Error(t, err)
assert.Nil(t, fileInfo)
assert.Nil(t, seeker)
})
}
func TestGetFilePath(t *testing.T) {
th, _ := SetupTestHelper(t)
t.Run("when FileInfo exists", func(t *testing.T) {
path := "/path/to/file/fileName.txt"
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
Id: "fileInfoID",
Path: path,
}, nil)
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err)
assert.NotNil(t, fileInfo)
assert.Equal(t, path, filePath)
})
t.Run("when FileInfo doesn't exist", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil)
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err)
assert.Nil(t, fileInfo)
assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath)
})
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)
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err)
assert.NotNil(t, fileInfo)
assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath)
})
}
func TestCopyCard(t *testing.T) {
th, _ := SetupTestHelper(t)
imageBlock := &model.Block{
ID: "imageBlock",
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
Schema: 1,
Type: "image",
Title: "",
Fields: map[string]interface{}{"fileId": "7fileName.jpg"},
CreateAt: 1680725585250,
UpdateAt: 1680725585250,
DeleteAt: 0,
BoardID: "boardID",
}
t.Run("Board doesn't exist", func(t *testing.T) {
th.Store.EXPECT().GetBoard("boardID").Return(nil, errDummy)
_, err := th.App.CopyCardFiles("boardID", []*model.Block{}, false)
assert.Error(t, err)
})
t.Run("Board exists, image block, with FileInfo", func(t *testing.T) {
path := "/path/to/file/fileName.txt"
fileInfo := &mm_model.FileInfo{
Id: "imageBlock",
Path: path,
}
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
ID: "boardID",
IsTemplate: false,
}, nil)
th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil)
th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false)
assert.NoError(t, err)
assert.Equal(t, "7fileName.jpg", imageBlock.Fields["fileId"])
assert.NotNil(t, updatedFileNames["7fileName.jpg"])
assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)])
})
t.Run("Board exists, attachment block, with FileInfo", func(t *testing.T) {
attachmentBlock := &model.Block{
ID: "attachmentBlock",
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
Schema: 1,
Type: "attachment",
Title: "",
Fields: map[string]interface{}{"fileId": "7fileName.jpg"},
CreateAt: 1680725585250,
UpdateAt: 1680725585250,
DeleteAt: 0,
BoardID: "boardID",
}
path := "/path/to/file/fileName.txt"
fileInfo := &mm_model.FileInfo{
Id: "attachmentBlock",
Path: path,
}
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
ID: "boardID",
IsTemplate: false,
}, nil)
th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil)
th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{attachmentBlock}, false)
assert.NoError(t, err)
assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)])
})
t.Run("Board exists, image block, without FileInfo", func(t *testing.T) {
// path := "/path/to/file/fileName.txt"
// fileInfo := &mm_model.FileInfo{
// Id: "imageBlock",
// Path: path,
// }
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
ID: "boardID",
IsTemplate: false,
}, nil)
th.Store.EXPECT().GetFileInfo(gomock.Any()).Return(nil, nil)
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false)
assert.NoError(t, err)
assert.NotNil(t, imageBlock.Fields["fileId"].(string))
assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)])
})
}
func TestCopyAndUpdateCardFiles(t *testing.T) {
th, _ := SetupTestHelper(t)
imageBlock := &model.Block{
ID: "imageBlock",
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
Schema: 1,
Type: "image",
Title: "",
Fields: map[string]interface{}{"fileId": "7fileName.jpg"},
CreateAt: 1680725585250,
UpdateAt: 1680725585250,
DeleteAt: 0,
BoardID: "boardID",
}
t.Run("Board exists, image block, with FileInfo", func(t *testing.T) {
path := "/path/to/file/fileName.txt"
fileInfo := &mm_model.FileInfo{
Id: "imageBlock",
Path: path,
}
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
ID: "boardID",
IsTemplate: false,
}, nil)
th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil)
th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil)
th.Store.EXPECT().PatchBlocks(gomock.Any(), "userID").Return(nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
err := th.App.CopyAndUpdateCardFiles("boardID", "userID", []*model.Block{imageBlock}, false)
assert.NoError(t, err)
assert.NotEqual(t, path, imageBlock.Fields["fileId"])
})
}

View File

@ -1,74 +0,0 @@
// 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/v8/boards/auth"
"github.com/mattermost/mattermost/server/v8/boards/services/config"
"github.com/mattermost/mattermost/server/v8/boards/services/metrics"
"github.com/mattermost/mattermost/server/v8/boards/services/permissions/mmpermissions"
mmpermissionsMocks "github.com/mattermost/mattermost/server/v8/boards/services/permissions/mmpermissions/mocks"
permissionsMocks "github.com/mattermost/mattermost/server/v8/boards/services/permissions/mocks"
"github.com/mattermost/mattermost/server/v8/boards/services/store/mockstore"
"github.com/mattermost/mattermost/server/v8/boards/services/webhook"
"github.com/mattermost/mattermost/server/v8/boards/ws"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks"
)
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
}

View File

@ -1,471 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/public/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)
return errImport
}
zr := zipstream.NewReader(br)
boardMap := make(map[string]*model.Board) // maps old board ids to new
fileMap := make(map[string]string) // maps old fileIds to new
for {
hdr, err := zr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
a.fixImagesAttachments(boardMap, fileMap, opt.TeamID, opt.ModifiedBy)
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":
board, err := a.ImportBoardJSONL(zr, opt)
if err != nil {
return fmt.Errorf("cannot import board %s: %w", dir, err)
}
boardMap[dir] = board
default:
// import file/image; dir is the old board id
board, ok := boardMap[dir]
if !ok {
a.logger.Warn("skipping orphan image in archive",
mlog.String("dir", dir),
mlog.String("filename", filename),
)
continue
}
newFileName, err := a.SaveFile(zr, opt.TeamID, board.ID, filename, board.IsTemplate)
if err != nil {
return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err)
}
fileMap[filename] = newFileName
a.logger.Debug("import archive file",
mlog.String("TeamID", opt.TeamID),
mlog.String("boardID", board.ID),
mlog.String("filename", filename),
mlog.String("newFileName", newFileName),
)
}
}
}
// Update image and attachment blocks
func (a *App) fixImagesAttachments(boardMap map[string]*model.Board, fileMap map[string]string, teamID string, userId string) {
blockIDs := make([]string, 0)
blockPatches := make([]model.BlockPatch, 0)
for _, board := range boardMap {
if board.IsTemplate {
continue
}
opts := model.QueryBlocksOptions{
BoardID: board.ID,
}
newBlocks, err := a.GetBlocks(opts)
if err != nil {
a.logger.Info("cannot retrieve imported blocks for board", mlog.String("BoardID", board.ID), mlog.Err(err))
return
}
for _, block := range newBlocks {
if block.Type == "image" || block.Type == "attachment" {
fieldName := "fileId"
oldId := block.Fields[fieldName]
blockIDs = append(blockIDs, block.ID)
blockPatches = append(blockPatches, model.BlockPatch{
UpdatedFields: map[string]interface{}{
fieldName: fileMap[oldId.(string)],
},
})
}
}
blockPatchBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches}
a.PatchBlocks(teamID, &blockPatchBatch, userId)
}
}
// 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) (*model.Board, 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 nil, 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 nil, 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 nil, 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 nil, 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 nil, 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 nil, fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2)
}
boardMembers = append(boardMembers, boardMember)
default:
return nil, model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
}
firstLine = false
}
}
if errRead != nil {
if errors.Is(errRead, io.EOF) {
break
}
return nil, 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 nil, fmt.Errorf("error generating archive block IDs: %w", err)
}
boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false)
if err != nil {
return nil, 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 nil, 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 nil, fmt.Errorf("cannot add member to board: %w", err2)
}
}
}
// find new board id
for _, board := range boardsAndBlocks.Boards {
return board, nil
}
return nil, 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
}

View File

@ -1,236 +0,0 @@
// 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/v8/boards/utils"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/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)
newBoard, err := th.App.ImportBoardJSONL(r, opts)
require.NoError(t, err, "import archive should not fail")
require.Equal(t, board.ID, newBoard.ID, "Board ID should be same")
})
t.Run("fix image and attachment", func(t *testing.T) {
boardMap := map[string]*model.Board{
"test": board,
}
fileMap := map[string]string{
"oldFileName1.jpg": "newFileName1.jpg",
"oldFileName2.jpg": "newFileName2.jpg",
}
imageBlock := &model.Block{
ID: "blockID-1",
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
Schema: 1,
Type: "image",
Title: "",
Fields: map[string]interface{}{"fileId": "oldFileName1.jpg"},
CreateAt: 1680725585250,
UpdateAt: 1680725585250,
DeleteAt: 0,
BoardID: "board-id",
}
attachmentBlock := &model.Block{
ID: "blockID-2",
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
Schema: 1,
Type: "attachment",
Title: "",
Fields: map[string]interface{}{"fileId": "oldFileName2.jpg"},
CreateAt: 1680725585250,
UpdateAt: 1680725585250,
DeleteAt: 0,
BoardID: "board-id",
}
blockIDs := []string{"blockID-1", "blockID-2"}
blockPatch := model.BlockPatch{
UpdatedFields: map[string]interface{}{"fileId": "newFileName1.jpg"},
}
blockPatch2 := model.BlockPatch{
UpdatedFields: map[string]interface{}{"fileId": "newFileName2.jpg"},
}
blockPatches := []model.BlockPatch{blockPatch, blockPatch2}
blockPatchesBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches}
opts := model.QueryBlocksOptions{
BoardID: board.ID,
}
th.Store.EXPECT().GetBlocks(opts).Return([]*model.Block{imageBlock, attachmentBlock}, nil)
th.Store.EXPECT().GetBlocksByIDs(blockIDs).Return([]*model.Block{imageBlock, attachmentBlock}, nil)
th.Store.EXPECT().GetBlock(blockIDs[0]).Return(imageBlock, nil)
th.Store.EXPECT().GetBlock(blockIDs[1]).Return(attachmentBlock, nil)
th.Store.EXPECT().GetMembersForBoard("board-id").AnyTimes().Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().PatchBlocks(&blockPatchesBatch, "my-userid")
th.App.fixImagesAttachments(boardMap, fileMap, "test-team", "my-userid")
})
}
//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}}
`

View File

@ -1,29 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"github.com/mattermost/mattermost/server/public/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")
}
}
}

View File

@ -1,87 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/v8/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
}

View File

@ -1,93 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/v8/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"})
})
}

View File

@ -1,99 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"github.com/mattermost/mattermost/server/v8/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
}

View File

@ -1,197 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/v8/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)
})
}

View File

@ -1,12 +0,0 @@
// 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/public/model"
)
func (a *App) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
return a.permissions.HasPermissionToBoard(userID, boardID, permission)
}

View File

@ -1,45 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"runtime"
"github.com/mattermost/mattermost/server/v8/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",
}
}

View File

@ -1,40 +0,0 @@
// 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/v8/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)
}
})
}

View File

@ -1,20 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/v8/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)
}

View File

@ -1,88 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/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())
})
}

View File

@ -1,55 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/public/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)
}

View File

@ -1,68 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/public/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()
}

View File

@ -1,158 +0,0 @@
// 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/v8/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)
}

View File

@ -1,115 +0,0 @@
// 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/v8/boards/assets"
"github.com/mattermost/mattermost/server/v8/boards/model"
"github.com/mattermost/mattermost/server/public/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
}

View File

@ -1,75 +0,0 @@
// 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/v8/boards/model"
"github.com/mattermost/mattermost/server/v8/boards/utils"
"github.com/mattermost/mattermost/server/public/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.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil).AnyTimes()
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")
})
}

View File

@ -1,85 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/v8/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)
}

View File

@ -1,89 +0,0 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/v8/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))
})
}

View File

@ -1,15 +0,0 @@
// 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

View File

@ -1,195 +0,0 @@
// 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)
}

View File

@ -1,36 +0,0 @@
{"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"}}

View File

@ -1,94 +0,0 @@
{"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"}}

View File

@ -1,22 +0,0 @@
{"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"}}

View File

@ -1,52 +0,0 @@
{"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"}}

View File

@ -1,12 +0,0 @@
{"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"}}

View File

@ -1,8 +0,0 @@
{"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"}}

View File

@ -1,32 +0,0 @@
{"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"}}

View File

@ -1,14 +0,0 @@
{"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"}}

View File

@ -1,13 +0,0 @@
{"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"}}

View File

@ -1,18 +0,0 @@
{"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"}}

View File

@ -1,7 +0,0 @@
{"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"}}

View File

@ -1,30 +0,0 @@
{"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"}}

Some files were not shown because too many files have changed in this diff Show More