Move playbooks to plugin (#23732)

* Remove build references

* Remove playbooks webapp and server, and add the prepackaged plugin

* Remove translations

* Add ProductSettings to the playwright type

* Restore playbooks as a prepackaged plugin for cypress e2e tests
This commit is contained in:
Miguel de la Cruz 2023-06-14 23:33:26 +02:00 committed by GitHub
parent f386ef1ea8
commit 44a99d1736
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
879 changed files with 66 additions and 127144 deletions

View File

@ -148,6 +148,7 @@ const prepackagedPlugins = [
'com.mattermost.nps',
'com.mattermost.welcomebot',
'zoom',
'playbooks',
];
Cypress.Commands.add('apiDisableNonPrepackagedPlugins', () => {

View File

@ -606,9 +606,7 @@ const defaultServerConfig: AdminConfig = {
CleanupJobsThresholdDays: -1,
CleanupConfigThresholdDays: -1,
},
ProductSettings: {
EnablePlaybooks: true,
},
ProductSettings: {},
PluginSettings: {
Enable: true,
EnableUploads: false,

View File

@ -147,8 +147,7 @@ DIST_PATH_WIN=$(DIST_ROOT)/windows/mattermost
TESTS=.
# Packages lists
TE_PACKAGES=$(shell $(GO) list ./... | grep -vE 'server/v8/playbooks|server/v8/cmd/mmctl')
PLAYBOOKS_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/playbooks')
TE_PACKAGES=$(shell $(GO) list ./... | grep -vE 'server/v8/cmd/mmctl')
SUITE_PACKAGES=$(shell $(GO) list ./...| grep -vE 'server/v8/cmd/mmctl')
MMCTL_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/cmd/mmctl')
@ -164,6 +163,7 @@ PLUGIN_PACKAGES += mattermost-plugin-confluence-v1.3.0
PLUGIN_PACKAGES += mattermost-plugin-custom-attributes-v1.3.1
PLUGIN_PACKAGES += mattermost-plugin-github-v2.1.6
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.6.0
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.36.1
PLUGIN_PACKAGES += mattermost-plugin-jenkins-v1.1.0
PLUGIN_PACKAGES += mattermost-plugin-jira-v3.2.5
PLUGIN_PACKAGES += mattermost-plugin-jitsi-v2.0.1
@ -188,9 +188,9 @@ endif
EE_PACKAGES=$(shell $(GO) list $(BUILD_ENTERPRISE_DIR)/...)
ifeq ($(BUILD_ENTERPRISE_READY),true)
ALL_PACKAGES=$(TE_PACKAGES) $(PLAYBOOKS_PACKAGES) $(EE_PACKAGES)
ALL_PACKAGES=$(TE_PACKAGES) $(EE_PACKAGES)
else
ALL_PACKAGES=$(TE_PACKAGES) $(PLAYBOOKS_PACKAGES)
ALL_PACKAGES=$(TE_PACKAGES)
endif
all: run ## Alias for 'run'.
@ -460,7 +460,6 @@ endif
test-server-race: test-server-pre
./scripts/test.sh "$(GO)" "-race $(GOFLAGS)" "$(TE_PACKAGES) $(EE_PACKAGES)" "$(TESTS)" "$(TESTFLAGS)" "$(GOBIN)" "90m"
./scripts/test.sh "$(GO)" "-race $(GOFLAGS)" "$(PLAYBOOKS_PACKAGES)" "$(TESTS)" "$(TESTFLAGS)" "$(GOBIN)" "90m"
ifneq ($(IS_CI),true)
ifneq ($(MM_NO_DOCKER),true)
ifneq ($(TEMP_DOCKER_SERVICES),)

View File

@ -172,7 +172,7 @@ func TestInstallPluginLocally(t *testing.T) {
defer th.TearDown()
cleanExistingBundles(t, th)
_, appErr := installPlugin(t, th, "playbooks", "0.0.1", installPluginLocallyAlways)
_, appErr := installPlugin(t, th, "com.mattermost.plugin-incident-response", "0.0.1", installPluginLocallyAlways)
require.NotNil(t, appErr)
require.Equal(t, "app.plugin.blocked.app_error", appErr.Id)
assertBundleInfoManifests(t, th, []*model.Manifest{})

View File

@ -83,16 +83,6 @@ func (s *Server) shouldStart(product string) bool {
return false
}
}
if product == "playbooks" {
if os.Getenv("MM_DISABLE_PLAYBOOKS") == "true" {
s.Log().Warn("Skipping Playbooks start: disabled via env var")
return false
}
if !*s.Config().ProductSettings.EnablePlaybooks {
s.Log().Warn("Skipping Playbooks start: disabled via configuration")
return false
}
}
return true
}

View File

@ -17,7 +17,6 @@ import (
// Blank imports for each product to register themselves
_ "github.com/mattermost/mattermost/server/v8/boards/product"
_ "github.com/mattermost/mattermost/server/v8/playbooks/product"
)
func main() {

View File

@ -144,8 +144,6 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts)
props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete)
props["EnablePlaybooks"] = strconv.FormatBool(*c.ProductSettings.EnablePlaybooks)
if license != nil {
props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)

View File

@ -326,20 +326,6 @@ func TestGetClientConfig(t *testing.T) {
"ExperimentalSharedChannels": "true",
},
},
{
"Default Playbooks Enabled",
&model.Config{
ProductSettings: model.ProductSettings{},
},
"",
&model.License{
Features: &model.Features{},
SkuShortName: "other",
},
map[string]string{
"EnablePlaybooks": "true",
},
},
}
for _, testCase := range testCases {

View File

@ -810,6 +810,9 @@ func TestDiff(t *testing.T) {
"com.mattermost.calls": {
Enable: true,
},
"playbooks": {
Enable: true,
},
},
},
},
@ -839,6 +842,9 @@ func TestDiff(t *testing.T) {
"com.mattermost.calls": {
Enable: true,
},
"playbooks": {
Enable: true,
},
},
},
},
@ -860,6 +866,9 @@ func TestDiff(t *testing.T) {
"com.mattermost.calls": {
Enable: true,
},
"playbooks": {
Enable: true,
},
},
},
},

View File

@ -22,13 +22,11 @@ require (
github.com/golang-migrate/migrate/v4 v4.15.2
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/golang/mock v1.6.0
github.com/google/go-querystring v1.1.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/gorilla/websocket v1.5.0
github.com/graph-gophers/dataloader/v6 v6.0.0
github.com/graph-gophers/dataloader/v7 v7.1.0
github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a
github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
@ -54,7 +52,6 @@ require (
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.23
github.com/minio/minio-go/v7 v7.0.51
github.com/mitchellh/mapstructure v1.5.0
github.com/oklog/run v1.1.0
github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb
github.com/opentracing/opentracing-go v1.2.0
@ -78,17 +75,14 @@ require (
github.com/uber/jaeger-lib v2.4.1+incompatible
github.com/vmihailenco/msgpack/v5 v5.3.5
github.com/wiggin77/merror v1.0.4
github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c
github.com/yuin/goldmark v1.5.4
golang.org/x/crypto v0.8.0
golang.org/x/image v0.7.0
golang.org/x/net v0.10.0
golang.org/x/oauth2 v0.7.0
golang.org/x/sync v0.2.0
golang.org/x/term v0.8.0
golang.org/x/tools v0.9.1
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/olivere/elastic.v6 v6.2.37
gopkg.in/yaml.v2 v2.4.0
@ -178,6 +172,7 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
@ -224,7 +219,6 @@ require (
golang.org/x/mod v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.54.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect

View File

@ -722,7 +722,6 @@ github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYV
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -787,8 +786,6 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graph-gophers/dataloader/v6 v6.0.0 h1:qBpmq3B8PIQesoh0EJXKGfw+ulMUb+KFl4IZOe9ScWg=
github.com/graph-gophers/dataloader/v6 v6.0.0/go.mod h1:J15OZSnOoZgMkijpbZcwCmglIDYqlUiTEE1xLPbyqZM=
github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc=
github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q=
github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a h1:i0+Se9S+2zL5CBxJouqn2Ej6UQMwH1c57ZB6DVnqck4=
github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
@ -1559,8 +1556,6 @@ github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8
github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls=
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
@ -1841,8 +1836,6 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -2181,7 +2174,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@ -2336,8 +2328,6 @@ gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qS
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

View File

@ -4947,10 +4947,6 @@
"id": "app.command.deletecommand.internal_error",
"translation": "Unable to delete the command."
},
{
"id": "app.command.execute.error",
"translation": "Unable to execute command."
},
{
"id": "app.command.getcommand.internal_error",
"translation": "Unable to get the command."
@ -6815,88 +6811,6 @@
"id": "app.user.demote_user_to_guest.user_update.app_error",
"translation": "Failed to update the user."
},
{
"id": "app.user.digest.overdue_status_updates.heading",
"translation": "Overdue Status Updates"
},
{
"id": "app.user.digest.overdue_status_updates.num_overdue",
"translation": {
"one": "You have {{.Count}} run overdue for a status update:",
"other": "You have {{.Count}} runs overdue for a status update:"
}
},
{
"id": "app.user.digest.overdue_status_updates.zero_overdue",
"translation": "You have 0 runs overdue."
},
{
"id": "app.user.digest.runs_in_progress.heading",
"translation": "Runs in Progress"
},
{
"id": "app.user.digest.runs_in_progress.num_in_progress",
"translation": {
"one": "You have {{.Count}} run currently in progress:",
"other": "You have {{.Count}} runs currently in progress:"
}
},
{
"id": "app.user.digest.runs_in_progress.zero_in_progress",
"translation": "You have 0 runs currently in progress."
},
{
"id": "app.user.digest.tasks.all_tasks_command",
"translation": "Please use `/playbook todo` to see all your tasks."
},
{
"id": "app.user.digest.tasks.due_after_today",
"translation": {
"one": "You have **{{.Count}} assigned task due after today**.",
"other": "You have **{{.Count}} assigned tasks due after today**."
}
},
{
"id": "app.user.digest.tasks.due_in_x_days",
"translation": {
"one": "Due in {{.Count}} day",
"other": "Due in {{.Count}} days"
}
},
{
"id": "app.user.digest.tasks.due_today",
"translation": "Due today"
},
{
"id": "app.user.digest.tasks.due_x_days_ago",
"translation": "Due {{.Count}} days ago"
},
{
"id": "app.user.digest.tasks.due_yesterday",
"translation": "Due yesterday"
},
{
"id": "app.user.digest.tasks.heading",
"translation": "Your assigned tasks"
},
{
"id": "app.user.digest.tasks.num_assigned",
"translation": {
"one": "You have {{.Count}} assigned task:",
"other": "You have {{.Count}} total assigned tasks:"
}
},
{
"id": "app.user.digest.tasks.num_assigned_due_until_today",
"translation": {
"one": "You have {{.Count}} assigned task that is now due:",
"other": "You have {{.Count}} assigned tasks that are now due:"
}
},
{
"id": "app.user.digest.tasks.zero_assigned",
"translation": "You have 0 assigned tasks."
},
{
"id": "app.user.get.app_error",
"translation": "We encountered an error finding the account."
@ -6973,26 +6887,6 @@
"id": "app.user.missing_account.const",
"translation": "Unable to find the user."
},
{
"id": "app.user.new_run.intro",
"translation": "**Owner** {{.Username}}"
},
{
"id": "app.user.new_run.playbook",
"translation": "Playbook"
},
{
"id": "app.user.new_run.run_name",
"translation": "Run name"
},
{
"id": "app.user.new_run.submit_label",
"translation": "Start run"
},
{
"id": "app.user.new_run.title",
"translation": "Run playbook"
},
{
"id": "app.user.permanent_delete.app_error",
"translation": "Unable to delete the existing account."
@ -7005,108 +6899,6 @@
"id": "app.user.promote_guest.user_update.app_error",
"translation": "Failed to update the user."
},
{
"id": "app.user.run.add_checklist_item.description",
"translation": "Description"
},
{
"id": "app.user.run.add_checklist_item.name",
"translation": "Name"
},
{
"id": "app.user.run.add_checklist_item.submit_label",
"translation": "Add task"
},
{
"id": "app.user.run.add_checklist_item.title",
"translation": "Add new task"
},
{
"id": "app.user.run.add_to_timeline.playbook_run",
"translation": "Playbook Run"
},
{
"id": "app.user.run.add_to_timeline.submit_label",
"translation": "Add to run timeline"
},
{
"id": "app.user.run.add_to_timeline.summary",
"translation": "Summary"
},
{
"id": "app.user.run.add_to_timeline.summary.help",
"translation": "Max 64 chars"
},
{
"id": "app.user.run.add_to_timeline.summary.placeholder",
"translation": "Short summary shown in the timeline"
},
{
"id": "app.user.run.add_to_timeline.title",
"translation": "Add to run timeline"
},
{
"id": "app.user.run.confirm_finish.num_outstanding",
"translation": {
"one": "There is **{{.Count}} outstanding task**. Are you sure you want to finish the run *{{.RunName}}* for all participants?",
"other": "There are **{{.Count}} outstanding tasks**. Are you sure you want to finish the run *{{.RunName}}* for all participants?"
}
},
{
"id": "app.user.run.confirm_finish.submit_label",
"translation": "Finish run"
},
{
"id": "app.user.run.confirm_finish.title",
"translation": "Confirm finish run"
},
{
"id": "app.user.run.request_join_channel",
"translation": "@{{.Name}} is a run participant and wants join this channel. Any member of the channel can invite them.\n"
},
{
"id": "app.user.run.request_update",
"translation": "@here — @{{.Name}} requested a status update for [{{.RunName}}]({{.RunURL}}). \n"
},
{
"id": "app.user.run.status_disable",
"translation": "@{{.Username}} disabled the status updates for [{{.RunName}}]({{.RunURL}})"
},
{
"id": "app.user.run.status_enable",
"translation": "@{{.Username}} enabled the status updates for [{{.RunName}}]({{.RunURL}})"
},
{
"id": "app.user.run.update_status.change_since_last_update",
"translation": "Change since last update"
},
{
"id": "app.user.run.update_status.finish_run",
"translation": "Finish run"
},
{
"id": "app.user.run.update_status.finish_run.placeholder",
"translation": "Also mark the run as finished"
},
{
"id": "app.user.run.update_status.num_channel",
"translation": {
"one": "Provide an update to the stakeholders. This post will be broadcasted to {{.Count}} channel.",
"other": "Provide an update to the stakeholders. This post will be broadcasted to {{.Count}} channels."
}
},
{
"id": "app.user.run.update_status.reminder_for_next_update",
"translation": "Reminder for next update"
},
{
"id": "app.user.run.update_status.submit_label",
"translation": "Update status"
},
{
"id": "app.user.run.update_status.title",
"translation": "Status update"
},
{
"id": "app.user.save.app_error",
"translation": "Unable to save the account."

View File

@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,63 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
type GenericChannelActionWithoutPayload struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Enabled bool `json:"enabled"`
DeleteAt int64 `json:"delete_at"`
ActionType string `json:"action_type"`
TriggerType string `json:"trigger_type"`
}
type GenericChannelAction struct {
GenericChannelActionWithoutPayload
Payload interface{} `json:"payload"`
}
type WelcomeMessagePayload struct {
Message string `json:"message" mapstructure:"message"`
}
type PromptRunPlaybookFromKeywordsPayload struct {
Keywords []string `json:"keywords" mapstructure:"keywords"`
PlaybookID string `json:"playbook_id" mapstructure:"playbook_id"`
}
type CategorizeChannelPayload struct {
CategoryName string `json:"category_name" mapstructure:"category_name"`
}
type WelcomeMessageAction struct {
GenericChannelActionWithoutPayload
Payload WelcomeMessagePayload `json:"payload"`
}
const (
// Action types
ActionTypeWelcomeMessage = "send_welcome_message"
ActionTypePromptRunPlaybook = "prompt_run_playbook"
ActionTypeCategorizeChannel = "categorize_channel"
// Trigger types
TriggerTypeNewMemberJoins = "new_member_joins"
TriggerTypeKeywordsPosted = "keywords"
)
// ChannelActionListOptions specifies the optional parameters to the
// ActionsService.List method.
type ChannelActionListOptions struct {
TriggerType string `url:"trigger_type,omitempty"`
ActionType string `url:"action_type,omitempty"`
}
// ChannelActionCreateOptions specifies the parameters for ActionsService.Create method.
type ChannelActionCreateOptions struct {
ChannelID string `json:"channel_id"`
Enabled bool `json:"enabled"`
ActionType string `json:"action_type"`
TriggerType string `json:"trigger_type"`
Payload interface{} `json:"payload"`
}

View File

@ -1,78 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"context"
"fmt"
"net/http"
)
// ActionsService handles communication with the actions related
// methods of the Playbook API.
type ActionsService struct {
client *Client
}
// Create an action. Returns the id of the newly created action.
func (s *ActionsService) Create(ctx context.Context, channelID string, opts ChannelActionCreateOptions) (string, error) {
actionURL := fmt.Sprintf("actions/channels/%s", channelID)
req, err := s.client.newRequest(http.MethodPost, actionURL, opts)
if err != nil {
return "", err
}
var result struct {
ID string `json:"id"`
}
resp, err := s.client.do(ctx, req, &result)
if err != nil {
return "", err
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("expected status code %d", http.StatusCreated)
}
return result.ID, nil
}
// List the actions in a channel.
func (s *ActionsService) List(ctx context.Context, channelID string, opts ChannelActionListOptions) ([]GenericChannelAction, error) {
actionURL, err := addOptions(fmt.Sprintf("actions/channels/%s", channelID), opts)
if err != nil {
return nil, fmt.Errorf("failed to build options: %w", err)
}
req, err := s.client.newRequest(http.MethodGet, actionURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
result := []GenericChannelAction{}
resp, err := s.client.do(ctx, req, &result)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
resp.Body.Close()
return result, nil
}
// Update an existing action.
func (s *ActionsService) Update(ctx context.Context, action GenericChannelAction) error {
updateURL := fmt.Sprintf("actions/channels/%s/%s", action.ChannelID, action.ID)
req, err := s.client.newRequest(http.MethodPut, updateURL, action)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
if err != nil {
return err
}
return nil
}

View File

@ -1,276 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"reflect"
"strconv"
"github.com/google/go-querystring/query"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)
const (
apiVersion = "v0"
manifestID = "playbooks"
userAgent = "go-client/" + apiVersion
)
// Client manages communication with the Playbooks API.
type Client struct {
// client is the underlying HTTP client used to make API requests.
client *http.Client
// BaseURL is the base HTTP endpoint for the Playbooks plugin.
BaseURL *url.URL
// User agent used when communicating with the Playbooks API.
UserAgent string
// PlaybookRuns is a collection of methods used to interact with playbook runs.
PlaybookRuns *PlaybookRunService
// Playbooks is a collection of methods used to interact with playbooks.
Playbooks *PlaybooksService
// Settings is a collection of methods used to interact with settings.
Settings *SettingsService
// Actions is a collection of methods used to interact with actions.
Actions *ActionsService
// Stats is a collection of methods used to interact with stats.
Stats *StatsService
// Reminders is a collection of methods used to interact with reminders.
Reminders *RemindersService
// Telemetry is a collection of methods used to interact with telemetry.
Telemetry *TelemetryService
}
// New creates a new instance of Client using the configuration from the given Mattermost Client.
func New(client4 *model.Client4) (*Client, error) {
ctx := context.Background()
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: client4.AuthToken},
)
return newClient(client4.URL, oauth2.NewClient(ctx, ts))
}
// newClient creates a new instance of Client from the given URL and http.Client.
func newClient(mattermostSiteURL string, httpClient *http.Client) (*Client, error) {
siteURL, err := url.Parse(mattermostSiteURL)
if err != nil {
return nil, err
}
c := &Client{client: httpClient, BaseURL: siteURL, UserAgent: userAgent}
c.PlaybookRuns = &PlaybookRunService{c}
c.Playbooks = &PlaybooksService{c}
c.Settings = &SettingsService{c}
c.Actions = &ActionsService{c}
c.Stats = &StatsService{c}
c.Reminders = &RemindersService{c}
c.Telemetry = &TelemetryService{c}
return c, nil
}
// newRequest creates an API request, JSON-encoding any given body parameter.
func (c *Client) newRequest(method, endpoint string, body interface{}) (*http.Request, error) {
u, err := c.BaseURL.Parse(buildAPIURL(endpoint))
if err != nil {
return nil, errors.Wrapf(err, "invalid endpoint %s", endpoint)
}
var buf io.ReadWriter
if body != nil {
buf = &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
err = enc.Encode(body)
if err != nil {
return nil, errors.Wrapf(err, "failed to encode body %s", body)
}
}
req, err := http.NewRequest(method, u.String(), buf)
if err != nil {
return nil, errors.Wrapf(err, "failed to create http request for url %s", u)
}
if buf != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
return req, nil
}
// buildAPIURL constructs the path to the given endpoint.
func buildAPIURL(endpoint string) string {
return fmt.Sprintf("plugins/%s/api/%s/%s", manifestID, apiVersion, endpoint)
}
// do sends an API request and returns the API response.
//
// The API response is JSON decoded and stored in the value pointed to by v, or returned as an
// error if an API error has occurred. If v implements the io.Writer
// interface, the raw response body will be written to v, without attempting to
// first decode it.
func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) {
if ctx == nil {
return nil, errors.New("context must be non-nil")
}
req = req.WithContext(ctx)
resp, err := c.client.Do(req)
if err != nil {
select {
case <-ctx.Done():
return nil, errors.Wrapf(ctx.Err(), "client err=%s", err.Error())
default:
}
return nil, err
}
defer resp.Body.Close()
err = checkResponse(resp)
if err != nil {
return resp, err
}
if v != nil {
if w, ok := v.(io.Writer); ok {
if _, err = io.Copy(w, resp.Body); err != nil {
return nil, err
}
} else {
body, _ := ioutil.ReadAll(resp.Body)
decErr := json.NewDecoder(bytes.NewReader(body)).Decode(v)
if decErr == io.EOF {
// TODO: Confirm if this happens only on empty bodies. If so, check that first before decoding.
decErr = nil // ignore EOF errors caused by empty response body
}
if decErr != nil {
err = decErr
}
}
}
return resp, err
}
type GraphQLInput struct {
Query string `json:"query"`
OperationName string `json:"operationName"`
Variables map[string]interface{} `json:"variables"`
}
func (c *Client) DoGraphql(ctx context.Context, input *GraphQLInput, v interface{}) error {
url := "query"
req, err := c.newRequest(http.MethodPost, url, input)
if err != nil {
return err
}
_, err = c.do(ctx, req, v)
if err != nil {
return err
}
return nil
}
// checkResponse checks the API response for an error.
//
// Any response with a status code outside 2xx is considered an error, and its body inspected for
// an optional `Error` property in a JSON struct.
func checkResponse(r *http.Response) error {
if c := r.StatusCode; http.StatusOK <= c && c <= 299 {
return nil
}
errorResponse := &ErrorResponse{
StatusCode: r.StatusCode,
Method: r.Request.Method,
URL: r.Request.URL.String(),
}
data, err := ioutil.ReadAll(r.Body)
if err != nil {
errorResponse.Err = fmt.Errorf("failed to read response body: %w", err)
}
r.Body = ioutil.NopCloser(bytes.NewBuffer(data))
if data != nil {
_ = json.Unmarshal(data, errorResponse)
}
return errorResponse
}
// addOption adds the given parameter as an URL query parameters to s.
func addOption(s string, name, value string) (string, error) {
u, err := url.Parse(s)
if err != nil {
return s, errors.Wrapf(err, "failed to parse %s", s)
}
qa := u.Query()
qa.Add(name, value)
u.RawQuery = qa.Encode()
return u.String(), nil
}
// addOptions adds the parameters in opts as URL query parameters to s. opts
// must be a struct whose fields may contain "url" tags.
func addOptions(s string, opts interface{}) (string, error) {
v := reflect.ValueOf(opts)
if v.Kind() == reflect.Ptr && v.IsNil() {
return s, nil
}
u, err := url.Parse(s)
if err != nil {
return s, errors.Wrapf(err, "failed to parse %s", s)
}
qs, err := query.Values(opts)
if err != nil {
return s, errors.Wrapf(err, "failed to opts %+v", opts)
}
// Append to the existing query parameters.
qa := u.Query()
for key, values := range qs {
for _, value := range values {
qa.Add(key, value)
}
}
u.RawQuery = qa.Encode()
return u.String(), nil
}
// addPaginationOptions adds the given pagination parameters as URL query parameters to s.
func addPaginationOptions(s string, page, perPage int) (string, error) {
u, err := url.Parse(s)
if err != nil {
return s, errors.Wrapf(err, "failed to parse %s", s)
}
qa := u.Query()
qa.Add("page", strconv.Itoa(page))
qa.Add("per_page", strconv.Itoa(perPage))
u.RawQuery = qa.Encode()
return u.String(), nil
}

View File

@ -1,5 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Package client provides an HTTP client for using the Playbooks API.
package client

View File

@ -1,36 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client_test
import (
"context"
"fmt"
"log"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
)
func Example() {
ctx := context.Background()
client4 := model.NewAPIv4Client("http://localhost:8065")
_, _, err := client4.Login(context.Background(), "test@example.com", "testtest")
if err != nil {
log.Fatal(err)
}
c, err := client.New(client4)
if err != nil {
log.Fatal(err)
}
playbookRunID := "h4n3h7s1qjf5pkis4dn6cuxgwa"
playbookRun, err := c.PlaybookRuns.Get(ctx, playbookRunID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Playbook Run Name: %s\n", playbookRun.Name)
}

View File

@ -1,52 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"encoding/json"
"errors"
"fmt"
)
// ErrorResponse is an error from an API request.
type ErrorResponse struct {
// Method is the HTTP verb used in the API request.
Method string
// URL is the HTTP endpoint used in the API request.
URL string
// StatusCode is the HTTP status code returned by the API.
StatusCode int
// Err is the error parsed from the API response.
Err error `json:"error"`
}
func (e *ErrorResponse) UnmarshalJSON(data []byte) error {
type Alias ErrorResponse
temp := &struct {
Err string `json:"error"`
*Alias
}{
Alias: (*Alias)(e),
}
// Try to extract a structured error from the body, otherwise fall back to using
// the whole body as the error message.
if err := json.Unmarshal(data, &temp); err != nil || temp.Err == "" {
e.Err = errors.New(string(data))
} else {
e.Err = errors.New(temp.Err)
}
return nil
}
// Unwrap exposes the underlying error of an ErrorResponse.
func (e *ErrorResponse) Unwrap() error {
return e.Err
}
// Error describes the error from the API request.
func (e *ErrorResponse) Error() string {
return fmt.Sprintf("%s %s [%d]: %v", e.Method, e.URL, e.StatusCode, e.Err)
}

View File

@ -1,192 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"fmt"
"gopkg.in/guregu/null.v4"
)
// Playbook represents the planning before a playbook run is initiated.
type Playbook struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Public bool `json:"public"`
TeamID string `json:"team_id"`
CreatePublicPlaybookRun bool `json:"create_public_playbook_run"`
CreateAt int64 `json:"create_at"`
DeleteAt int64 `json:"delete_at"`
NumStages int64 `json:"num_stages"`
NumSteps int64 `json:"num_steps"`
Checklists []Checklist `json:"checklists"`
Members []PlaybookMember `json:"members"`
ReminderMessageTemplate string `json:"reminder_message_template"`
ReminderTimerDefaultSeconds int64 `json:"reminder_timer_default_seconds"`
InvitedUserIDs []string `json:"invited_user_ids"`
InvitedGroupIDs []string `json:"invited_group_ids"`
InviteUsersEnabled bool `json:"invite_users_enabled"`
DefaultOwnerID string `json:"default_owner_id"`
DefaultOwnerEnabled bool `json:"default_owner_enabled"`
BroadcastChannelIDs []string `json:"broadcast_channel_ids"`
BroadcastEnabled bool `json:"broadcast_enabled"`
WebhookOnCreationURLs []string `json:"webhook_on_creation_urls"`
WebhookOnCreationEnabled bool `json:"webhook_on_creation_enabled"`
Metrics []PlaybookMetricConfig `json:"metrics"`
CreateChannelMemberOnNewParticipant bool `json:"create_channel_member_on_new_participant"`
RemoveChannelMemberOnRemovedParticipant bool `json:"remove_channel_member_on_removed_participant"`
ChannelID string `json:"channel_id" export:"channel_id"`
ChannelMode ChannelPlaybookMode `json:"channel_mode" export:"channel_mode"`
}
type PlaybookMember struct {
UserID string `json:"user_id"`
Roles []string `json:"roles"`
SchemeRoles []string `json:"scheme_roles"`
}
const (
MetricTypeDuration = "metric_duration"
MetricTypeCurrency = "metric_currency"
MetricTypeInteger = "metric_integer"
)
// Checklist represents a checklist in a playbook
type Checklist struct {
ID string `json:"id"`
Title string `json:"title"`
Items []ChecklistItem `json:"items"`
}
// ChecklistItem represents an item in a checklist
type ChecklistItem struct {
ID string `json:"id"`
Title string `json:"title"`
State string `json:"state"`
StateModified int64 `json:"state_modified"`
AssigneeID string `json:"assignee_id"`
AssigneeModified int64 `json:"assignee_modified"`
Command string `json:"command"`
CommandLastRun int64 `json:"command_last_run"`
Description string `json:"description"`
LastSkipped int64 `json:"delete_at"`
DueDate int64 `json:"due_date"`
TaskActions []TaskAction `json:"task_actions"`
}
// TaskAction represents a task action in an item
type TaskAction struct {
Trigger TriggerAction `json:"trigger"`
Actions []TriggerAction `json:"actions"`
}
// TriggerAction represents a trigger or action in a Task Action
type TriggerAction struct {
Type string `json:"type"`
Payload string `json:"payload"`
}
// PlaybookCreateOptions specifies the parameters for PlaybooksService.Create method.
type PlaybookCreateOptions struct {
Title string `json:"title"`
Description string `json:"description"`
TeamID string `json:"team_id"`
Public bool `json:"public"`
CreatePublicPlaybookRun bool `json:"create_public_playbook_run"`
Checklists []Checklist `json:"checklists"`
Members []PlaybookMember `json:"members"`
BroadcastChannelID string `json:"broadcast_channel_id"`
ReminderMessageTemplate string `json:"reminder_message_template"`
ReminderTimerDefaultSeconds int64 `json:"reminder_timer_default_seconds"`
InvitedUserIDs []string `json:"invited_user_ids"`
InvitedGroupIDs []string `json:"invited_group_ids"`
InviteUsersEnabled bool `json:"invite_users_enabled"`
DefaultOwnerID string `json:"default_owner_id"`
DefaultOwnerEnabled bool `json:"default_owner_enabled"`
BroadcastChannelIDs []string `json:"broadcast_channel_ids"`
BroadcastEnabled bool `json:"broadcast_enabled"`
Metrics []PlaybookMetricConfig `json:"metrics"`
CreateChannelMemberOnNewParticipant bool `json:"create_channel_member_on_new_participant"`
RemoveChannelMemberOnRemovedParticipant bool `json:"remove_channel_member_on_removed_participant"`
ChannelID string `json:"channel_id" export:"channel_id"`
ChannelMode ChannelPlaybookMode `json:"channel_mode" export:"channel_mode"`
}
type PlaybookMetricConfig struct {
ID string `json:"id"`
PlaybookID string `json:"playbook_id"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Target null.Int `json:"target"`
}
// PlaybookListOptions specifies the optional parameters to the
// PlaybooksService.List method.
type PlaybookListOptions struct {
Sort Sort `url:"sort,omitempty"`
Direction SortDirection `url:"direction,omitempty"`
SearchTeam string `url:"search_term,omitempty"`
WithArchived bool `url:"with_archived,omitempty"`
}
type GetPlaybooksResults struct {
TotalCount int `json:"total_count"`
PageCount int `json:"page_count"`
HasMore bool `json:"has_more"`
Items []Playbook `json:"items"`
}
type PlaybookStats struct {
RunsInProgress int `json:"runs_in_progress"`
ParticipantsActive int `json:"participants_active"`
RunsFinishedPrev30Days int `json:"runs_finished_prev_30_days"`
RunsFinishedPercentageChange int `json:"runs_finished_percentage_change"`
RunsStartedPerWeek []int `json:"runs_started_per_week"`
RunsStartedPerWeekTimes [][]int64 `json:"runs_started_per_week_times"`
ActiveRunsPerDay []int `json:"active_runs_per_day"`
ActiveRunsPerDayTimes [][]int64 `json:"active_runs_per_day_times"`
ActiveParticipantsPerDay []int `json:"active_participants_per_day"`
ActiveParticipantsPerDayTimes [][]int64 `json:"active_participants_per_day_times"`
MetricOverallAverage []null.Int `json:"metric_overall_average"`
MetricRollingAverage []null.Int `json:"metric_rolling_average"`
MetricRollingAverageChange []null.Int `json:"metric_rolling_average_change"`
MetricValueRange [][]int64 `json:"metric_value_range"`
MetricRollingValues [][]int64 `json:"metric_rolling_values"`
LastXRunNames []string `json:"last_x_run_names"`
}
type ChannelPlaybookMode int
const (
PlaybookRunCreateNewChannel ChannelPlaybookMode = iota
PlaybookRunLinkExistingChannel
)
var channelPlaybookTypes = [...]string{
PlaybookRunCreateNewChannel: "create_new_channel",
PlaybookRunLinkExistingChannel: "link_existing_channel",
}
// String creates the string version of the TelemetryTrack
func (cpm ChannelPlaybookMode) String() string {
return channelPlaybookTypes[cpm]
}
// MarshalText converts a ChannelPlaybookMode to a string for serializers (including JSON)
func (cpm ChannelPlaybookMode) MarshalText() ([]byte, error) {
return []byte(channelPlaybookTypes[cpm]), nil
}
// UnmarshalText parses a ChannelPlaybookMode from text. For deserializers (including JSON)
func (cpm *ChannelPlaybookMode) UnmarshalText(text []byte) error {
for i, st := range channelPlaybookTypes {
if st == string(text) {
*cpm = ChannelPlaybookMode(i)
return nil
}
}
return fmt.Errorf("unknown ChannelPlaybookMode: %s", string(text))
}

View File

@ -1,292 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"time"
"gopkg.in/guregu/null.v4"
)
// Me is a constant that refers to the current user, and can be used in various APIs in place of
// explicitly specifying the current user's id.
const Me = "me"
// PlaybookRun represents a playbook run.
type PlaybookRun struct {
ID string `json:"id"`
Name string `json:"name"`
Summary string `json:"summary"`
SummaryModifiedAt int64 `json:"summary_modified_at"`
OwnerUserID string `json:"owner_user_id"`
ReporterUserID string `json:"reporter_user_id"`
TeamID string `json:"team_id"`
ChannelID string `json:"channel_id"`
CreateAt int64 `json:"create_at"`
EndAt int64 `json:"end_at"`
DeleteAt int64 `json:"delete_at"`
ActiveStage int `json:"active_stage"`
ActiveStageTitle string `json:"active_stage_title"`
PostID string `json:"post_id"`
PlaybookID string `json:"playbook_id"`
Checklists []Checklist `json:"checklists"`
StatusPosts []StatusPost `json:"status_posts"`
CurrentStatus string `json:"current_status"`
LastStatusUpdateAt int64 `json:"last_status_update_at"`
ReminderPostID string `json:"reminder_post_id"`
PreviousReminder time.Duration `json:"previous_reminder"`
ReminderTimerDefaultSeconds int64 `json:"reminder_timer_default_seconds"`
StatusUpdateEnabled bool `json:"status_update_enabled"`
BroadcastChannelIDs []string `json:"broadcast_channel_ids"`
WebhookOnStatusUpdateURLs []string `json:"webhook_on_status_update_urls"`
StatusUpdateBroadcastChannelsEnabled bool `json:"status_update_broadcast_channels_enabled"`
StatusUpdateBroadcastWebhooksEnabled bool `json:"status_update_broadcast_webhooks_enabled"`
ReminderMessageTemplate string `json:"reminder_message_template"`
InvitedUserIDs []string `json:"invited_user_ids"`
InvitedGroupIDs []string `json:"invited_group_ids"`
TimelineEvents []TimelineEvent `json:"timeline_events"`
DefaultOwnerID string `json:"default_owner_id"`
WebhookOnCreationURLs []string `json:"webhook_on_creation_urls"`
Retrospective string `json:"retrospective"`
RetrospectivePublishedAt int64 `json:"retrospective_published_at"`
RetrospectiveWasCanceled bool `json:"retrospective_was_canceled"`
RetrospectiveReminderIntervalSeconds int64 `json:"retrospective_reminder_interval_seconds"`
RetrospectiveEnabled bool `json:"retrospective_enabled"`
MessageOnJoin string `json:"message_on_join"`
ParticipantIDs []string `json:"participant_ids"`
CategoryName string `json:"category_name"`
MetricsData []RunMetricData `json:"metrics_data"`
CreateChannelMemberOnNewParticipant bool `json:"create_channel_member_on_new_participant"`
RemoveChannelMemberOnRemovedParticipant bool `json:"remove_channel_member_on_removed_participant"`
}
// StatusPost is information added to the playbook run when selecting from the db and sent to the
// client; it is not saved to the db.
type StatusPost struct {
ID string `json:"id"`
CreateAt int64 `json:"create_at"`
DeleteAt int64 `json:"delete_at"`
}
// StatusPostComplete is the complete status update (post)
// it's similar to StatusPost but with extended info.
type StatusPostComplete struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
Message string `json:"message"`
AuthorUserName string `json:"author_user_name"`
}
// Metadata tracks ancillary metadata about a playbook run.
type Metadata struct {
ChannelName string `json:"channel_name"`
ChannelDisplayName string `json:"channel_display_name"`
TeamName string `json:"team_name"`
NumParticipants int64 `json:"num_participants"`
TotalPosts int64 `json:"total_posts"`
Followers []string `json:"followers"`
}
// TimelineEventType describes a type of timeline event.
type TimelineEventType string
const (
PlaybookRunCreated TimelineEventType = "incident_created"
TaskStateModified TimelineEventType = "task_state_modified"
StatusUpdated TimelineEventType = "status_updated"
StatusUpdateRequested TimelineEventType = "status_update_requested"
OwnerChanged TimelineEventType = "owner_changed"
AssigneeChanged TimelineEventType = "assignee_changed"
RanSlashCommand TimelineEventType = "ran_slash_command"
EventFromPost TimelineEventType = "event_from_post"
UserJoinedLeft TimelineEventType = "user_joined_left"
PublishedRetrospective TimelineEventType = "published_retrospective"
CanceledRetrospective TimelineEventType = "canceled_retrospective"
RunFinished TimelineEventType = "run_finished"
RunRestored TimelineEventType = "run_restored"
StatusUpdatesEnabled TimelineEventType = "status_updates_enabled"
StatusUpdatesDisabled TimelineEventType = "status_updates_disabled"
)
// TimelineEvent represents an event recorded to a playbook run's timeline.
type TimelineEvent struct {
ID string `json:"id"`
PlaybookRunID string `json:"playbook_run"`
CreateAt int64 `json:"create_at"`
DeleteAt int64 `json:"delete_at"`
EventAt int64 `json:"event_at"`
EventType TimelineEventType `json:"event_type"`
Summary string `json:"summary"`
Details string `json:"details"`
PostID string `json:"post_id"`
SubjectUserID string `json:"subject_user_id"`
CreatorUserID string `json:"creator_user_id"`
}
// PlaybookRunCreateOptions specifies the parameters for PlaybookRunService.Create method.
type PlaybookRunCreateOptions struct {
Name string `json:"name"`
OwnerUserID string `json:"owner_user_id"`
TeamID string `json:"team_id"`
ChannelID string `json:"channel_id"`
Description string `json:"description"`
PostID string `json:"post_id"`
PlaybookID string `json:"playbook_id"`
CreatePublicRun *bool `json:"create_public_run"`
Type string `json:"type"`
}
// RunAction represents the run action settings. Frontend passes this struct to update settings.
type RunAction struct {
BroadcastChannelIDs []string `json:"broadcast_channel_ids"`
WebhookOnStatusUpdateURLs []string `json:"webhook_on_status_update_urls"`
StatusUpdateBroadcastChannelsEnabled bool `json:"status_update_broadcast_channels_enabled"`
StatusUpdateBroadcastWebhooksEnabled bool `json:"status_update_broadcast_webhooks_enabled"`
}
// RetrospectiveUpdate represents the run retrospective info
type RetrospectiveUpdate struct {
Text string `json:"retrospective"`
Metrics []RunMetricData `json:"metrics"`
}
// Sort enumerates the available fields we can sort on.
type Sort string
const (
// SortByCreateAt sorts by the "create_at" field. It is the default.
SortByCreateAt Sort = "create_at"
// SortByID sorts by the "id" field.
SortByID Sort = "id"
// SortByName sorts by the "name" field.
SortByName Sort = "name"
// SortByOwnerUserID sorts by the "owner_user_id" field.
SortByOwnerUserID Sort = "owner_user_id"
// SortByTeamID sorts by the "team_id" field.
SortByTeamID Sort = "team_id"
// SortByEndAt sorts by the "end_at" field.
SortByEndAt Sort = "end_at"
// SortBySteps sorts playbooks by the number of steps in the playbook.
SortBySteps Sort = "steps"
// SortByStages sorts playbooks by the number of stages in the playbook.
SortByStages Sort = "stages"
// SortByTitle sorts by the "title" field.
SortByTitle Sort = "title"
// SortByRuns sorts by the number of times a playbook has been run.
SortByRuns Sort = "runs"
)
// SortDirection determines whether results are sorted ascending or descending.
type SortDirection string
const (
// Desc sorts the results in descending order.
SortDesc SortDirection = "desc"
// Asc sorts the results in ascending order.
SortAsc SortDirection = "asc"
)
// PlaybookRunListOptions specifies the optional parameters to the
// PlaybookRunService.List method.
type PlaybookRunListOptions struct {
// TeamID filters playbook runs to those in the given team.
TeamID string `url:"team_id,omitempty"`
Sort Sort `url:"sort,omitempty"`
Direction SortDirection `url:"direction,omitempty"`
// Statuses filters by InProgress or Ended; defaults to All when no status specified.
Statuses []Status `url:"statuses,omitempty"`
// OwnerID filters by owner's Mattermost user ID. Defaults to blank (no filter). Specify "me" for current user.
OwnerID string `url:"owner_user_id,omitempty"`
// ParticipantID filters playbook runs that have this user as a participant. Defaults to blank (no filter). Specify "me" for current user.
ParticipantID string `url:"participant_id,omitempty"`
// ParticipantOrFollowerID filters playbook runs that have this user as member or as follower. Defaults to blank (no filter). Specify "me" for current user.
ParticipantOrFollowerID string `url:"participant_or_follower,omitempty"`
// SearchTerm returns results of the search term and respecting the other header filter options.
// The search term acts as a filter and respects the Sort and Direction fields (i.e., results are
// not returned in relevance order).
SearchTerm string `url:"search_term,omitempty"`
// PlaybookID filters playbook runs that are derived from this playbook id.
// Defaults to blank (no filter).
PlaybookID string `url:"playbook_id,omitempty"`
// ActiveGTE filters playbook runs that were active after (or equal) to the unix time given (in millis).
// A value of 0 means the filter is ignored (which is the default).
ActiveGTE int64 `url:"active_gte,omitempty"`
// ActiveLT filters playbook runs that were active before the unix time given (in millis).
// A value of 0 means the filter is ignored (which is the default).
ActiveLT int64 `url:"active_lt,omitempty"`
// StartedGTE filters playbook runs that were started after (or equal) to the unix time given (in millis).
// A value of 0 means the filter is ignored (which is the default).
StartedGTE int64 `url:"started_gte,omitempty"`
// StartedLT filters playbook runs that were started before the unix time given (in millis).
// A value of 0 means the filter is ignored (which is the default).
StartedLT int64 `url:"started_lt,omitempty"`
}
// PlaybookRunList contains the paginated result.
type PlaybookRunList struct {
TotalCount int `json:"total_count"`
PageCount int `json:"page_count"`
HasMore bool `json:"has_more"`
Items []*PlaybookRun
}
// Status is the type used to specify the activity status of the playbook run.
type Status string
const (
StatusInProgress Status = "InProgress"
StatusFinished Status = "Finished"
)
type GetPlaybookRunsResults struct {
TotalCount int `json:"total_count"`
PageCount int `json:"page_count"`
HasMore bool `json:"has_more"`
Items []PlaybookRun `json:"items"`
}
// StatusUpdateOptions are the fields required to update a playbook run's status
type StatusUpdateOptions struct {
Message string `json:"message"`
Reminder time.Duration `json:"reminder"`
FinishRun bool `json:"finish_run"`
}
type RunMetricData struct {
MetricConfigID string `json:"metric_config_id"`
Value null.Int `json:"value"`
}
// OwnerInfo holds the summary information of a owner.
type OwnerInfo struct {
UserID string `json:"user_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Nickname string `json:"nickname"`
}

View File

@ -1,375 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"context"
"fmt"
"net/http"
"time"
)
// PlaybookRunService handles communication with the playbook run related
// methods of the Playbooks API.
type PlaybookRunService struct {
client *Client
}
// Get a playbook run.
func (s *PlaybookRunService) Get(ctx context.Context, playbookRunID string) (*PlaybookRun, error) {
playbookRunURL := fmt.Sprintf("runs/%s", playbookRunID)
req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil)
if err != nil {
return nil, err
}
playbookRun := new(PlaybookRun)
resp, err := s.client.do(ctx, req, playbookRun)
if err != nil {
return nil, err
}
resp.Body.Close()
return playbookRun, nil
}
// GetByChannelID gets a playbook run by ChannelID.
func (s *PlaybookRunService) GetByChannelID(ctx context.Context, channelID string) (*PlaybookRun, error) {
channelURL := fmt.Sprintf("runs/channel/%s", channelID)
req, err := s.client.newRequest(http.MethodGet, channelURL, nil)
if err != nil {
return nil, err
}
playbookRun := new(PlaybookRun)
resp, err := s.client.do(ctx, req, playbookRun)
if err != nil {
return nil, err
}
resp.Body.Close()
return playbookRun, nil
}
// Get a playbook run's metadata.
func (s *PlaybookRunService) GetMetadata(ctx context.Context, playbookRunID string) (*Metadata, error) {
playbookRunURL := fmt.Sprintf("runs/%s/metadata", playbookRunID)
req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil)
if err != nil {
return nil, err
}
playbookRun := new(Metadata)
resp, err := s.client.do(ctx, req, playbookRun)
if err != nil {
return nil, err
}
resp.Body.Close()
return playbookRun, nil
}
// Get all playbook status updates.
func (s *PlaybookRunService) GetStatusUpdates(ctx context.Context, playbookRunID string) ([]StatusPostComplete, error) {
playbookRunURL := fmt.Sprintf("runs/%s/status-updates", playbookRunID)
req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil)
if err != nil {
return nil, err
}
var statusUpdates []StatusPostComplete
resp, err := s.client.do(ctx, req, &statusUpdates)
if err != nil {
return nil, err
}
resp.Body.Close()
return statusUpdates, nil
}
// List the playbook runs.
func (s *PlaybookRunService) List(ctx context.Context, page, perPage int, opts PlaybookRunListOptions) (*GetPlaybookRunsResults, error) {
playbookRunURL := "runs"
playbookRunURL, err := addOptions(playbookRunURL, opts)
if err != nil {
return nil, fmt.Errorf("failed to build options: %w", err)
}
playbookRunURL, err = addPaginationOptions(playbookRunURL, page, perPage)
if err != nil {
return nil, fmt.Errorf("failed to build pagination options: %w", err)
}
req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
result := &GetPlaybookRunsResults{}
resp, err := s.client.do(ctx, req, result)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
resp.Body.Close()
return result, nil
}
// Create a playbook run.
func (s *PlaybookRunService) Create(ctx context.Context, opts PlaybookRunCreateOptions) (*PlaybookRun, error) {
playbookRunURL := "runs"
req, err := s.client.newRequest(http.MethodPost, playbookRunURL, opts)
if err != nil {
return nil, err
}
playbookRun := new(PlaybookRun)
resp, err := s.client.do(ctx, req, playbookRun)
if err != nil {
return nil, err
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("expected status code %d", http.StatusCreated)
}
return playbookRun, nil
}
func (s *PlaybookRunService) UpdateStatus(ctx context.Context, playbookRunID string, message string, reminderInSeconds int64) error {
updateURL := fmt.Sprintf("runs/%s/status", playbookRunID)
opts := StatusUpdateOptions{
Message: message,
Reminder: time.Duration(reminderInSeconds),
}
req, err := s.client.newRequest(http.MethodPost, updateURL, opts)
if err != nil {
return err
}
resp, err := s.client.do(ctx, req, nil)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("expected status code %d", http.StatusOK)
}
return nil
}
func (s *PlaybookRunService) RequestUpdate(ctx context.Context, playbookRunID, userID string) error {
requestURL := fmt.Sprintf("runs/%s/request-update", playbookRunID)
req, err := s.client.newRequest(http.MethodPost, requestURL, nil)
if err != nil {
return err
}
resp, err := s.client.do(ctx, req, nil)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("expected status code %d", http.StatusOK)
}
return err
}
func (s *PlaybookRunService) Finish(ctx context.Context, playbookRunID string) error {
finishURL := fmt.Sprintf("runs/%s/finish", playbookRunID)
req, err := s.client.newRequest(http.MethodPut, finishURL, nil)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
if err != nil {
return err
}
return nil
}
func (s *PlaybookRunService) CreateChecklist(ctx context.Context, playbookRunID string, checklist Checklist) error {
createURL := fmt.Sprintf("runs/%s/checklists", playbookRunID)
req, err := s.client.newRequest(http.MethodPost, createURL, checklist)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) RemoveChecklist(ctx context.Context, playbookRunID string, checklistNumber int) error {
createURL := fmt.Sprintf("runs/%s/checklists/%d", playbookRunID, checklistNumber)
req, err := s.client.newRequest(http.MethodDelete, createURL, nil)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) RenameChecklist(ctx context.Context, playbookRunID string, checklistNumber int, newTitle string) error {
createURL := fmt.Sprintf("runs/%s/checklists/%d/rename", playbookRunID, checklistNumber)
req, err := s.client.newRequest(http.MethodPut, createURL, struct{ Title string }{newTitle})
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) AddChecklistItem(ctx context.Context, playbookRunID string, checklistNumber int, checklistItem ChecklistItem) error {
addURL := fmt.Sprintf("runs/%s/checklists/%d/add", playbookRunID, checklistNumber)
req, err := s.client.newRequest(http.MethodPost, addURL, checklistItem)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) MoveChecklist(ctx context.Context, playbookRunID string, sourceChecklistIdx, destChecklistIdx int) error {
createURL := fmt.Sprintf("runs/%s/checklists/move", playbookRunID)
body := struct {
SourceChecklistIdx int `json:"source_checklist_idx"`
DestChecklistIdx int `json:"dest_checklist_idx"`
}{sourceChecklistIdx, destChecklistIdx}
req, err := s.client.newRequest(http.MethodPost, createURL, body)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) MoveChecklistItem(ctx context.Context, playbookRunID string, sourceChecklistIdx, sourceItemIdx, destChecklistIdx, destItemIdx int) error {
createURL := fmt.Sprintf("runs/%s/checklists/move-item", playbookRunID)
body := struct {
SourceChecklistIdx int `json:"source_checklist_idx"`
SourceItemIdx int `json:"source_item_idx"`
DestChecklistIdx int `json:"dest_checklist_idx"`
DestItemIdx int `json:"dest_item_idx"`
}{sourceChecklistIdx, sourceItemIdx, destChecklistIdx, destItemIdx}
req, err := s.client.newRequest(http.MethodPost, createURL, body)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
// UpdateRetrospective updates the run's retrospective info
func (s *PlaybookRunService) UpdateRetrospective(ctx context.Context, playbookRunID, userID string, retroUpdate RetrospectiveUpdate) error {
createURL := fmt.Sprintf("runs/%s/retrospective", playbookRunID)
req, err := s.client.newRequest(http.MethodPost, createURL, retroUpdate)
if err != nil {
return err
}
resp, err := s.client.do(ctx, req, nil)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("expected status code %d", http.StatusOK)
}
return err
}
// PublishRetrospective publishes the run's retrospective
func (s *PlaybookRunService) PublishRetrospective(ctx context.Context, playbookRunID, userID string, retroUpdate RetrospectiveUpdate) error {
createURL := fmt.Sprintf("runs/%s/retrospective/publish", playbookRunID)
req, err := s.client.newRequest(http.MethodPost, createURL, retroUpdate)
if err != nil {
return err
}
resp, err := s.client.do(ctx, req, nil)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("expected status code %d", http.StatusOK)
}
return err
}
func (s *PlaybookRunService) SetItemAssignee(ctx context.Context, playbookRunID string, checklistIdx int, itemIdx int, assigneeID string) error {
createURL := fmt.Sprintf("runs/%s/checklists/%d/item/%d/assignee", playbookRunID, checklistIdx, itemIdx)
body := struct {
AssigneeID string `json:"assignee_id"`
}{assigneeID}
req, err := s.client.newRequest(http.MethodPut, createURL, body)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) SetItemCommand(ctx context.Context, playbookRunID string, checklistIdx int, itemIdx int, newCommand string) error {
createURL := fmt.Sprintf("runs/%s/checklists/%d/item/%d/command", playbookRunID, checklistIdx, itemIdx)
body := struct {
Command string `json:"command"`
}{newCommand}
req, err := s.client.newRequest(http.MethodPut, createURL, body)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) RunItemCommand(ctx context.Context, playbookRunID string, checklistIdx int, itemIdx int) error {
createURL := fmt.Sprintf("runs/%s/checklists/%d/item/%d/run", playbookRunID, checklistIdx, itemIdx)
req, err := s.client.newRequest(http.MethodPost, createURL, nil)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
func (s *PlaybookRunService) SetItemDueDate(ctx context.Context, playbookRunID string, checklistIdx int, itemIdx int, duedate int64) error {
createURL := fmt.Sprintf("runs/%s/checklists/%d/item/%d/duedate", playbookRunID, checklistIdx, itemIdx)
body := struct {
DueDate int64 `json:"due_date"`
}{duedate}
req, err := s.client.newRequest(http.MethodPut, createURL, body)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
return err
}
// Get a playbook run.
func (s *PlaybookRunService) GetOwners(ctx context.Context) ([]OwnerInfo, error) {
req, err := s.client.newRequest(http.MethodGet, "runs/owners", nil)
if err != nil {
return nil, err
}
owners := make([]OwnerInfo, 0)
resp, err := s.client.do(ctx, req, &owners)
if err != nil {
return nil, err
}
resp.Body.Close()
return owners, nil
}

View File

@ -1,77 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client_test
import (
"context"
"fmt"
"log"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
)
func ExamplePlaybookRunService_Get() {
ctx := context.Background()
client4 := model.NewAPIv4Client("http://localhost:8065")
client4.Login(context.Background(), "test@example.com", "testtest")
c, err := client.New(client4)
if err != nil {
log.Fatal(err)
}
playbookRunID := "h4n3h7s1qjf5pkis4dn6cuxgwa"
playbookRun, err := c.PlaybookRuns.Get(ctx, playbookRunID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Playbook Run Name: %s\n", playbookRun.Name)
}
func ExamplePlaybookRunService_List() {
ctx := context.Background()
client4 := model.NewAPIv4Client("http://localhost:8065")
_, _, err := client4.Login(context.Background(), "test@example.com", "testtest")
if err != nil {
log.Fatal(err.Error())
}
teams, _, err := client4.GetAllTeams(context.Background(), "", 0, 1)
if err != nil {
log.Fatal(err.Error())
}
if len(teams) == 0 {
log.Fatal("no teams for this user")
}
c, err := client.New(client4)
if err != nil {
log.Fatal(err)
}
var playbookRuns []client.PlaybookRun
for page := 0; ; page++ {
result, err := c.PlaybookRuns.List(ctx, page, 100, client.PlaybookRunListOptions{
TeamID: teams[0].Id,
Sort: client.SortByCreateAt,
Direction: client.SortDesc,
})
if err != nil {
log.Fatal(err)
}
playbookRuns = append(playbookRuns, result.Items...)
if !result.HasMore {
break
}
}
for _, playbookRun := range playbookRuns {
fmt.Printf("Playbook Run Name: %s\n", playbookRun.Name)
}
}

View File

@ -1,269 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"github.com/pkg/errors"
)
// PlaybooksService handles communication with the playbook related
// methods of the Playbook API.
type PlaybooksService struct {
client *Client
}
// Get a playbook.
func (s *PlaybooksService) Get(ctx context.Context, playbookID string) (*Playbook, error) {
playbookURL := fmt.Sprintf("playbooks/%s", playbookID)
req, err := s.client.newRequest(http.MethodGet, playbookURL, nil)
if err != nil {
return nil, err
}
playbook := new(Playbook)
resp, err := s.client.do(ctx, req, playbook)
if err != nil {
return nil, err
}
resp.Body.Close()
return playbook, nil
}
// List the playbooks.
func (s *PlaybooksService) List(ctx context.Context, teamId string, page, perPage int, opts PlaybookListOptions) (*GetPlaybooksResults, error) {
playbookURL := "playbooks"
playbookURL, err := addOption(playbookURL, "team_id", teamId)
if err != nil {
return nil, fmt.Errorf("failed to build options: %w", err)
}
playbookURL, err = addPaginationOptions(playbookURL, page, perPage)
if err != nil {
return nil, fmt.Errorf("failed to build pagination options: %w", err)
}
playbookURL, err = addOptions(playbookURL, opts)
if err != nil {
return nil, fmt.Errorf("failed to build options: %w", err)
}
req, err := s.client.newRequest(http.MethodGet, playbookURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
result := &GetPlaybooksResults{}
resp, err := s.client.do(ctx, req, result)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
resp.Body.Close()
return result, nil
}
// Create a playbook. Returns the id of the newly created playbook
func (s *PlaybooksService) Create(ctx context.Context, opts PlaybookCreateOptions) (string, error) {
// For ease of use set the default if not specificed so it doesn't just error
if opts.ReminderTimerDefaultSeconds == 0 {
opts.ReminderTimerDefaultSeconds = 86400
}
playbookURL := "playbooks"
req, err := s.client.newRequest(http.MethodPost, playbookURL, opts)
if err != nil {
return "", err
}
var result struct {
ID string `json:"id"`
}
resp, err := s.client.do(ctx, req, &result)
if err != nil {
return "", err
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("expected status code %d", http.StatusCreated)
}
return result.ID, nil
}
func (s *PlaybooksService) Update(ctx context.Context, playbook Playbook) error {
updateURL := fmt.Sprintf("playbooks/%s", playbook.ID)
req, err := s.client.newRequest(http.MethodPut, updateURL, playbook)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
if err != nil {
return err
}
return nil
}
func (s *PlaybooksService) Archive(ctx context.Context, playbookID string) error {
updateURL := fmt.Sprintf("playbooks/%s", playbookID)
req, err := s.client.newRequest(http.MethodDelete, updateURL, nil)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
if err != nil {
return err
}
return nil
}
func (s *PlaybooksService) Export(ctx context.Context, playbookID string) ([]byte, error) {
url := fmt.Sprintf("playbooks/%s/export", playbookID)
req, err := s.client.newRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := s.client.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
result, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("expected status code %d", http.StatusOK)
}
return result, nil
}
// Duplicate a playbook. Returns the id of the newly created playbook
func (s *PlaybooksService) Duplicate(ctx context.Context, playbookID string) (string, error) {
url := fmt.Sprintf("playbooks/%s/duplicate", playbookID)
req, err := s.client.newRequest(http.MethodPost, url, nil)
if err != nil {
return "", err
}
var result struct {
ID string `json:"id"`
}
resp, err := s.client.do(ctx, req, &result)
if err != nil {
return "", err
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("expected status code %d", http.StatusCreated)
}
return result.ID, nil
}
// Imports a playbook. Returns the id of the newly created playbook
func (s *PlaybooksService) Import(ctx context.Context, toImport []byte, team string) (string, error) {
url := "playbooks/import?team_id=" + team
u, err := s.client.BaseURL.Parse(buildAPIURL(url))
if err != nil {
return "", errors.Wrapf(err, "invalid endpoint %s", url)
}
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(toImport))
if err != nil {
return "", errors.Wrapf(err, "failed to create http request for import")
}
req.Header.Set("Content-Type", "application/json")
var result struct {
ID string `json:"id"`
}
resp, err := s.client.do(ctx, req, &result)
if err != nil {
return "", err
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("expected status code %d", http.StatusCreated)
}
return result.ID, nil
}
func (s *PlaybooksService) Stats(ctx context.Context, playbookID string) (*PlaybookStats, error) {
playbookStatsURL := fmt.Sprintf("stats/playbook?playbook_id=%s", playbookID)
req, err := s.client.newRequest(http.MethodGet, playbookStatsURL, nil)
if err != nil {
return nil, err
}
stats := new(PlaybookStats)
resp, err := s.client.do(ctx, req, stats)
if err != nil {
return nil, err
}
resp.Body.Close()
return stats, nil
}
func (s *PlaybooksService) AutoFollow(ctx context.Context, playbookID string, userID string) error {
followsURL := fmt.Sprintf("playbooks/%s/autofollows/%s", playbookID, userID)
req, err := s.client.newRequest(http.MethodPut, followsURL, nil)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
if err != nil {
return err
}
return nil
}
func (s *PlaybooksService) AutoUnfollow(ctx context.Context, playbookID string, userID string) error {
followsURL := fmt.Sprintf("playbooks/%s/autofollows/%s", playbookID, userID)
req, err := s.client.newRequest(http.MethodDelete, followsURL, nil)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
if err != nil {
return err
}
return nil
}
func (s *PlaybooksService) GetAutoFollows(ctx context.Context, playbookID string) ([]string, error) {
autofollowsURL := fmt.Sprintf("playbooks/%s/autofollows", playbookID)
req, err := s.client.newRequest(http.MethodGet, autofollowsURL, nil)
if err != nil {
return nil, err
}
var followers []string
resp, err := s.client.do(ctx, req, &followers)
if err != nil {
return nil, err
}
resp.Body.Close()
return followers, nil
}

View File

@ -1,76 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client_test
import (
"context"
"fmt"
"log"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
)
func ExamplePlaybooksService_Get() {
ctx := context.Background()
client4 := model.NewAPIv4Client("http://localhost:8065")
client4.Login(context.Background(), "test@example.com", "testtest")
c, err := client.New(client4)
if err != nil {
log.Fatal(err)
}
playbookID := "h4n3h7s1qjf5pkis4dn6cuxgwa"
playbook, err := c.Playbooks.Get(ctx, playbookID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Playbook Name: %s\n", playbook.Title)
}
func ExamplePlaybooksService_List() {
ctx := context.Background()
client4 := model.NewAPIv4Client("http://localhost:8065")
_, _, err := client4.Login(context.Background(), "test@example.com", "testtest")
if err != nil {
log.Fatal(err.Error())
}
teams, _, err := client4.GetAllTeams(context.Background(), "", 0, 1)
if err != nil {
log.Fatal(err.Error())
}
if len(teams) == 0 {
log.Fatal("no teams for this user")
}
c, err := client.New(client4)
if err != nil {
log.Fatal(err)
}
var playbooks []client.Playbook
for page := 0; ; page++ {
result, err := c.Playbooks.List(ctx, teams[0].Id, page, 100, client.PlaybookListOptions{
Sort: client.SortByCreateAt,
Direction: client.SortDesc,
})
if err != nil {
log.Fatal(err)
}
playbooks = append(playbooks, result.Items...)
if !result.HasMore {
break
}
}
for _, playbook := range playbooks {
fmt.Printf("Playbook Name: %s\n", playbook.Title)
}
}

View File

@ -1,8 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
type ReminderResetPayload struct {
NewReminderSeconds int `json:"new_reminder_seconds"`
}

View File

@ -1,36 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"context"
"fmt"
"io/ioutil"
"net/http"
)
type RemindersService struct {
client *Client
}
func (s *RemindersService) Reset(ctx context.Context, playbookRunID string, payload ReminderResetPayload) error {
resetURL := fmt.Sprintf("runs/%s/reminder", playbookRunID)
req, err := s.client.newRequest(http.MethodPost, resetURL, payload)
if err != nil {
return err
}
resp, err := s.client.do(ctx, req, ioutil.Discard)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("expected status code %d", http.StatusNoContent)
}
return nil
}

View File

@ -1,55 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"context"
"net/http"
)
type GlobalSettings struct {
// EnableExperimentalFeatures is a read-only field set to true when experimental features
// are enabled. Changing this field requires access to the system console plugin
// configuration.
EnableExperimentalFeatures bool `json:"enable_experimental_features"`
}
// SettingsService handles communication with the settings related methods.
type SettingsService struct {
client *Client
}
// Get the configured settings.
func (s *SettingsService) Get(ctx context.Context) (*GlobalSettings, error) {
settingsURL := "settings"
req, err := s.client.newRequest(http.MethodGet, settingsURL, nil)
if err != nil {
return nil, err
}
settings := new(GlobalSettings)
resp, err := s.client.do(ctx, req, settings)
if err != nil {
return nil, err
}
resp.Body.Close()
return settings, nil
}
// Update the configured settings.
func (s *SettingsService) Update(ctx context.Context, settings GlobalSettings) error {
settingsURL := "settings"
req, err := s.client.newRequest(http.MethodPut, settingsURL, settings)
if err != nil {
return err
}
_, err = s.client.do(ctx, req, nil)
if err != nil {
return err
}
return nil
}

View File

@ -1,38 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"context"
"net/http"
)
// StatsService handles communication with the stats related methods.
type StatsService struct {
client *Client
}
// PlaybookSiteStats holds the data that we want to expose in system console
type PlaybookSiteStats struct {
TotalPlaybooks int `json:"total_playbooks"`
TotalPlaybookRuns int `json:"total_playbook_runs"`
}
// Get the stats that should be displayed in system console.
func (s *StatsService) GetSiteStats(ctx context.Context) (*PlaybookSiteStats, error) {
statsURL := "stats/site"
req, err := s.client.newRequest(http.MethodGet, statsURL, nil)
if err != nil {
return nil, err
}
stats := new(PlaybookSiteStats)
resp, err := s.client.do(ctx, req, stats)
if err != nil {
return nil, err
}
resp.Body.Close()
return stats, nil
}

View File

@ -1,49 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"context"
"fmt"
"io/ioutil"
"net/http"
)
type TelemetryService struct {
client *Client
}
func (s *TelemetryService) CreateEvent(ctx context.Context, name string, eventType string, properties map[string]interface{}) error {
payload := struct {
Type string
Name string
Properties map[string]interface{}
}{
Type: eventType,
Name: name,
Properties: properties,
}
req, err := s.client.newRequest(http.MethodPost, "telemetry", payload)
if err != nil {
return err
}
resp, err := s.client.do(ctx, req, nil)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
return fmt.Errorf("expected status code %d, got %d: %s", http.StatusNoContent, resp.StatusCode, body)
}
return nil
}

View File

@ -1,7 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
var BuildAPIURL = buildAPIURL
var NewClient = newClient

View File

@ -1,497 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package product
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/mattermost/mattermost/server/public/model"
mm_model "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/v8/channels/app/request"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
)
// normalizeAppError returns a truly nil error if appErr is nil
// See https://golang.org/doc/faq#nil_error for more details.
func normalizeAppErr(appErr *mm_model.AppError) error {
if appErr == nil {
return nil
}
if appErr.StatusCode == http.StatusNotFound {
return app.ErrNotFound
}
return appErr
}
// serviceAPIAdapter is an adapter that flattens the APIs provided by suite services so they can
// be used as per the Plugin API.
// Note: when supporting a plugin build is no longer needed this adapter may be removed as the Boards app
// can be modified to use the services in modular fashion.
type serviceAPIAdapter struct {
api *playbooksProduct
ctx *request.Context
}
func newServiceAPIAdapter(api *playbooksProduct) *serviceAPIAdapter {
return &serviceAPIAdapter{
api: api,
ctx: request.EmptyContext(api.logger),
}
}
//
// Channels service.
//
func (a *serviceAPIAdapter) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
channel, appErr := a.api.channelService.GetDirectChannel(userID1, userID2)
return channel, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelByID(channelID string) (*mm_model.Channel, error) {
channel, appErr := a.api.channelService.GetChannelByID(channelID)
return channel, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) {
member, appErr := a.api.channelService.GetChannelMember(channelID, userID)
return member, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelsForTeamForUser(teamID string, userID string, includeDeleted bool) (mm_model.ChannelList, error) {
opts := &mm_model.ChannelSearchOpts{
IncludeDeleted: includeDeleted,
}
channels, appErr := a.api.channelService.GetChannelsForTeamForUser(teamID, userID, opts)
return channels, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelSidebarCategories(userID, teamID string) (*mm_model.OrderedSidebarCategories, error) {
categories, appErr := a.api.channelService.GetChannelSidebarCategories(userID, teamID)
return categories, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelMembers(channelID string, page, perPage int) (mm_model.ChannelMembers, error) {
channelMembers, appErr := a.api.channelService.GetChannelMembers(channelID, page, perPage)
return channelMembers, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) CreateChannelSidebarCategory(userID, teamID string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, error) {
channels, appErr := a.api.channelService.CreateChannelSidebarCategory(userID, teamID, newCategory)
return channels, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) UpdateChannelSidebarCategories(userID, teamID string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, error) {
channels, appErr := a.api.channelService.UpdateChannelSidebarCategories(userID, teamID, categories)
return channels, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) CreateChannel(channel *mm_model.Channel) error {
_, appErr := a.api.channelService.CreateChannel(channel)
return normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) AddMemberToChannel(channelID, userID string) (*mm_model.ChannelMember, error) {
channelMember, appErr := a.api.channelService.AddChannelMember(channelID, userID)
return channelMember, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) AddUserToChannel(channelID, userID, asUserID string) (*mm_model.ChannelMember, error) {
channel, appErr := a.api.channelService.AddUserToChannel(channelID, userID, asUserID)
return channel, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) UpdateChannelMemberRoles(channelID, userID, newRoles string) (*mm_model.ChannelMember, error) {
channelMember, appErr := a.api.channelService.UpdateChannelMemberRoles(channelID, userID, newRoles)
return channelMember, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) DeleteChannelMember(channelID, userID string) error {
appErr := a.api.channelService.DeleteChannelMember(channelID, userID)
return normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) AddChannelMember(channelID, userID string) (*mm_model.ChannelMember, error) {
channelMember, appErr := a.api.channelService.AddChannelMember(channelID, userID)
return channelMember, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error) {
channel, appErr := a.api.channelService.GetDirectChannelOrCreate(userID1, userID2)
return channel, normalizeAppErr(appErr)
}
//
// Post service.
//
func (a *serviceAPIAdapter) CreatePost(post *mm_model.Post) (*mm_model.Post, error) {
createdPost, appErr := a.api.postService.CreatePost(a.ctx, post)
if appErr != nil {
return nil, normalizeAppErr(appErr)
}
err := createdPost.ShallowCopy(post)
if err != nil {
return nil, err
}
return post, nil
}
func (a *serviceAPIAdapter) GetPostsByIds(postIDs []string) ([]*mm_model.Post, error) {
post, _, appErr := a.api.postService.GetPostsByIds(postIDs)
return post, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) SendEphemeralPost(userID string, post *mm_model.Post) {
*post = *a.api.postService.SendEphemeralPost(a.ctx, userID, post)
}
func (a *serviceAPIAdapter) GetPost(postID string) (*mm_model.Post, error) {
post, appErr := a.api.postService.GetPost(postID)
return post, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) DeletePost(postID string) (*mm_model.Post, error) {
post, appErr := a.api.postService.DeletePost(a.ctx, postID, playbooksProductID)
return post, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) UpdatePost(post *mm_model.Post) (*mm_model.Post, error) {
post, appErr := a.api.postService.UpdatePost(a.ctx, post, false)
return post, normalizeAppErr(appErr)
}
//
// User service.
//
func (a *serviceAPIAdapter) GetUserByID(userID string) (*mm_model.User, error) {
user, appErr := a.api.userService.GetUser(userID)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetUserByUsername(name string) (*mm_model.User, error) {
user, appErr := a.api.userService.GetUserByUsername(name)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetUserByEmail(email string) (*mm_model.User, error) {
user, appErr := a.api.userService.GetUserByEmail(email)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) UpdateUser(user *mm_model.User) (*mm_model.User, error) {
user, appErr := a.api.userService.UpdateUser(a.ctx, user, true)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error) {
user, appErr := a.api.userService.GetUsersFromProfiles(options)
return user, normalizeAppErr(appErr)
}
//
// Team service.
//
func (a *serviceAPIAdapter) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) {
member, appErr := a.api.teamService.GetMember(teamID, userID)
return member, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) {
member, appErr := a.api.teamService.CreateMember(a.ctx, teamID, userID)
return member, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetGroup(groupID string) (*model.Group, error) {
group, appErr := a.api.teamService.GetGroup(groupID)
return group, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetTeam(teamID string) (*mm_model.Team, error) {
team, appErr := a.api.teamService.GetTeam(teamID)
return team, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetGroupMemberUsers(groupID string, page, perPage int) ([]*mm_model.User, error) {
users, appErr := a.api.teamService.GetGroupMemberUsers(groupID, page, perPage)
return users, normalizeAppErr(appErr)
}
//
// Permissions service.
//
func (a *serviceAPIAdapter) HasPermissionTo(userID string, permission *mm_model.Permission) bool {
return a.api.permissionsService.HasPermissionTo(userID, permission)
}
func (a *serviceAPIAdapter) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
return a.api.permissionsService.HasPermissionToTeam(userID, teamID, permission)
}
func (a *serviceAPIAdapter) HasPermissionToChannel(askingUserID string, channelID string, permission *mm_model.Permission) bool {
return a.api.permissionsService.HasPermissionToChannel(askingUserID, channelID, permission)
}
func (a *serviceAPIAdapter) RolesGrantPermission(roleNames []string, permissionID string) bool {
return a.api.permissionsService.RolesGrantPermission(roleNames, permissionID)
}
//
// Bot service.
//
func (a *serviceAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
return a.api.botService.EnsureBot(a.ctx, playbooksProductID, bot)
}
//
// License service.
//
func (a *serviceAPIAdapter) GetLicense() *mm_model.License {
return a.api.licenseService.GetLicense()
}
func (a *serviceAPIAdapter) RequestTrialLicense(requesterID string, users int, termsAccepted bool, receiveEmailsAccepted bool) error {
return normalizeAppErr(a.api.licenseService.RequestTrialLicense(requesterID, users, termsAccepted, receiveEmailsAccepted))
}
//
// FileInfoStore service.
//
func (a *serviceAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error) {
fi, appErr := a.api.fileInfoStoreService.GetFileInfo(fileID)
return fi, normalizeAppErr(appErr)
}
//
// Cluster store.
//
func (a *serviceAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
a.api.clusterService.PublishWebSocketEvent(playbooksProductID, event, payload, broadcast)
}
func (a *serviceAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error {
return a.api.clusterService.PublishPluginClusterEvent(playbooksProductID, ev, opts)
}
//
// Cloud service.
//
func (a *serviceAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
return a.api.cloudService.GetCloudLimits()
}
//
// Config service.
//
func (a *serviceAPIAdapter) GetConfig() *mm_model.Config {
cfg := a.api.configService.Config().Clone()
cfg.Sanitize()
return cfg
}
func (a *serviceAPIAdapter) LoadPluginConfiguration(dest any) error {
finalConfig := make(map[string]any)
// If we have settings given we override the defaults with them
for setting, value := range a.api.configService.Config().PluginSettings.Plugins[playbooksProductID] {
finalConfig[strings.ToLower(setting)] = value
}
pluginSettingsJSONBytes, err := json.Marshal(finalConfig)
if err != nil {
logrus.WithError(err).Error("Error marshaling config for plugin")
return nil
}
err = json.Unmarshal(pluginSettingsJSONBytes, dest)
if err != nil {
logrus.WithError(err).Error("Error unmarshaling config for plugin")
}
return nil
}
func (a *serviceAPIAdapter) SavePluginConfig(pluginConfig map[string]any) error {
cfg := a.GetConfig()
cfg.PluginSettings.Plugins["playbooks"] = pluginConfig
_, _, err := a.api.configService.SaveConfig(cfg, true)
return normalizeAppErr(err)
}
//
// KVStore service.
//
func (a *serviceAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
b, appErr := a.api.kvStoreService.SetPluginKeyWithOptions(playbooksProductID, key, value, options)
return b, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) KVGet(key string) ([]byte, error) {
data, appErr := a.api.kvStoreService.KVGet(playbooksProductID, key)
return data, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) KVDelete(key string) error {
appErr := a.api.kvStoreService.KVDelete(playbooksProductID, key)
return normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) KVList(page, perPage int) ([]string, error) {
data, appErr := a.api.kvStoreService.KVList(playbooksProductID, page, perPage)
return data, normalizeAppErr(appErr)
}
// Get gets the value for the given key into the given interface.
//
// An error is returned only if the value cannot be fetched. A non-existent key will return no
// error, with nothing written to the given interface.
//
// Minimum server version: 5.2
func (a *serviceAPIAdapter) Get(key string, o interface{}) error {
data, appErr := a.api.kvStoreService.KVGet(playbooksProductID, key)
if appErr != nil {
return normalizeAppErr(appErr)
}
if len(data) == 0 {
return nil
}
if bytesOut, ok := o.(*[]byte); ok {
*bytesOut = data
return nil
}
if err := json.Unmarshal(data, o); err != nil {
return errors.Wrapf(err, "failed to unmarshal value for key %s", key)
}
return nil
}
//
// Store service.
//
func (a *serviceAPIAdapter) GetMasterDB() (*sql.DB, error) {
return a.api.storeService.GetMasterDB(), nil
}
// DriverName returns the driver name for the datasource.
func (a *serviceAPIAdapter) DriverName() string {
return *a.api.configService.Config().SqlSettings.DriverName
}
//
// System service.
//
func (a *serviceAPIAdapter) GetDiagnosticID() string {
return a.api.systemService.GetDiagnosticId()
}
func (a *serviceAPIAdapter) GetServerVersion() string {
return model.CurrentVersion
}
//
// Router service.
//
func (a *serviceAPIAdapter) RegisterRouter(sub *mux.Router) {
a.api.routerService.RegisterRouter(playbooksProductName, sub)
}
//
// Preferences service.
//
func (a *serviceAPIAdapter) GetPreferencesForUser(userID string) (mm_model.Preferences, error) {
p, appErr := a.api.preferencesService.GetPreferencesForUser(userID)
return p, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) UpdatePreferencesForUser(userID string, preferences mm_model.Preferences) error {
appErr := a.api.preferencesService.UpdatePreferencesForUser(userID, preferences)
return normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) DeletePreferencesForUser(userID string, preferences mm_model.Preferences) error {
appErr := a.api.preferencesService.DeletePreferencesForUser(userID, preferences)
return normalizeAppErr(appErr)
}
//
// Session service.
//
func (a *serviceAPIAdapter) GetSession(sessionID string) (*mm_model.Session, error) {
session, appErr := a.api.sessionService.GetSessionById(sessionID)
return session, normalizeAppErr(appErr)
}
//
// Frontend service.
//
func (a *serviceAPIAdapter) OpenInteractiveDialog(dialog model.OpenDialogRequest) error {
return normalizeAppErr(a.api.frontendService.OpenInteractiveDialog(dialog))
}
//
// Command service.
//
func (a *serviceAPIAdapter) Execute(command *mm_model.CommandArgs) (*mm_model.CommandResponse, error) {
user, err := a.GetUserByID(command.UserId)
if err != nil {
return nil, err
}
command.T = i18n.GetUserTranslations(user.Locale)
command.SiteURL = *a.GetConfig().ServiceSettings.SiteURL
response, appErr := a.api.commandService.ExecuteCommand(a.ctx, command)
return response, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) RegisterCommand(command *mm_model.Command) error {
return a.api.commandService.RegisterProductCommand(playbooksProductName, command)
}
func (a *serviceAPIAdapter) IsEnterpriseReady() bool {
result, _ := strconv.ParseBool(model.BuildEnterpriseReady)
return result
}
//
// Threads service
//
func (a *serviceAPIAdapter) RegisterCollectionAndTopic(collectionType, topicType string) error {
return a.api.threadsService.RegisterCollectionAndTopic(playbooksProductID, collectionType, topicType)
}
// Ensure the adapter implements ServicesAPI.
var _ playbooks.ServicesAPI = &serviceAPIAdapter{}

View File

@ -1,10 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imports
import (
// Needed to ensure the init() method in the Playbooks product is run.
// This file is copied to the mmserver imports package via makefile.
_ "github.com/mattermost/mattermost/server/v8/playbooks/product"
)

View File

@ -1,86 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package product
import (
"fmt"
"io"
"github.com/mattermost/logr/v2"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/sirupsen/logrus"
)
// LogrusHook is a logrus.Hook for emitting plugin logs through the RPC API for inclusion in the
// server logs.
//
// To configure the default Logrus logger for use with plugin logging, simply invoke:
//
// pluginapi.ConfigureLogrus(logrus.StandardLogger(), pluginAPIClient)
//
// Alternatively, construct your own logger to pass to pluginapi.ConfigureLogrus.
type LogrusHook struct {
log mlog.LoggerIFace
}
// NewLogrusHook creates a new instance of LogrusHook.
func NewLogrusHook(log mlog.LoggerIFace) *LogrusHook {
return &LogrusHook{
log: log,
}
}
// Levels allows LogrusHook to process any log level.
func (lh *LogrusHook) Levels() []logrus.Level {
return logrus.AllLevels
}
// Fire proxies logrus entries through the plugin API at the appropriate level.
func (lh *LogrusHook) Fire(entry *logrus.Entry) error {
fields := []logr.Field{}
for key, value := range entry.Data {
field := logr.Field{
Key: key,
Interface: value,
}
if key == "error" {
field.Type = logr.ErrorType
}
fields = append(fields, field)
}
if entry.Caller != nil {
fields = append(fields,
logr.Field{
Key: "plugin_caller",
String: fmt.Sprintf("%s:%d", entry.Caller.File, entry.Caller.Line),
})
}
switch entry.Level {
case logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel:
lh.log.Error(entry.Message, fields...)
case logrus.WarnLevel:
lh.log.Warn(entry.Message, fields...)
case logrus.InfoLevel:
lh.log.Info(entry.Message, fields...)
case logrus.DebugLevel, logrus.TraceLevel:
lh.log.Debug(entry.Message, fields...)
}
return nil
}
// ConfigureLogrus configures the given logrus logger with a hook to proxy through the RPC API,
// discarding the default output to avoid duplicating the events across the standard STDOUT proxy.
func ConfigureLogrus(logger *logrus.Logger, log mlog.LoggerIFace) {
hook := NewLogrusHook(log)
logger.Hooks.Add(hook)
logger.SetOutput(io.Discard)
logrus.SetReportCaller(true)
// By default, log everything to the server, and let it decide what gets through.
logrus.SetLevel(logrus.TraceLevel)
}

View File

@ -1,818 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package product
import (
"fmt"
"net/http"
"os"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/mlog"
mmapp "github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/product"
"github.com/mattermost/mattermost/server/v8/playbooks/product/pluginapi/cluster"
"github.com/mattermost/mattermost/server/v8/playbooks/server/api"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/bot"
"github.com/mattermost/mattermost/server/v8/playbooks/server/command"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
"github.com/mattermost/mattermost/server/v8/playbooks/server/enterprise"
"github.com/mattermost/mattermost/server/v8/playbooks/server/metrics"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/mattermost/mattermost/server/v8/playbooks/server/scheduler"
"github.com/mattermost/mattermost/server/v8/playbooks/server/sqlstore"
"github.com/mattermost/mattermost/server/v8/playbooks/server/telemetry"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
playbooksProductName = "playbooks"
playbooksProductID = "playbooks"
)
const (
updateMetricsTaskFrequency = 15 * time.Minute
metricsExposePort = ":9093"
// Topic represents a start of a thread. In playbooks we support 2 types of topics:
// status topic - indicating the start of the thread below status update and
// task topic - indicating the start of the thread below task(checklist item)
TopicTypeStatus = "status"
TopicTypeTask = "task"
// Collection is a group of topics and their corresponding threads.
// In Playbooks we support a single type of collection - a run
CollectionTypeRun = "run"
)
const ServerKey product.ServiceKey = "server"
const (
rudderDataplaneURL = "https://pdat.matterlytics.com"
rudderKeyProd = "1ag0Mv7LPf5uJNhcnKomqg0ENFd"
rudderKeyTest = "1Zu3mOF6U6M9zeaJsfmmhYigWLt"
// These are placeholders to allow the existing release pipelines to run without failing to
// insert the values that are now hard-coded above. Remove this once we converge on the
// unified delivery pipeline in GitHub.
_ = "placeholder_rudder_dataplane_url"
_ = "placeholder_playbooks_rudder_key"
)
var errServiceTypeAssert = errors.New("type assertion failed")
type TelemetryClient interface {
app.PlaybookRunTelemetry
app.PlaybookTelemetry
app.GenericTelemetry
bot.Telemetry
app.UserInfoTelemetry
app.ChannelActionTelemetry
app.CategoryTelemetry
Enable() error
Disable() error
}
func init() {
product.RegisterProduct(playbooksProductName, product.Manifest{
Initializer: newPlaybooksProduct,
Dependencies: map[product.ServiceKey]struct{}{
product.TeamKey: {},
product.ChannelKey: {},
product.UserKey: {},
product.PostKey: {},
product.BotKey: {},
product.ClusterKey: {},
product.ConfigKey: {},
product.LogKey: {},
product.LicenseKey: {},
product.FilestoreKey: {},
product.FileInfoStoreKey: {},
product.RouterKey: {},
product.CloudKey: {},
product.KVStoreKey: {},
product.StoreKey: {},
product.SystemKey: {},
product.PreferencesKey: {},
product.SessionKey: {},
product.FrontendKey: {},
product.CommandKey: {},
product.ThreadsKey: {},
},
})
}
type playbooksProduct struct {
server *mmapp.Server
teamService product.TeamService
channelService product.ChannelService
userService product.UserService
postService product.PostService
permissionsService product.PermissionService
botService product.BotService
clusterService product.ClusterService
configService product.ConfigService
logger mlog.LoggerIFace
licenseService product.LicenseService
filestoreService product.FilestoreService
fileInfoStoreService product.FileInfoStoreService
routerService product.RouterService
cloudService product.CloudService
kvStoreService product.KVStoreService
storeService product.StoreService
systemService product.SystemService
preferencesService product.PreferencesService
hooksService product.HooksService
sessionService product.SessionService
frontendService product.FrontendService
commandService product.CommandService
threadsService product.ThreadsService
handler *api.Handler
config *config.ServiceImpl
playbookRunService app.PlaybookRunService
playbookService app.PlaybookService
permissions *app.PermissionsService
channelActionService app.ChannelActionService
categoryService app.CategoryService
bot *bot.Bot
userInfoStore app.UserInfoStore
telemetryClient TelemetryClient
licenseChecker app.LicenseChecker
metricsService *metrics.Metrics
playbookStore app.PlaybookStore
playbookRunStore app.PlaybookRunStore
metricsServer *metrics.Service
metricsUpdaterTask *scheduler.ScheduledTask
serviceAdapter playbooks.ServicesAPI
}
func newPlaybooksProduct(services map[product.ServiceKey]interface{}) (product.Product, error) {
playbooks := &playbooksProduct{}
err := playbooks.setProductServices(services)
if err != nil {
return nil, err
}
playbooks.server = services[ServerKey].(*mmapp.Server)
playbooks.serviceAdapter = newServiceAPIAdapter(playbooks)
return playbooks, nil
}
func (pp *playbooksProduct) setProductServices(services map[product.ServiceKey]interface{}) error {
for key, service := range services {
switch key {
case product.TeamKey:
teamService, ok := service.(product.TeamService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.teamService = teamService
case product.ChannelKey:
channelService, ok := service.(product.ChannelService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.channelService = channelService
case product.UserKey:
userService, ok := service.(product.UserService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.userService = userService
case product.PostKey:
postService, ok := service.(product.PostService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.postService = postService
case product.PermissionsKey:
permissionsService, ok := service.(product.PermissionService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.permissionsService = permissionsService
case product.BotKey:
botService, ok := service.(product.BotService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.botService = botService
case product.ClusterKey:
clusterService, ok := service.(product.ClusterService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.clusterService = clusterService
case product.ConfigKey:
configService, ok := service.(product.ConfigService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.configService = configService
case product.LogKey:
logger, ok := service.(mlog.LoggerIFace)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.logger = logger.With(mlog.String("product", playbooksProductName))
case product.LicenseKey:
licenseService, ok := service.(product.LicenseService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.licenseService = licenseService
case product.FilestoreKey:
filestoreService, ok := service.(product.FilestoreService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.filestoreService = filestoreService
case product.FileInfoStoreKey:
fileInfoStoreService, ok := service.(product.FileInfoStoreService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.fileInfoStoreService = fileInfoStoreService
case product.RouterKey:
routerService, ok := service.(product.RouterService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.routerService = routerService
case product.CloudKey:
cloudService, ok := service.(product.CloudService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.cloudService = cloudService
case product.KVStoreKey:
kvStoreService, ok := service.(product.KVStoreService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.kvStoreService = kvStoreService
case product.StoreKey:
storeService, ok := service.(product.StoreService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.storeService = storeService
case product.SystemKey:
systemService, ok := service.(product.SystemService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.systemService = systemService
case product.PreferencesKey:
preferencesService, ok := service.(product.PreferencesService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.preferencesService = preferencesService
case product.HooksKey:
hooksService, ok := service.(product.HooksService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.hooksService = hooksService
case product.SessionKey:
sessionService, ok := service.(product.SessionService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.sessionService = sessionService
case product.FrontendKey:
frontendService, ok := service.(product.FrontendService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.frontendService = frontendService
case product.CommandKey:
commandService, ok := service.(product.CommandService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.commandService = commandService
case product.ThreadsKey:
threadsService, ok := service.(product.ThreadsService)
if !ok {
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
pp.threadsService = threadsService
}
}
return nil
}
func (pp *playbooksProduct) Start() error {
logger := logrus.StandardLogger()
ConfigureLogrus(logger, pp.logger)
botID, err := pp.serviceAdapter.EnsureBot(&model.Bot{
Username: "playbooks",
DisplayName: "Playbooks",
Description: "Playbooks bot.",
OwnerId: "playbooks",
})
if err != nil {
return errors.Wrapf(err, "failed to ensure bot")
}
pp.config = config.NewConfigService(pp.serviceAdapter)
err = pp.config.UpdateConfiguration(func(c *config.Configuration) {
c.BotUserID = botID
c.AdminLogLevel = "debug"
})
if err != nil {
return errors.Wrapf(err, "failed save bot to config")
}
pp.handler = api.NewHandler(pp.config)
rudderWriteKey := ""
switch model.GetServiceEnvironment() {
case model.ServiceEnvironmentProduction:
rudderWriteKey = rudderKeyProd
case model.ServiceEnvironmentTest:
rudderWriteKey = rudderKeyTest
case model.ServiceEnvironmentDev:
}
if rudderWriteKey == "" {
logrus.Warn("Rudder credentials are not set. Disabling analytics.")
pp.telemetryClient = &telemetry.NoopTelemetry{}
} else {
logrus.Info("Rudder credentials are set. Enabling analytics.")
diagnosticID := pp.serviceAdapter.GetDiagnosticID()
serverVersion := pp.serviceAdapter.GetServerVersion()
pp.telemetryClient, err = telemetry.NewRudder(rudderDataplaneURL, rudderWriteKey, diagnosticID, serverVersion)
if err != nil {
return errors.Wrapf(err, "failed init telemetry client")
}
}
toggleTelemetry := func() {
diagnosticsFlag := pp.serviceAdapter.GetConfig().LogSettings.EnableDiagnostics
telemetryEnabled := diagnosticsFlag != nil && *diagnosticsFlag
if telemetryEnabled {
if err = pp.telemetryClient.Enable(); err != nil {
logrus.WithError(err).Error("Telemetry could not be enabled")
}
return
}
if err = pp.telemetryClient.Disable(); err != nil {
logrus.WithError(err).Error("Telemetry could not be disabled")
}
}
toggleTelemetry()
pp.config.RegisterConfigChangeListener(toggleTelemetry)
apiClient := sqlstore.NewClient(pp.serviceAdapter)
pp.bot = bot.New(pp.serviceAdapter, pp.config.GetConfiguration().BotUserID, pp.config, pp.telemetryClient)
scheduler := cluster.GetJobOnceScheduler(pp.serviceAdapter)
sqlStore, err := sqlstore.New(apiClient, scheduler)
if err != nil {
return errors.Wrapf(err, "failed creating the SQL store")
}
pp.playbookRunStore = sqlstore.NewPlaybookRunStore(apiClient, sqlStore)
pp.playbookStore = sqlstore.NewPlaybookStore(apiClient, sqlStore)
statsStore := sqlstore.NewStatsStore(apiClient, sqlStore)
pp.userInfoStore = sqlstore.NewUserInfoStore(sqlStore)
channelActionStore := sqlstore.NewChannelActionStore(apiClient, sqlStore)
categoryStore := sqlstore.NewCategoryStore(apiClient, sqlStore)
pp.handler = api.NewHandler(pp.config)
pp.playbookService = app.NewPlaybookService(pp.playbookStore, pp.bot, pp.telemetryClient, pp.serviceAdapter, pp.metricsService)
keywordsThreadIgnorer := app.NewKeywordsThreadIgnorer()
pp.channelActionService = app.NewChannelActionsService(pp.serviceAdapter, pp.bot, pp.config, channelActionStore, pp.playbookService, keywordsThreadIgnorer, pp.telemetryClient)
pp.categoryService = app.NewCategoryService(categoryStore, pp.serviceAdapter, pp.telemetryClient)
pp.licenseChecker = enterprise.NewLicenseChecker(pp.serviceAdapter)
pp.playbookRunService = app.NewPlaybookRunService(
pp.playbookRunStore,
pp.bot,
pp.config,
scheduler,
pp.telemetryClient,
pp.telemetryClient,
pp.serviceAdapter,
pp.playbookService,
pp.channelActionService,
pp.licenseChecker,
pp.metricsService,
)
if err = scheduler.SetCallback(pp.playbookRunService.HandleReminder); err != nil {
logrus.WithError(err).Error("JobOnceScheduler could not add the playbookRunService's HandleReminder")
}
if err = scheduler.Start(); err != nil {
logrus.WithError(err).Error("JobOnceScheduler could not start")
}
// Migrations use the scheduler, so they have to be run after playbookRunService and scheduler have started
mutex, err := cluster.NewMutex(pp.serviceAdapter, "IR_dbMutex")
if err != nil {
return errors.Wrapf(err, "failed creating cluster mutex")
}
mutex.Lock()
if err = sqlStore.RunMigrations(); err != nil {
mutex.Unlock()
return errors.Wrapf(err, "failed to run migrations")
}
mutex.Unlock()
pp.permissions = app.NewPermissionsService(
pp.playbookService,
pp.playbookRunService,
pp.serviceAdapter,
pp.config,
pp.licenseChecker,
)
// register collections and topics.
// TODO bump the minimum server version
if err = pp.serviceAdapter.RegisterCollectionAndTopic(CollectionTypeRun, TopicTypeStatus); err != nil {
logrus.WithError(err).WithField("collection_type", CollectionTypeRun).WithField("topic_type", TopicTypeStatus).Warnf("failed to register collection and topic")
}
if err = pp.serviceAdapter.RegisterCollectionAndTopic(CollectionTypeRun, TopicTypeTask); err != nil {
logrus.WithError(err).WithField("collection_type", CollectionTypeRun).WithField("topic_type", TopicTypeTask).Warnf("failed to register collection and topic")
}
api.NewGraphQLHandler(
pp.handler.APIRouter,
pp.playbookService,
pp.playbookRunService,
pp.categoryService,
pp.serviceAdapter,
pp.config,
pp.permissions,
pp.playbookStore,
pp.licenseChecker,
)
api.NewPlaybookHandler(
pp.handler.APIRouter,
pp.playbookService,
pp.serviceAdapter,
pp.config,
pp.permissions,
)
api.NewPlaybookRunHandler(
pp.handler.APIRouter,
pp.playbookRunService,
pp.playbookService,
pp.permissions,
pp.licenseChecker,
pp.serviceAdapter,
pp.bot,
pp.config,
)
api.NewStatsHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
statsStore,
pp.playbookService,
pp.permissions,
pp.licenseChecker,
)
api.NewBotHandler(
pp.handler.APIRouter,
pp.serviceAdapter, pp.bot,
pp.config,
pp.playbookRunService,
pp.userInfoStore,
)
api.NewTelemetryHandler(
pp.handler.APIRouter,
pp.playbookRunService,
pp.serviceAdapter,
pp.telemetryClient,
pp.playbookService,
pp.telemetryClient,
pp.telemetryClient,
pp.telemetryClient,
pp.permissions,
)
api.NewSignalHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
pp.playbookRunService,
pp.playbookService,
keywordsThreadIgnorer,
)
api.NewSettingsHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
pp.config,
)
api.NewActionsHandler(
pp.handler.APIRouter,
pp.channelActionService,
pp.serviceAdapter,
pp.permissions,
)
api.NewCategoryHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
pp.categoryService,
pp.playbookService,
pp.playbookRunService,
)
isTestingEnabled := false
flag := pp.serviceAdapter.GetConfig().ServiceSettings.EnableTesting
if flag != nil {
isTestingEnabled = *flag
}
if err = command.RegisterCommands(pp.serviceAdapter.RegisterCommand, isTestingEnabled); err != nil {
return errors.Wrapf(err, "failed register commands")
}
if err := pp.hooksService.RegisterHooks(playbooksProductName, pp); err != nil {
return fmt.Errorf("failed to register hooks: %w", err)
}
enableMetrics := pp.configService.Config().MetricsSettings.Enable
if enableMetrics != nil && *enableMetrics {
pp.metricsService = newMetricsInstance()
// run metrics server to expose data
pp.runMetricsServer()
// run metrics updater recurring task
pp.runMetricsUpdaterTask(pp.playbookStore, pp.playbookRunStore, updateMetricsTaskFrequency)
// set error counter middleware handler
pp.handler.APIRouter.Use(pp.getErrorCounterHandler())
}
pp.routerService.RegisterRouter(playbooksProductName, pp.handler.APIRouter)
logrus.Debug("Playbooks product successfully started.")
return nil
}
func (pp *playbooksProduct) Stop() error {
if pp.metricsServer != nil {
err := pp.metricsServer.Shutdown()
if err != nil {
logrus.WithError(err).Warn("unable to shut down metric server")
}
}
if pp.metricsUpdaterTask != nil {
pp.metricsUpdaterTask.Cancel()
}
return nil
}
func newMetricsInstance() *metrics.Metrics {
// Init metrics
instanceInfo := metrics.InstanceInfo{
Version: model.BuildHash,
InstallationID: os.Getenv("MM_CLOUD_INSTALLATION_ID"),
}
return metrics.NewMetrics(instanceInfo)
}
func (pp *playbooksProduct) runMetricsServer() {
logrus.WithField("port", metricsExposePort).Info("Starting Playbooks metrics server")
pp.metricsServer = metrics.NewMetricsServer(metricsExposePort, pp.metricsService)
// Run server to expose metrics
go func() {
err := pp.metricsServer.Run()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logrus.WithError(err).Error("Metrics server could not be started")
}
}()
}
func (pp *playbooksProduct) runMetricsUpdaterTask(playbookStore app.PlaybookStore, playbookRunStore app.PlaybookRunStore, updateMetricsTaskFrequency time.Duration) {
metricsUpdater := func() {
if playbooksActiveTotal, err := playbookStore.GetPlaybooksActiveTotal(); err == nil {
pp.metricsService.ObservePlaybooksActiveTotal(playbooksActiveTotal)
} else {
logrus.WithError(err).Error("error updating metrics, playbooks_active_total")
}
if runsActiveTotal, err := playbookRunStore.GetRunsActiveTotal(); err == nil {
pp.metricsService.ObserveRunsActiveTotal(runsActiveTotal)
} else {
logrus.WithError(err).Error("error updating metrics, runs_active_total")
}
if remindersOverdueTotal, err := playbookRunStore.GetOverdueUpdateRunsTotal(); err == nil {
pp.metricsService.ObserveRemindersOutstandingTotal(remindersOverdueTotal)
} else {
logrus.WithError(err).Error("error updating metrics, reminders_outstanding_total")
}
if retrosOverdueTotal, err := playbookRunStore.GetOverdueRetroRunsTotal(); err == nil {
pp.metricsService.ObserveRetrosOutstandingTotal(retrosOverdueTotal)
} else {
logrus.WithError(err).Error("error updating metrics, retros_outstanding_total")
}
if followersActiveTotal, err := playbookRunStore.GetFollowersActiveTotal(); err == nil {
pp.metricsService.ObserveFollowersActiveTotal(followersActiveTotal)
} else {
logrus.WithError(err).Error("error updating metrics, followers_active_total")
}
if participantsActiveTotal, err := playbookRunStore.GetParticipantsActiveTotal(); err == nil {
pp.metricsService.ObserveParticipantsActiveTotal(participantsActiveTotal)
} else {
logrus.WithError(err).Error("error updating metrics, participants_active_total")
}
}
pp.metricsUpdaterTask = scheduler.CreateRecurringTask("metricsUpdater", metricsUpdater, updateMetricsTaskFrequency)
}
func (pp *playbooksProduct) getErrorCounterHandler() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recorder := &StatusRecorder{
ResponseWriter: w,
Status: 200,
}
next.ServeHTTP(recorder, r)
if recorder.Status < 200 || recorder.Status > 299 {
pp.metricsService.IncrementErrorsCount(1)
}
})
}
}
type StatusRecorder struct {
http.ResponseWriter
Status int
}
func (r *StatusRecorder) WriteHeader(status int) {
r.Status = status
r.ResponseWriter.WriteHeader(status)
}
// ServeHTTP routes incoming HTTP requests to the plugin's REST API.
func (pp *playbooksProduct) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
pp.handler.ServeHTTP(w, r)
}
//
// These callbacks are called by the suite automatically
//
func (pp *playbooksProduct) OnConfigurationChange() error {
if pp.config == nil {
return nil
}
return pp.config.OnConfigurationChange()
}
// ExecuteCommand executes a command that has been previously registered via the RegisterCommand.
func (pp *playbooksProduct) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
runner := command.NewCommandRunner(c, args, pp.serviceAdapter, pp.bot,
pp.playbookRunService, pp.playbookService, pp.config, pp.userInfoStore, pp.telemetryClient, pp.permissions)
if err := runner.Execute(); err != nil {
return nil, model.NewAppError("Playbooks.ExecuteCommand", "app.command.execute.error", nil, err.Error(), http.StatusInternalServerError)
}
return &model.CommandResponse{}, nil
}
func (pp *playbooksProduct) UserHasJoinedChannel(c *plugin.Context, channelMember *model.ChannelMember, actor *model.User) {
actorID := ""
if actor != nil && actor.Id != channelMember.UserId {
actorID = actor.Id
}
pp.channelActionService.UserHasJoinedChannel(channelMember.UserId, channelMember.ChannelId, actorID)
}
func (pp *playbooksProduct) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
pp.channelActionService.MessageHasBeenPosted(post)
pp.playbookRunService.MessageHasBeenPosted(post)
}
func (pp *playbooksProduct) UserHasPermissionToCollection(c *plugin.Context, userID string, collectionType, collectionID string, permission *model.Permission) (bool, error) {
if collectionType != CollectionTypeRun {
return false, errors.Errorf("collection %s is not registered by playbooks", collectionType)
}
run, err := pp.playbookRunService.GetPlaybookRun(collectionID)
if err != nil {
return false, errors.Wrapf(err, "No run with id - %s", collectionID)
}
return pp.permissions.HasPermissionsToRun(userID, run, permission), nil
}
func (pp *playbooksProduct) GetAllCollectionIDsForUser(c *plugin.Context, userID, collectionType string) ([]string, error) {
if collectionType != CollectionTypeRun {
return nil, errors.Errorf("collection %s is not registered by playbooks", collectionType)
}
ids, err := pp.playbookRunService.GetPlaybookRunIDsForUser(userID)
if err != nil {
return nil, err
}
return ids, nil
}
func (pp *playbooksProduct) GetAllUserIdsForCollection(c *plugin.Context, collectionType, collectionID string) ([]string, error) {
if collectionType != CollectionTypeRun {
return nil, errors.Errorf("collection %s is not registered by playbooks", collectionType)
}
run, err := pp.playbookRunService.GetPlaybookRun(collectionID)
if err != nil {
return nil, errors.Wrapf(err, "No run with id - %s", collectionID)
}
followers, err := pp.playbookRunService.GetFollowers(collectionID)
if err != nil {
return nil, errors.Wrapf(err, "can't get followers for run - %s", collectionID)
}
return mergeSlice(run.ParticipantIDs, followers), nil
}
func (pp *playbooksProduct) GetCollectionMetadataByIds(c *plugin.Context, collectionType string, collectionIDs []string) (map[string]*model.CollectionMetadata, error) {
if collectionType != CollectionTypeRun {
return nil, errors.Errorf("collection %s is not registered by playbooks", collectionType)
}
runsMetadata := map[string]*model.CollectionMetadata{}
runs, err := pp.playbookRunService.GetRunMetadataByIDs(collectionIDs)
if err != nil {
return nil, errors.Wrap(err, "can't get playbook run metadata by ids")
}
for _, run := range runs {
runsMetadata[run.ID] = &model.CollectionMetadata{
Id: run.ID,
CollectionType: CollectionTypeRun,
TeamId: run.TeamID,
Name: run.Name,
RelativeURL: app.GetRunDetailsRelativeURL(run.ID),
}
}
return runsMetadata, nil
}
func (pp *playbooksProduct) GetTopicMetadataByIds(c *plugin.Context, topicType string, topicIDs []string) (map[string]*model.TopicMetadata, error) {
topicsMetadata := map[string]*model.TopicMetadata{}
var getTopicMetadataByIDs func(topicIDs []string) ([]app.TopicMetadata, error)
switch topicType {
case TopicTypeStatus:
getTopicMetadataByIDs = pp.playbookRunService.GetStatusMetadataByIDs
case TopicTypeTask:
getTopicMetadataByIDs = pp.playbookRunService.GetTaskMetadataByIDs
default:
return map[string]*model.TopicMetadata{}, errors.Errorf("topic type %s is not registered by playbooks", topicType)
}
topics, err := getTopicMetadataByIDs(topicIDs)
if err != nil {
return nil, errors.Wrap(err, "can't get metadata by topic ids")
}
for _, topic := range topics {
topicsMetadata[topic.ID] = &model.TopicMetadata{
Id: topic.ID,
TopicType: topicType,
CollectionType: CollectionTypeRun,
TeamId: topic.TeamID,
CollectionId: topic.RunID,
}
}
return topicsMetadata, nil
}
func mergeSlice(a, b []string) []string {
m := make(map[string]struct{}, len(a)+len(b))
for _, elem := range a {
m[elem] = struct{}{}
}
for _, elem := range b {
m[elem] = struct{}{}
}
merged := make([]string, 0, len(m))
for key := range m {
merged = append(merged, key)
}
return merged
}

View File

@ -1,232 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cluster
import (
"encoding/json"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
// cronPrefix is used to namespace key values created for a job from other key values
// created by a plugin.
cronPrefix = "cron_"
)
// JobPluginAPI is the plugin API interface required to schedule jobs.
type JobPluginAPI interface {
MutexPluginAPI
KVGet(key string) ([]byte, error)
KVDelete(key string) error
KVList(page, count int) ([]string, error)
}
// JobConfig defines the configuration of a scheduled job.
type JobConfig struct {
// Interval is the period of execution for the job.
Interval time.Duration
}
// NextWaitInterval is a callback computing the next wait interval for a job.
type NextWaitInterval func(now time.Time, metadata JobMetadata) time.Duration
// MakeWaitForInterval creates a function to scheduling a job to run on the given interval relative
// to the last finished timestamp.
//
// For example, if the job first starts at 12:01 PM, and is configured with interval 5 minutes,
// it will next run at:
//
// 12:06, 12:11, 12:16, ...
//
// If the job has not previously started, it will run immediately.
func MakeWaitForInterval(interval time.Duration) NextWaitInterval {
if interval == 0 {
panic("must specify non-zero ready interval")
}
return func(now time.Time, metadata JobMetadata) time.Duration {
sinceLastFinished := now.Sub(metadata.LastFinished)
if sinceLastFinished < interval {
return interval - sinceLastFinished
}
return 0
}
}
// MakeWaitForRoundedInterval creates a function, scheduling a job to run on the nearest rounded
// interval relative to the last finished timestamp.
//
// For example, if the job first starts at 12:04 PM, and is configured with interval 5 minutes,
// and is configured to round to 5 minute intervals, it will next run at:
//
// 12:05 PM, 12:10 PM, 12:15 PM, ...
//
// If the job has not previously started, it will run immediately. Note that this wait interval
// strategy does not guarantee a minimum interval between runs, only that subsequent runs will be
// scheduled on the rounded interval.
func MakeWaitForRoundedInterval(interval time.Duration) NextWaitInterval {
if interval == 0 {
panic("must specify non-zero ready interval")
}
return func(now time.Time, metadata JobMetadata) time.Duration {
if metadata.LastFinished.IsZero() {
return 0
}
target := metadata.LastFinished.Add(interval).Truncate(interval)
untilTarget := target.Sub(now)
if untilTarget > 0 {
return untilTarget
}
return 0
}
}
// Job is a scheduled job whose callback function is executed on a configured interval by at most
// one plugin instance at a time.
//
// Use scheduled jobs to perform background activity on a regular interval without having to
// explicitly coordinate with other instances of the same plugin that might repeat that effort.
type Job struct {
pluginAPI JobPluginAPI
key string
mutex *Mutex
nextWaitInterval NextWaitInterval
callback func()
stopOnce sync.Once
stop chan bool
done chan bool
}
// JobMetadata persists metadata about job execution.
type JobMetadata struct {
// LastFinished is the last time the job finished anywhere in the cluster.
LastFinished time.Time
}
// Schedule creates a scheduled job.
func Schedule(pluginAPI JobPluginAPI, key string, nextWaitInterval NextWaitInterval, callback func()) (*Job, error) {
key = cronPrefix + key
mutex, err := NewMutex(pluginAPI, key)
if err != nil {
return nil, errors.Wrap(err, "failed to create job mutex")
}
job := &Job{
pluginAPI: pluginAPI,
key: key,
mutex: mutex,
nextWaitInterval: nextWaitInterval,
callback: callback,
stop: make(chan bool),
done: make(chan bool),
}
go job.run()
return job, nil
}
// readMetadata reads the job execution metadata from the kv store.
func (j *Job) readMetadata() (JobMetadata, error) {
data, appErr := j.pluginAPI.KVGet(j.key)
if appErr != nil {
return JobMetadata{}, errors.Wrap(appErr, "failed to read data")
}
if data == nil {
return JobMetadata{}, nil
}
var metadata JobMetadata
err := json.Unmarshal(data, &metadata)
if err != nil {
return JobMetadata{}, errors.Wrap(err, "failed to decode data")
}
return metadata, nil
}
// saveMetadata writes updated job execution metadata from the kv store.
//
// It is assumed that the job mutex is held, negating the need to require an atomic write.
func (j *Job) saveMetadata(metadata JobMetadata) error {
data, err := json.Marshal(metadata)
if err != nil {
return errors.Wrap(err, "failed to marshal data")
}
ok, appErr := j.pluginAPI.KVSetWithOptions(j.key, data, model.PluginKVSetOptions{})
if appErr != nil || !ok {
return errors.Wrap(appErr, "failed to set data")
}
return nil
}
// run attempts to run the scheduled job, guaranteeing only one instance is executing concurrently.
func (j *Job) run() {
defer close(j.done)
var waitInterval time.Duration
for {
select {
case <-j.stop:
return
case <-time.After(waitInterval):
}
func() {
// Acquire the corresponding job lock and hold it throughout execution.
j.mutex.Lock()
defer j.mutex.Unlock()
metadata, err := j.readMetadata()
if err != nil {
logrus.WithError(err).WithField("key", j.key).Error("failed to read job metadata")
waitInterval = nextWaitInterval(waitInterval, err)
return
}
// Is it time to run the job?
waitInterval = j.nextWaitInterval(time.Now(), metadata)
if waitInterval > 0 {
return
}
// Run the job
j.callback()
metadata.LastFinished = time.Now()
err = j.saveMetadata(metadata)
if err != nil {
logrus.WithError(err).WithField("key", j.key).Error("failed to write job data")
}
waitInterval = j.nextWaitInterval(time.Now(), metadata)
}()
}
}
// Close terminates a scheduled job, preventing it from being scheduled on this plugin instance.
func (j *Job) Close() error {
j.stopOnce.Do(func() {
close(j.stop)
})
<-j.done
return nil
}

View File

@ -1,212 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cluster
import (
"encoding/json"
"math/rand"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
)
const (
// oncePrefix is used to namespace key values created for a scheduleOnce job
oncePrefix = "once_"
// keysPerPage is the maximum number of keys to retrieve from the db per call
keysPerPage = 1000
// maxNumFails is the maximum number of KVStore read fails or failed attempts to run the
// callback until the scheduler cancels a job.
maxNumFails = 3
// waitAfterFail is the amount of time to wait after a failure
waitAfterFail = 1 * time.Second
// pollNewJobsInterval is the amount of time to wait between polling the db for new scheduled jobs
pollNewJobsInterval = 5 * time.Minute
// scheduleOnceJitter is the range of jitter to add to intervals to avoid contention issues
scheduleOnceJitter = 100 * time.Millisecond
)
type JobOnceMetadata struct {
Key string
RunAt time.Time
}
type JobOnce struct {
pluginAPI JobPluginAPI
clusterMutex *Mutex
// key is the original key. It is prefixed with oncePrefix when used as a key in the KVStore
key string
runAt time.Time
numFails int
// done signals the job.run go routine to exit
done chan bool
doneOnce sync.Once
// join is a join point for the job.run() goroutine to join the calling goroutine (in this case,
// the one calling job.Cancel)
join chan bool
joinOnce sync.Once
storedCallback *syncedCallback
activeJobs *syncedJobs
}
// Cancel terminates a scheduled job, preventing it from being scheduled on this plugin instance.
// It also removes the job from the db, preventing it from being run in the future.
func (j *JobOnce) Cancel() {
j.clusterMutex.Lock()
defer j.clusterMutex.Unlock()
j.cancelWhileHoldingMutex()
// join the running goroutine
j.joinOnce.Do(func() {
<-j.join
})
}
func newJobOnce(pluginAPI JobPluginAPI, key string, runAt time.Time, callback *syncedCallback, jobs *syncedJobs) (*JobOnce, error) {
mutex, err := NewMutex(pluginAPI, key)
if err != nil {
return nil, errors.Wrap(err, "failed to create job mutex")
}
return &JobOnce{
pluginAPI: pluginAPI,
clusterMutex: mutex,
key: key,
runAt: runAt,
done: make(chan bool),
join: make(chan bool),
storedCallback: callback,
activeJobs: jobs,
}, nil
}
func (j *JobOnce) run() {
defer close(j.join)
wait := time.Until(j.runAt)
for {
select {
case <-j.done:
return
case <-time.After(wait + addJitter()):
}
func() {
// Acquire the cluster mutex while we're trying to do the job
j.clusterMutex.Lock()
defer j.clusterMutex.Unlock()
// Check that the job has not been completed
metadata, err := readMetadata(j.pluginAPI, j.key)
if err != nil {
j.numFails++
if j.numFails > maxNumFails {
j.cancelWhileHoldingMutex()
return
}
// wait a bit of time and try again
wait = waitAfterFail
return
}
// If key doesn't exist, or if the runAt has changed, the original job has been completed already
if metadata == nil || !j.runAt.Equal(metadata.RunAt) {
j.cancelWhileHoldingMutex()
return
}
j.executeJob()
j.cancelWhileHoldingMutex()
}()
}
}
func (j *JobOnce) executeJob() {
j.storedCallback.mu.Lock()
defer j.storedCallback.mu.Unlock()
j.storedCallback.callback(j.key)
}
// readMetadata reads the job's stored metadata. If the caller wishes to make an atomic
// read/write, the cluster mutex for job's key should be held.
func readMetadata(pluginAPI JobPluginAPI, key string) (*JobOnceMetadata, error) {
data, err := pluginAPI.KVGet(oncePrefix + key)
if err != nil {
return nil, errors.Wrap(err, "failed to read data")
}
if data == nil {
return nil, nil
}
var metadata JobOnceMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, errors.Wrap(err, "failed to decode data")
}
return &metadata, nil
}
// saveMetadata writes the job's metadata to the kvstore. saveMetadata acquires the job's cluster lock.
// saveMetadata will not overwrite an existing key.
func (j *JobOnce) saveMetadata() error {
j.clusterMutex.Lock()
defer j.clusterMutex.Unlock()
metadata := JobOnceMetadata{
Key: j.key,
RunAt: j.runAt,
}
data, err := json.Marshal(metadata)
if err != nil {
return errors.Wrap(err, "failed to marshal data")
}
ok, err := j.pluginAPI.KVSetWithOptions(oncePrefix+j.key, data, model.PluginKVSetOptions{
Atomic: true,
OldValue: nil,
})
if err != nil {
return err
}
if !ok {
return errors.New("failed to set data")
}
return nil
}
// cancelWhileHoldingMutex assumes the caller holds the job's mutex.
func (j *JobOnce) cancelWhileHoldingMutex() {
// remove the job from the kv store, if it exists
_ = j.pluginAPI.KVDelete(oncePrefix + j.key)
j.activeJobs.mu.Lock()
defer j.activeJobs.mu.Unlock()
delete(j.activeJobs.jobs, j.key)
j.doneOnce.Do(func() {
close(j.done)
})
}
func addJitter() time.Duration {
return time.Duration(rand.Int63n(int64(scheduleOnceJitter)))
}

View File

@ -1,231 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cluster
import (
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// syncedCallback uses the mutex to make things predictable for the client: the callback will be
// called once at a time (the client does not need to worry about concurrency within the callback)
type syncedCallback struct {
mu sync.Mutex
callback func(string)
}
type syncedJobs struct {
mu sync.RWMutex
jobs map[string]*JobOnce
}
type JobOnceScheduler struct {
pluginAPI JobPluginAPI
startedMu sync.RWMutex
started bool
activeJobs *syncedJobs
storedCallback *syncedCallback
}
// GetJobOnceScheduler returns a scheduler which is ready to have its callback set. Repeated
// calls will return the same scheduler.
func GetJobOnceScheduler(pluginAPI JobPluginAPI) *JobOnceScheduler {
return &JobOnceScheduler{
pluginAPI: pluginAPI,
activeJobs: &syncedJobs{
jobs: make(map[string]*JobOnce),
},
storedCallback: &syncedCallback{},
}
}
// Start starts the Scheduler. It finds all previous ScheduleOnce jobs and starts them running, and
// fires any jobs that have reached or exceeded their runAt time. Thus, even if a cluster goes down
// and is restarted, Start will restart previously scheduled jobs.
func (s *JobOnceScheduler) Start() error {
s.startedMu.Lock()
defer s.startedMu.Unlock()
if s.started {
return errors.New("scheduler has already been started")
}
if err := s.verifyCallbackExists(); err != nil {
return errors.Wrap(err, "callback not found; cannot start scheduler")
}
if err := s.scheduleNewJobsFromDB(); err != nil {
return errors.Wrap(err, "could not start JobOnceScheduler due to error")
}
go s.pollForNewScheduledJobs()
s.started = true
return nil
}
// SetCallback sets the scheduler's callback. When a job fires, the callback will be called with
// the job's id.
func (s *JobOnceScheduler) SetCallback(callback func(string)) error {
if callback == nil {
return errors.New("callback cannot be nil")
}
s.storedCallback.mu.Lock()
defer s.storedCallback.mu.Unlock()
s.storedCallback.callback = callback
return nil
}
// ListScheduledJobs returns a list of the jobs in the db that have been scheduled. There is no
// guarantee that list is accurate by the time the caller reads the list. E.g., the jobs in the list
// may have been run, canceled, or new jobs may have scheduled.
func (s *JobOnceScheduler) ListScheduledJobs() ([]JobOnceMetadata, error) {
var ret []JobOnceMetadata
for i := 0; ; i++ {
keys, err := s.pluginAPI.KVList(i, keysPerPage)
if err != nil {
return nil, errors.Wrap(err, "error getting KVList")
}
for _, k := range keys {
if strings.HasPrefix(k, oncePrefix) {
metadata, err := readMetadata(s.pluginAPI, k[len(oncePrefix):])
if err != nil {
logrus.WithError(err).WithField("key", k).Error("could not retrieve data from plugin kvstore")
continue
}
if metadata == nil {
continue
}
ret = append(ret, *metadata)
}
}
if len(keys) < keysPerPage {
break
}
}
return ret, nil
}
// ScheduleOnce creates a scheduled job that will run once. When the clock reaches runAt, the
// callback will be called with key as the argument.
//
// If the job key already exists in the db, this will return an error. To reschedule a job, first
// cancel the original then schedule it again.
func (s *JobOnceScheduler) ScheduleOnce(key string, runAt time.Time) (*JobOnce, error) {
s.startedMu.RLock()
defer s.startedMu.RUnlock()
if !s.started {
return nil, errors.New("start the scheduler before adding jobs")
}
job, err := newJobOnce(s.pluginAPI, key, runAt, s.storedCallback, s.activeJobs)
if err != nil {
return nil, errors.Wrap(err, "could not create new job")
}
if err = job.saveMetadata(); err != nil {
return nil, errors.Wrap(err, "could not save job metadata")
}
s.runAndTrack(job)
return job, nil
}
// Cancel cancels a job by its key. This is useful if the plugin lost the original *JobOnce, or
// is stopping a job found in ListScheduledJobs().
func (s *JobOnceScheduler) Cancel(key string) {
// using an anonymous function because job.Close() below needs access to the activeJobs mutex
job := func() *JobOnce {
s.activeJobs.mu.RLock()
defer s.activeJobs.mu.RUnlock()
j, ok := s.activeJobs.jobs[key]
if ok {
return j
}
// Job wasn't active, so no need to call CancelWhileHoldingMutex (which shuts down the
// goroutine). There's a condition where another server in the cluster started the job, and
// the current server hasn't polled for it yet. To solve that case, delete it from the db.
mutex, err := NewMutex(s.pluginAPI, key)
if err != nil {
logrus.WithError(err).WithField("key", key).Error("failed to create job mutex in Cancel")
}
mutex.Lock()
defer mutex.Unlock()
_ = s.pluginAPI.KVDelete(oncePrefix + key)
return nil
}()
if job != nil {
job.Cancel()
}
}
func (s *JobOnceScheduler) scheduleNewJobsFromDB() error {
scheduled, err := s.ListScheduledJobs()
if err != nil {
return errors.Wrap(err, "could not read scheduled jobs from db")
}
for _, m := range scheduled {
job, err := newJobOnce(s.pluginAPI, m.Key, m.RunAt, s.storedCallback, s.activeJobs)
if err != nil {
logrus.WithError(err).WithField("key", m.Key).Error("could not create new job")
continue
}
s.runAndTrack(job)
}
return nil
}
func (s *JobOnceScheduler) runAndTrack(job *JobOnce) {
s.activeJobs.mu.Lock()
defer s.activeJobs.mu.Unlock()
// has this been scheduled already on this server?
if _, ok := s.activeJobs.jobs[job.key]; ok {
return
}
go job.run()
s.activeJobs.jobs[job.key] = job
}
// pollForNewScheduledJobs will only be started once per plugin. It doesn't need to be stopped.
func (s *JobOnceScheduler) pollForNewScheduledJobs() {
for {
<-time.After(pollNewJobsInterval + addJitter())
if err := s.scheduleNewJobsFromDB(); err != nil {
logrus.WithError(err).Error("scheduleOnce poller encountered an error but is still polling")
}
}
}
func (s *JobOnceScheduler) verifyCallbackExists() error {
s.storedCallback.mu.Lock()
defer s.storedCallback.mu.Unlock()
if s.storedCallback.callback == nil {
return errors.New("set callback before starting the scheduler")
}
return nil
}

View File

@ -1,188 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cluster
import (
"context"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
// mutexPrefix is used to namespace key values created for a mutex from other key values
// created by a plugin.
mutexPrefix = "mutex_"
)
const (
// ttl is the interval after which a locked mutex will expire unless refreshed
ttl = time.Second * 15
// refreshInterval is the interval on which the mutex will be refreshed when locked
refreshInterval = ttl / 2
)
// MutexPluginAPI is the plugin API interface required to manage mutexes.
type MutexPluginAPI interface {
KVSetWithOptions(key string, value []byte, options model.PluginKVSetOptions) (bool, error)
}
// Mutex is similar to sync.Mutex, except usable by multiple plugin instances across a cluster.
//
// Internally, a mutex relies on an atomic key-value set operation as exposed by the Mattermost
// plugin API.
//
// Mutexes with different names are unrelated. Mutexes with the same name from different plugins
// are unrelated. Pick a unique name for each mutex your plugin requires.
//
// A Mutex must not be copied after first use.
type Mutex struct {
pluginAPI MutexPluginAPI
key string
// lock guards the variables used to manage the refresh task, and is not itself related to
// the cluster-wide lock.
lock sync.Mutex
stopRefresh chan bool
refreshDone chan bool
}
// NewMutex creates a mutex with the given key name.
//
// Panics if key is empty.
func NewMutex(pluginAPI MutexPluginAPI, key string) (*Mutex, error) {
key, err := makeLockKey(key)
if err != nil {
return nil, err
}
return &Mutex{
pluginAPI: pluginAPI,
key: key,
}, nil
}
// makeLockKey returns the prefixed key used to namespace mutex keys.
func makeLockKey(key string) (string, error) {
if key == "" {
return "", errors.New("must specify valid mutex key")
}
return mutexPrefix + key, nil
}
// lock makes a single attempt to atomically lock the mutex, returning true only if successful.
func (m *Mutex) tryLock() (bool, error) {
ok, err := m.pluginAPI.KVSetWithOptions(m.key, []byte{1}, model.PluginKVSetOptions{
Atomic: true,
OldValue: nil, // No existing key value.
ExpireInSeconds: int64(ttl / time.Second),
})
if err != nil {
return false, errors.Wrap(err, "failed to set mutex kv")
}
return ok, nil
}
// refreshLock rewrites the lock key value with a new expiry, returning true only if successful.
func (m *Mutex) refreshLock() error {
ok, err := m.pluginAPI.KVSetWithOptions(m.key, []byte{1}, model.PluginKVSetOptions{
Atomic: true,
OldValue: []byte{1},
ExpireInSeconds: int64(ttl / time.Second),
})
if err != nil {
return errors.Wrap(err, "failed to refresh mutex kv")
} else if !ok {
return errors.New("unexpectedly failed to refresh mutex kv")
}
return nil
}
// Lock locks m. If the mutex is already locked by any plugin instance, including the current one,
// the calling goroutine blocks until the mutex can be locked.
func (m *Mutex) Lock() {
_ = m.LockWithContext(context.Background())
}
// LockWithContext locks m unless the context is canceled. If the mutex is already locked by any plugin
// instance, including the current one, the calling goroutine blocks until the mutex can be locked,
// or the context is canceled.
//
// The mutex is locked only if a nil error is returned.
func (m *Mutex) LockWithContext(ctx context.Context) error {
var waitInterval time.Duration
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(waitInterval):
}
locked, err := m.tryLock()
if err != nil {
logrus.WithError(err).WithField("lock_key", m.key).Error("failed to lock mutex")
waitInterval = nextWaitInterval(waitInterval, err)
continue
} else if !locked {
waitInterval = nextWaitInterval(waitInterval, err)
continue
}
stop := make(chan bool)
done := make(chan bool)
go func() {
defer close(done)
t := time.NewTicker(refreshInterval)
for {
select {
case <-t.C:
err := m.refreshLock()
if err != nil {
logrus.WithError(err).WithField("lock_key", m.key).Error("failed to refresh mutex")
return
}
case <-stop:
return
}
}
}()
m.lock.Lock()
m.stopRefresh = stop
m.refreshDone = done
m.lock.Unlock()
return nil
}
}
// Unlock unlocks m. It is a run-time error if m is not locked on entry to Unlock.
//
// Just like sync.Mutex, a locked Lock is not associated with a particular goroutine or plugin
// instance. It is allowed for one goroutine or plugin instance to lock a Lock and then arrange
// for another goroutine or plugin instance to unlock it. In practice, ownership of the lock should
// remain within a single plugin instance.
func (m *Mutex) Unlock() {
m.lock.Lock()
if m.stopRefresh == nil {
m.lock.Unlock()
panic("mutex has not been acquired")
}
close(m.stopRefresh)
m.stopRefresh = nil
<-m.refreshDone
m.lock.Unlock()
// If an error occurs deleting, the mutex kv will still expire, allowing later retry.
_, _ = m.pluginAPI.KVSetWithOptions(m.key, nil, model.PluginKVSetOptions{})
}

View File

@ -1,46 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cluster
import (
"math/rand"
"time"
)
const (
// minWaitInterval is the minimum amount of time to wait between locking attempts
minWaitInterval = 1 * time.Second
// maxWaitInterval is the maximum amount of time to wait between locking attempts
maxWaitInterval = 5 * time.Minute
// pollWaitInterval is the usual time to wait between unsuccessful locking attempts
pollWaitInterval = 1 * time.Second
// jitterWaitInterval is the amount of jitter to add when waiting to avoid thundering herds
jitterWaitInterval = minWaitInterval / 2
)
// nextWaitInterval determines how long to wait until the next lock retry.
func nextWaitInterval(lastWaitInterval time.Duration, err error) time.Duration {
nextWaitInterval := lastWaitInterval
if nextWaitInterval <= 0 {
nextWaitInterval = minWaitInterval
}
if err != nil {
nextWaitInterval *= 2
if nextWaitInterval > maxWaitInterval {
nextWaitInterval = maxWaitInterval
}
} else {
nextWaitInterval = pollWaitInterval
}
// Add some jitter to avoid unnecessary collision between competing plugin instances.
nextWaitInterval += time.Duration(rand.Int63n(int64(jitterWaitInterval)) - int64(jitterWaitInterval)/2)
return nextWaitInterval
}

View File

@ -1,110 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package pluginapi
import (
"github.com/mattermost/mattermost/server/public/model"
)
const (
e10 = "E10"
e20 = "E20"
professional = "professional"
enterprise = "enterprise"
)
// IsEnterpriseLicensedOrDevelopment returns true when the server is licensed with any Mattermost
// Enterprise License, or has `EnableDeveloper` and `EnableTesting` configuration settings
// enabled signaling a non-production, developer mode.
func IsEnterpriseLicensedOrDevelopment(config *model.Config, license *model.License) bool {
if license != nil {
return true
}
return IsConfiguredForDevelopment(config)
}
// isValidSkuShortName returns whether the SKU short name is one of the known strings;
// namely: E10 or professional, or E20 or enterprise
func isValidSkuShortName(license *model.License) bool {
if license == nil {
return false
}
switch license.SkuShortName {
case e10, e20, professional, enterprise:
return true
default:
return false
}
}
// IsE10LicensedOrDevelopment returns true when the server is at least licensed with a legacy Mattermost
// Enterprise E10 License or a Mattermost Professional License, or has `EnableDeveloper` and
// `EnableTesting` configuration settings enabled, signaling a non-production, developer mode.
func IsE10LicensedOrDevelopment(config *model.Config, license *model.License) bool {
if license != nil &&
(license.SkuShortName == e10 || license.SkuShortName == professional ||
license.SkuShortName == e20 || license.SkuShortName == enterprise) {
return true
}
if !isValidSkuShortName(license) {
// As a fallback for licenses whose SKU short name is unknown, make a best effort to try
// and use the presence of a known E10/Professional feature as a check to determine licensing.
if license != nil &&
license.Features != nil &&
license.Features.LDAP != nil &&
*license.Features.LDAP {
return true
}
}
return IsConfiguredForDevelopment(config)
}
// IsE20LicensedOrDevelopment returns true when the server is licensed with a legacy Mattermost
// Enterprise E20 License or a Mattermost Enterprise License, or has `EnableDeveloper` and
// `EnableTesting` configuration settings enabled, signaling a non-production, developer mode.
func IsE20LicensedOrDevelopment(config *model.Config, license *model.License) bool {
if license != nil && (license.SkuShortName == e20 || license.SkuShortName == enterprise) {
return true
}
if !isValidSkuShortName(license) {
// As a fallback for licenses whose SKU short name is unknown, make a best effort to try
// and use the presence of a known E20/Enterprise feature as a check to determine licensing.
if license != nil &&
license.Features != nil &&
license.Features.FutureFeatures != nil &&
*license.Features.FutureFeatures {
return true
}
}
return IsConfiguredForDevelopment(config)
}
// IsConfiguredForDevelopment returns true when the server has `EnableDeveloper` and `EnableTesting`
// configuration settings enabled, signaling a non-production, developer mode.
func IsConfiguredForDevelopment(config *model.Config) bool {
if config != nil &&
config.ServiceSettings.EnableTesting != nil &&
*config.ServiceSettings.EnableTesting &&
config.ServiceSettings.EnableDeveloper != nil &&
*config.ServiceSettings.EnableDeveloper {
return true
}
return false
}
// IsCloud returns true when the server is on cloud, and false otherwise.
func IsCloud(license *model.License) bool {
if license == nil || license.Features == nil || license.Features.Cloud == nil {
return false
}
return *license.Features.Cloud
}

View File

@ -1,3 +0,0 @@
coverage.txt
dist
data

View File

@ -1,214 +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"
"net/url"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
"github.com/gorilla/mux"
)
type ActionsHandler struct {
*ErrorHandler
channelActionsService app.ChannelActionService
api playbooks.ServicesAPI
permissions *app.PermissionsService
}
func NewActionsHandler(router *mux.Router, channelActionsService app.ChannelActionService, api playbooks.ServicesAPI, permissions *app.PermissionsService) *ActionsHandler {
handler := &ActionsHandler{
ErrorHandler: &ErrorHandler{},
channelActionsService: channelActionsService,
api: api,
permissions: permissions,
}
actionsRouter := router.PathPrefix("/actions").Subrouter()
channelsActionsRouter := actionsRouter.PathPrefix("/channels").Subrouter()
channelActionsRouter := channelsActionsRouter.PathPrefix("/{channel_id:[A-Za-z0-9]+}").Subrouter()
channelActionsRouter.HandleFunc("", withContext(handler.createChannelAction)).Methods(http.MethodPost)
channelActionsRouter.HandleFunc("", withContext(handler.getChannelActions)).Methods(http.MethodGet)
channelActionsRouter.HandleFunc("/check-and-send-message-on-join", withContext(handler.checkAndSendMessageOnJoin)).Methods(http.MethodGet)
channelActionRouter := channelActionsRouter.PathPrefix("/{action_id:[A-Za-z0-9]+}").Subrouter()
channelActionRouter.HandleFunc("", withContext(handler.updateChannelAction)).Methods(http.MethodPut)
return handler
}
func (a *ActionsHandler) createChannelAction(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
vars := mux.Vars(r)
channelID := vars["channel_id"]
if !a.PermissionsCheck(w, c.logger, a.permissions.ChannelActionCreate(userID, channelID)) {
return
}
var channelAction app.GenericChannelAction
if err := json.NewDecoder(r.Body).Decode(&channelAction); err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to parse action", err)
return
}
// Ensure that the channel ID in both the URL and the body of the request are the same;
// otherwise the permission check done above no longer makes sense
if channelAction.ChannelID != channelID {
a.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "channel ID in request body must match channel ID in URL", nil)
return
}
// Validate the action type and payload
if err := a.channelActionsService.Validate(channelAction); err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid action", err)
return
}
id, err := a.channelActionsService.Create(channelAction)
if err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusInternalServerError, "unable to create action", err)
return
}
result := struct {
ID string `json:"id"`
}{
ID: id,
}
w.Header().Add("Location", makeAPIURL(a.api, "actions/channel/%s/%s", channelAction.ChannelID, id))
ReturnJSON(w, &result, http.StatusCreated)
}
func isValidTrigger(trigger string) bool {
if trigger == "" {
return true
}
for _, elem := range app.ValidTriggerTypes {
if trigger == string(elem) {
return true
}
}
return false
}
func isValidAction(action string) bool {
if action == "" {
return true
}
for _, elem := range app.ValidActionTypes {
if action == string(elem) {
return true
}
}
return false
}
func parseGetChannelActionsOptions(query url.Values) (*app.GetChannelActionOptions, error) {
actionTypeStr := query.Get("action_type")
triggerTypeStr := query.Get("trigger_type")
if !isValidAction(actionTypeStr) {
return nil, fmt.Errorf("action_type %q not recognized; valid values are %v", actionTypeStr, app.ValidActionTypes)
}
if !isValidTrigger(triggerTypeStr) {
return nil, fmt.Errorf("trigger_type %q not recognized; valid values are %v", triggerTypeStr, app.ValidTriggerTypes)
}
return &app.GetChannelActionOptions{
ActionType: app.ActionType(actionTypeStr),
TriggerType: app.TriggerType(triggerTypeStr),
}, nil
}
func (a *ActionsHandler) getChannelActions(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
vars := mux.Vars(r)
channelID := vars["channel_id"]
if !a.PermissionsCheck(w, c.logger, a.permissions.ChannelActionView(userID, channelID)) {
return
}
options, err := parseGetChannelActionsOptions(r.URL.Query())
if err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, errors.Wrapf(err, "bad options").Error(), err)
return
}
actions, err := a.channelActionsService.GetChannelActions(channelID, *options)
if err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusInternalServerError, fmt.Sprintf("unable to retrieve actions for channel %s", channelID), err)
return
}
ReturnJSON(w, &actions, http.StatusOK)
}
// checkAndSendMessageOnJoin handles the GET /actions/channels/{channel_id}/check_and_send_message_on_join endpoint.
func (a *ActionsHandler) checkAndSendMessageOnJoin(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
channelID := vars["channel_id"]
userID := r.Header.Get("Mattermost-User-ID")
if !a.PermissionsCheck(w, c.logger, a.permissions.ChannelActionView(userID, channelID)) {
return
}
hasViewed := a.channelActionsService.CheckAndSendMessageOnJoin(userID, channelID)
ReturnJSON(w, map[string]interface{}{"viewed": hasViewed}, http.StatusOK)
}
func (a *ActionsHandler) updateChannelAction(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
vars := mux.Vars(r)
channelID := vars["channel_id"]
if !a.PermissionsCheck(w, c.logger, a.permissions.ChannelActionUpdate(userID, channelID)) {
return
}
var newChannelAction app.GenericChannelAction
if err := json.NewDecoder(r.Body).Decode(&newChannelAction); err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to parse action", err)
return
}
// Ensure that the channel ID in both the URL and the body of the request are the same;
// otherwise the permission check done above no longer makes sense
if newChannelAction.ChannelID != channelID {
a.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "channel ID in request body must match channel ID in URL", nil)
return
}
// Validate the new action type and payload
if err := a.channelActionsService.Validate(newChannelAction); err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid action", err)
return
}
err := a.channelActionsService.Update(newChannelAction, userID)
if err != nil {
a.HandleErrorWithCode(w, c.logger, http.StatusInternalServerError, fmt.Sprintf("unable to update action with ID %q", newChannelAction.ID), err)
return
}
w.WriteHeader(http.StatusOK)
}

View File

@ -1,114 +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/sirupsen/logrus"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
)
// MaxRequestSize is the size limit for any incoming request
// The default limit set by mattermost-server is the configured max file size, and
// it sometimes isn't small enough to prevent some scenarios.
//
// This is important to prevent huge payloads from being sent
// that could end in a bigger problem.
//
// If an endpoint needs a smaller limit than this one, it could be solved by adding their
// own limit BEFORE reading the request body `r.Body = http.MaxBytesReader(w, r.Body, MaxRequestSize)`
const MaxRequestSize = 5 * 1024 * 1024 // 5MB
// Handler Root API handler.
type Handler struct {
*ErrorHandler
APIRouter *mux.Router
root *mux.Router
config config.Service
}
// NewHandler constructs a new handler.
func NewHandler(config config.Service) *Handler {
handler := &Handler{
ErrorHandler: &ErrorHandler{},
config: config,
}
root := mux.NewRouter()
api := root.PathPrefix("/api/v0").Subrouter()
api.Use(LogRequest)
api.Use(MattermostAuthorizationRequired)
api.Handle("{anything:.*}", http.NotFoundHandler())
api.NotFoundHandler = http.NotFoundHandler()
handler.APIRouter = api
handler.root = root
handler.config = config
return handler
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxRequestSize)
h.root.ServeHTTP(w, r)
}
// handleResponseWithCode logs the internal error and sends the public facing error
// message as JSON in a response with the provided code.
func handleResponseWithCode(w http.ResponseWriter, code int, publicMsg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
responseMsg, _ := json.Marshal(struct {
Error string `json:"error"` // A public facing message providing details about the error.
}{
Error: publicMsg,
})
_, _ = w.Write(responseMsg)
}
// HandleErrorWithCode logs the internal error and sends the public facing error
// message as JSON in a response with the provided code.
func HandleErrorWithCode(logger logrus.FieldLogger, w http.ResponseWriter, code int, publicErrorMsg string, internalErr error) {
if internalErr != nil {
logger = logger.WithError(internalErr)
}
if code >= http.StatusInternalServerError {
logger.Error(publicErrorMsg)
} else {
logger.Warn(publicErrorMsg)
}
handleResponseWithCode(w, code, publicErrorMsg)
}
// ReturnJSON writes the given pointerToObject as json with the provided httpStatus
func ReturnJSON(w http.ResponseWriter, pointerToObject interface{}, httpStatus int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
if err := json.NewEncoder(w).Encode(pointerToObject); err != nil {
logrus.WithError(err).Warn("Unable to write to http.ResponseWriter")
return
}
}
// MattermostAuthorizationRequired checks if request is authorized.
func MattermostAuthorizationRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-Id")
if userID != "" {
next.ServeHTTP(w, r)
return
}
http.Error(w, "Not authorized", http.StatusUnauthorized)
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,226 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/bot"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
)
type BotHandler struct {
*ErrorHandler
api playbooks.ServicesAPI
poster bot.Poster
config config.Service
playbookRunService app.PlaybookRunService
userInfoStore app.UserInfoStore
}
func NewBotHandler(router *mux.Router, api playbooks.ServicesAPI, poster bot.Poster, config config.Service, playbookRunService app.PlaybookRunService, userInfoStore app.UserInfoStore) *BotHandler {
handler := &BotHandler{
ErrorHandler: &ErrorHandler{},
api: api,
poster: poster,
config: config,
playbookRunService: playbookRunService,
userInfoStore: userInfoStore,
}
botRouter := router.PathPrefix("/bot").Subrouter()
notifyAdminsRouter := botRouter.PathPrefix("/notify-admins").Subrouter()
notifyAdminsRouter.HandleFunc("", withContext(handler.notifyAdmins)).Methods(http.MethodPost)
notifyAdminsRouter.HandleFunc("/button-start-trial", withContext(handler.startTrial)).Methods(http.MethodPost)
botRouter.HandleFunc("/connect", withContext(handler.connect)).Methods(http.MethodGet)
return handler
}
type messagePayload struct {
MessageType string `json:"message_type"`
}
func (h *BotHandler) notifyAdmins(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
var payload messagePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode message", err)
return
}
if err := h.poster.NotifyAdmins(payload.MessageType, userID, !h.api.IsEnterpriseReady()); err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusOK)
}
func CanStartTrialLicense(userID string, api playbooks.ServicesAPI) error {
if !api.HasPermissionTo(userID, model.PermissionManageLicenseInformation) {
return errors.Wrap(app.ErrNoPermissions, "no permission to manage license information")
}
return nil
}
func (h *BotHandler) startTrial(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
if err := CanStartTrialLicense(userID, h.api); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusForbidden, "no permission to start a trial license", err)
return
}
var requestData *model.PostActionIntegrationRequest
err := json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to parse json", err)
return
}
if requestData == nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "missing request data", nil)
return
}
users, ok := requestData.Context["users"].(float64)
if !ok {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "malformed context: users is not a number", nil)
return
}
termsAccepted, ok := requestData.Context["termsAccepted"].(bool)
if !ok {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "malformed context: termsAccepted is not a boolean", nil)
return
}
receiveEmailsAccepted, ok := requestData.Context["receiveEmailsAccepted"].(bool)
if !ok {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "malformed context: receiveEmailsAccepted is not a boolean", nil)
return
}
originalPost, err := h.api.GetPost(requestData.PostId)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
// Modify the button text while the license is downloading
originalAttachments := originalPost.Attachments()
outer:
for _, attachment := range originalAttachments {
for _, action := range attachment.Actions {
if action.Id == "message" {
action.Name = "Requesting trial..."
break outer
}
}
}
model.ParseSlackAttachment(originalPost, originalAttachments)
_, _ = h.api.UpdatePost(originalPost)
post := &model.Post{
Id: requestData.PostId,
}
if err := h.api.RequestTrialLicense(requestData.UserId, int(users), termsAccepted, receiveEmailsAccepted); err != nil {
post.Message = "Trial license could not be retrieved. Visit [https://mattermost.com/trial/](https://mattermost.com/trial/) to request a license."
if _, postErr := h.api.UpdatePost(post); postErr != nil {
logrus.WithError(postErr).WithField("post_id", post.Id).Error("unable to edit the admin notification post")
}
h.HandleErrorWithCode(w, c.logger, http.StatusInternalServerError, "unable to request the trial license", err)
return
}
post.Message = "Thank you!"
attachments := []*model.SlackAttachment{
{
Title: "Youre currently on a free trial of Mattermost Enterprise.",
Text: "Your free trial will expire in **30 days**. Visit our Customer Portal to purchase a license to continue using commercial edition features after your trial ends.\n[Purchase a license](https://customers.mattermost.com/signup)\n[Contact sales](https://mattermost.com/contact-us/)",
},
}
model.ParseSlackAttachment(post, attachments)
if _, err := h.api.UpdatePost(post); err != nil {
logrus.WithError(err).WithField("post_id", post.Id).Error("unable to edit the admin notification post")
}
ReturnJSON(w, post, http.StatusOK)
}
type DigestSenderParams struct {
isWeekly bool
}
// connect handles the GET /bot/connect endpoint (a notification sent when the client wakes up or reconnects)
func (h *BotHandler) connect(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
info, err := h.userInfoStore.Get(userID)
if errors.Is(err, app.ErrNotFound) {
info = app.UserInfo{
ID: userID,
}
} else if err != nil {
h.HandleError(w, c.logger, err)
return
}
var timezone *time.Location
offset, _ := strconv.Atoi(r.Header.Get("X-Timezone-Offset"))
timezone = time.FixedZone("local", offset*60*60)
sendRegularDigest := h.createDigestSender(c, w, userID, &info)
// we want to first try a weekly digest
// if we have already sent it this week, try with a daily one
currentTime := time.UnixMilli(model.GetMillis()).In(timezone)
if app.ShouldSendWeeklyDigestMessage(info, timezone, currentTime) {
sendRegularDigest(DigestSenderParams{isWeekly: true})
} else if app.ShouldSendDailyDigestMessage(info, timezone, currentTime) {
sendRegularDigest(DigestSenderParams{isWeekly: false})
}
w.WriteHeader(http.StatusOK)
}
func (h *BotHandler) createDigestSender(c *Context, w http.ResponseWriter, userID string, userInfo *app.UserInfo) func(DigestSenderParams) {
return func(params DigestSenderParams) {
now := model.GetMillis()
// record that we're sending a DM now (this will prevent us trying over and over on every
// response if there's a failure later)
userInfo.LastDailyTodoDMAt = now
if err := h.userInfoStore.Upsert(*userInfo); err != nil {
h.HandleError(w, c.logger, err)
return
}
regulartity := "daily"
if params.isWeekly {
regulartity = "weekly"
}
if err := h.playbookRunService.DMTodoDigestToUser(userID, false, params.isWeekly); err != nil {
h.HandleError(w, c.logger, errors.Wrapf(err, "failed to send '%s' DMTodoDigest to userID '%s'", regulartity, userID))
return
}
}
}

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"
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
)
const maxItemsInRunsAndPlaybooksCategory = 1000
type CategoryHandler struct {
*ErrorHandler
api playbooks.ServicesAPI
categoryService app.CategoryService
playbookService app.PlaybookService
playbookRunService app.PlaybookRunService
}
func NewCategoryHandler(router *mux.Router, api playbooks.ServicesAPI, categoryService app.CategoryService, playbookService app.PlaybookService, playbookRunService app.PlaybookRunService) *CategoryHandler {
handler := &CategoryHandler{
ErrorHandler: &ErrorHandler{},
api: api,
categoryService: categoryService,
playbookService: playbookService,
playbookRunService: playbookRunService,
}
categoriesRouter := router.PathPrefix("/my_categories").Subrouter()
categoriesRouter.HandleFunc("", withContext(handler.getMyCategories)).Methods(http.MethodGet)
categoriesRouter.HandleFunc("", withContext(handler.createMyCategory)).Methods(http.MethodPost)
categoriesRouter.HandleFunc("/favorites", withContext(handler.isFavorite)).Methods(http.MethodGet)
categoryRouter := categoriesRouter.PathPrefix("/{id:[A-Za-z0-9]+}").Subrouter()
categoryRouter.HandleFunc("", withContext(handler.updateMyCategory)).Methods(http.MethodPut)
categoryRouter.HandleFunc("", withContext(handler.deleteMyCategory)).Methods(http.MethodDelete)
categoryRouter.HandleFunc("/collapse", withContext(handler.collapseMyCategory)).Methods(http.MethodPut)
return handler
}
func (h *CategoryHandler) getMyCategories(c *Context, w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
teamID := params.Get("team_id")
userID := r.Header.Get("Mattermost-User-ID")
customCategories, err := h.categoryService.GetCategories(teamID, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
filteredCustomCategories := filterEmptyCategories(customCategories)
runsCategory, err := h.getRunsCategory(teamID, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
filteredRuns := filterDuplicatesFromCategory(runsCategory, filteredCustomCategories)
allCategories := append([]app.Category{}, customCategories...)
allCategories = append(allCategories, filteredRuns)
playbooksCategory, err := h.getPlaybooksCategory(teamID, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
filteredPlaybooks := filterDuplicatesFromCategory(playbooksCategory, filteredCustomCategories)
allCategories = append(allCategories, filteredPlaybooks)
ReturnJSON(w, allCategories, http.StatusOK)
}
func (h *CategoryHandler) createMyCategory(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
var category app.Category
if err := json.NewDecoder(r.Body).Decode(&category); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode category", err)
return
}
if category.ID != "" {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Category given already has ID", nil)
return
}
// user can only create category for themselves
if category.UserID != userID {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, fmt.Sprintf("userID %s and category userID %s mismatch", userID, category.UserID), nil)
return
}
createdCategory, err := h.categoryService.Create(category)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
ReturnJSON(w, createdCategory, http.StatusOK)
}
func (h *CategoryHandler) updateMyCategory(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
categoryID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
var category app.Category
if err := json.NewDecoder(r.Body).Decode(&category); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode category", err)
return
}
if categoryID != category.ID {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "categoryID mismatch in patch and body", nil)
return
}
// user can only update category for themselves
if category.UserID != userID {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "user ID mismatch in session and category", nil)
return
}
// verify if category belongs to the user
existingCategory, err := h.categoryService.Get(category.ID)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Can't get category", err)
return
}
if existingCategory.DeleteAt != 0 {
h.HandleErrorWithCode(w, c.logger, http.StatusNotFound, "Category deleted", nil)
return
}
if existingCategory.UserID != category.UserID {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "UserID mismatch", nil)
return
}
if err := h.categoryService.Update(category); err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *CategoryHandler) collapseMyCategory(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
categoryID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
var collapsed bool
if err := json.NewDecoder(r.Body).Decode(&collapsed); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode collapsed", err)
return
}
existingCategory, err := h.categoryService.Get(categoryID)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Can't get category", err)
return
}
if existingCategory.DeleteAt != 0 {
h.HandleErrorWithCode(w, c.logger, http.StatusNotFound, "Category deleted", nil)
return
}
// verify if category belongs to the user
if existingCategory.UserID != userID {
h.HandleErrorWithCode(w, c.logger, http.StatusForbidden, "UserID mismatch", nil)
return
}
if existingCategory.Collapsed == collapsed {
w.WriteHeader(http.StatusOK)
return
}
patchedCategory := existingCategory
patchedCategory.Collapsed = collapsed
patchedCategory.UpdateAt = model.GetMillis()
if err := h.categoryService.Update(patchedCategory); err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *CategoryHandler) deleteMyCategory(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
categoryID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
existingCategory, err := h.categoryService.Get(categoryID)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Can't get category", err)
return
}
// category is already deleted. This avoids
// overriding the original deleted at timestamp
if existingCategory.DeleteAt != 0 {
h.HandleErrorWithCode(w, c.logger, http.StatusNotFound, "Category deleted", nil)
return
}
// verify if category belongs to the user
if existingCategory.UserID != userID {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "UserID mismatch", nil)
return
}
if err := h.categoryService.Delete(categoryID); err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *CategoryHandler) isFavorite(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
params := r.URL.Query()
teamID := params.Get("team_id")
itemID := params.Get("item_id")
itemType := params.Get("type")
convertedItemType, err := app.StringToItemType(itemType)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
isFavorite, err := h.categoryService.IsItemFavorite(app.CategoryItem{ItemID: itemID, Type: convertedItemType}, teamID, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
ReturnJSON(w, isFavorite, http.StatusOK)
}
func (h *CategoryHandler) getRunsCategory(teamID, userID string) (app.Category, error) {
runs, err := h.playbookRunService.GetPlaybookRuns(
app.RequesterInfo{
UserID: userID,
TeamID: teamID,
},
app.PlaybookRunFilterOptions{
TeamID: teamID,
ParticipantOrFollowerID: userID,
Statuses: []string{app.StatusInProgress},
Types: []string{app.RunTypePlaybook}, // only playbook runs can be viewed in Playbook product
Page: 0,
PerPage: maxItemsInRunsAndPlaybooksCategory,
},
)
if err != nil {
return app.Category{}, errors.Wrapf(err, "can't get playbook runs")
}
runCategoryItems := []app.CategoryItem{}
for _, run := range runs.Items {
runCategoryItems = append(runCategoryItems, app.CategoryItem{
ItemID: run.ID,
Type: app.RunItemType,
Name: run.Name,
})
}
runCategory := app.Category{
ID: "runsCategory",
Name: "Runs",
TeamID: teamID,
UserID: userID,
Collapsed: false,
Items: runCategoryItems,
}
return runCategory, nil
}
func (h *CategoryHandler) getPlaybooksCategory(teamID, userID string) (app.Category, error) {
playbooks, err := h.playbookService.GetPlaybooksForTeam(
app.RequesterInfo{
TeamID: teamID,
UserID: userID,
},
teamID,
app.PlaybookFilterOptions{
Page: 0,
PerPage: maxItemsInRunsAndPlaybooksCategory,
WithMembershipOnly: true,
},
)
if err != nil {
return app.Category{}, errors.Wrap(err, "can't get playbooks for team")
}
playbookCategoryItems := []app.CategoryItem{}
for _, playbook := range playbooks.Items {
playbookCategoryItems = append(playbookCategoryItems, app.CategoryItem{
ItemID: playbook.ID,
Type: app.PlaybookItemType,
Name: playbook.Title,
Public: playbook.Public,
})
}
playbookCategory := app.Category{
ID: "playbooksCategory",
Name: "Playbooks",
TeamID: teamID,
UserID: userID,
Collapsed: false,
Items: playbookCategoryItems,
}
return playbookCategory, nil
}
func categoriesContainItem(categories []app.Category, item app.CategoryItem) bool {
for _, category := range categories {
if category.ContainsItem(item) {
return true
}
}
return false
}
func filterDuplicatesFromCategory(category app.Category, categories []app.Category) app.Category {
newItems := []app.CategoryItem{}
for _, item := range category.Items {
if !categoriesContainItem(categories, item) {
newItems = append(newItems, item)
}
}
category.Items = newItems
return category
}
func filterEmptyCategories(categories []app.Category) []app.Category {
newCategories := []app.Category{}
for _, category := range categories {
if len(category.Items) > 0 {
newCategories = append(newCategories, category)
}
}
return newCategories
}

View File

@ -1,41 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"net/http"
"github.com/sirupsen/logrus"
)
// requestIDContextKeyType ensures requestIDContextKey can never collide with another context key
// having the same value.
type requestIDContextKeyType string
// requestIDContextKey is the key for the incoming requestID.
var requestIDContextKey = requestIDContextKeyType("requestID")
// getLogger builds a logger with the requestID attached to the given request.
func getLogger(r *http.Request) logrus.FieldLogger {
var logger logrus.FieldLogger = logrus.StandardLogger()
requestID, ok := r.Context().Value(requestIDContextKey).(string)
if ok {
logger = logger.WithField("request_id", requestID)
}
return logger
}
type Context struct {
logger logrus.FieldLogger
}
// withContext passes a logger to http handler functions.
func withContext(handler func(c *Context, w http.ResponseWriter, r *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
logger := getLogger(r)
handler(&Context{logger}, w, r)
}
}

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/sirupsen/logrus"
)
type ErrorHandler struct {
}
// HandleError logs the internal error and sends a generic error as JSON in a 500 response.
func (h *ErrorHandler) HandleError(w http.ResponseWriter, logger logrus.FieldLogger, internalErr error) {
h.HandleErrorWithCode(w, logger, http.StatusInternalServerError, "An internal error has occurred. Check app server logs for details.", internalErr)
}
// HandleErrorWithCode logs the internal error and sends the public facing error
// message as JSON in a response with the provided code.
func (h *ErrorHandler) HandleErrorWithCode(w http.ResponseWriter, logger logrus.FieldLogger, code int, publicErrorMsg string, internalErr error) {
HandleErrorWithCode(logger, w, code, publicErrorMsg, internalErr)
}
// PermissionsCheck handles the output of a permission check
// Automatically does the proper error handling.
// Returns true if the check passed and false on failure. Correct use is: if !h.PermissionsCheck(w, check) { return }
func (h *ErrorHandler) PermissionsCheck(w http.ResponseWriter, logger logrus.FieldLogger, checkOutput error) bool {
if checkOutput != nil {
h.HandleErrorWithCode(w, logger, http.StatusForbidden, "Not authorized", checkOutput)
return false
}
return true
}

View File

@ -1,17 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"github.com/graph-gophers/dataloader/v7"
)
const loaderBatchCapacity = 200
func populateResultWithError[K any](err error, result []*dataloader.Result[K]) []*dataloader.Result[K] {
for i := range result {
result[i] = &dataloader.Result[K]{Error: err}
}
return result
}

View File

@ -1,191 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
_ "embed"
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/graph-gophers/dataloader/v7"
graphql "github.com/graph-gophers/graphql-go"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type GraphQLHandler struct {
*ErrorHandler
playbookService app.PlaybookService
playbookRunService app.PlaybookRunService
categoryService app.CategoryService
api playbooks.ServicesAPI
config config.Service
permissions *app.PermissionsService
playbookStore app.PlaybookStore
licenceChecker app.LicenseChecker
schema *graphql.Schema
}
//go:embed schema.graphqls
var SchemaFile string
func NewGraphQLHandler(
router *mux.Router,
playbookService app.PlaybookService,
playbookRunService app.PlaybookRunService,
categoryService app.CategoryService,
api playbooks.ServicesAPI,
configService config.Service,
permissions *app.PermissionsService,
playbookStore app.PlaybookStore,
licenceChecker app.LicenseChecker,
) *GraphQLHandler {
handler := &GraphQLHandler{
ErrorHandler: &ErrorHandler{},
playbookService: playbookService,
playbookRunService: playbookRunService,
categoryService: categoryService,
api: api,
config: configService,
permissions: permissions,
playbookStore: playbookStore,
licenceChecker: licenceChecker,
}
opts := []graphql.SchemaOpt{
graphql.UseFieldResolvers(),
graphql.MaxParallelism(5),
}
if !configService.IsConfiguredForDevelopmentAndTesting() {
opts = append(opts,
graphql.MaxDepth(8),
graphql.DisableIntrospection(),
)
}
root := &RootResolver{}
var err error
handler.schema, err = graphql.ParseSchema(SchemaFile, root, opts...)
if err != nil {
logrus.WithError(err).Error("unable to parse graphql schema")
return nil
}
router.HandleFunc("/query", withContext(graphiQL)).Methods("GET")
router.HandleFunc("/query", withContext(handler.graphQL)).Methods("POST")
return handler
}
type ctxKey struct{}
type GraphQLContext struct {
r *http.Request
playbookService app.PlaybookService
playbookRunService app.PlaybookRunService
playbookStore app.PlaybookStore
categoryService app.CategoryService
api playbooks.ServicesAPI
logger logrus.FieldLogger
config config.Service
permissions *app.PermissionsService
licenceChecker app.LicenseChecker
favoritesLoader *dataloader.Loader[favoriteInfo, bool]
playbooksLoader *dataloader.Loader[playbookInfo, *app.Playbook]
}
// When moving over to the multi-product architecture this should be handled by the server.
func (h *GraphQLHandler) graphQL(c *Context, w http.ResponseWriter, r *http.Request) {
// Limit bodies to 300KiB.
r.Body = http.MaxBytesReader(w, r.Body, 300*1024)
var params struct {
Query string `json:"query"`
OperationName string `json:"operationName"`
Variables map[string]interface{} `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
c.logger.WithError(err).Error("Unable to decode graphql query")
return
}
if !h.config.IsConfiguredForDevelopmentAndTesting() {
if params.OperationName == "" {
c.logger.Warn("Invalid blank operation name")
return
}
}
// dataloaders
favoritesLoader := dataloader.NewBatchedLoader(graphQLFavoritesLoader[bool], dataloader.WithBatchCapacity[favoriteInfo, bool](loaderBatchCapacity))
playbooksLoader := dataloader.NewBatchedLoader(graphQLPlaybooksLoader[*app.Playbook], dataloader.WithBatchCapacity[playbookInfo, *app.Playbook](loaderBatchCapacity))
graphQLContext := &GraphQLContext{
r: r,
playbookService: h.playbookService,
playbookRunService: h.playbookRunService,
categoryService: h.categoryService,
api: h.api,
logger: c.logger,
config: h.config,
permissions: h.permissions,
playbookStore: h.playbookStore,
licenceChecker: h.licenceChecker,
favoritesLoader: favoritesLoader,
playbooksLoader: playbooksLoader,
}
// Populate the context with required info.
reqCtx := r.Context()
reqCtx = context.WithValue(reqCtx, ctxKey{}, graphQLContext)
response := h.schema.Exec(reqCtx,
params.Query,
params.OperationName,
params.Variables,
)
r.Header.Set("X-GQL-Operation", params.OperationName)
for _, err := range response.Errors {
errLogger := c.logger.WithError(err).WithField("operation", params.OperationName)
if errors.Is(err, app.ErrNoPermissions) {
errLogger.Warn("Warning executing request")
} else if err.Rule == "FieldsOnCorrectType" {
errLogger.Warn("Query for non existent field")
} else {
errLogger.Error("Error executing request")
}
}
if err := json.NewEncoder(w).Encode(response); err != nil {
c.logger.WithError(err).Warn("Error while writing response")
}
}
func getContext(ctx context.Context) (*GraphQLContext, error) {
c, ok := ctx.Value(ctxKey{}).(*GraphQLContext)
if !ok {
return nil, errors.New("custom context not found in context")
}
return c, nil
}
// GraphiqlPage is the html base code for the graphiQL query runner
//
//go:embed graphqli.html
var GraphiqlPage []byte
func graphiQL(c *Context, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(GraphiqlPage)
}

View File

@ -1,56 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
"github.com/graph-gophers/dataloader/v7"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
)
type favoriteInfo struct {
TeamID string
UserID string
ID string
Type app.CategoryItemType
}
func graphQLFavoritesLoader[V bool](ctx context.Context, keys []favoriteInfo) []*dataloader.Result[V] {
result := make([]*dataloader.Result[V], len(keys))
if len(keys) == 0 {
return result
}
c, err := getContext(ctx)
if err != nil {
for i := range keys {
result[i] = &dataloader.Result[V]{Error: err}
}
return result
}
// assume all keys are for the same team and user
teamID := keys[0].TeamID
userID := keys[0].UserID
categoryItems := make([]app.CategoryItem, len(keys))
for i, favorite := range keys {
categoryItems[i] = app.CategoryItem{
ItemID: favorite.ID,
Type: favorite.Type,
}
}
favorites, err := c.categoryService.AreItemsFavorites(categoryItems, teamID, userID)
if err != nil {
populateResultWithError(err, result)
}
for i, fav := range favorites {
result[i] = &dataloader.Result[V]{Data: V(fav)}
}
return result
}

View File

@ -1,79 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
"github.com/graph-gophers/dataloader/v7"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
)
type playbookInfo struct {
UserID string
TeamID string
ID string
}
func graphQLPlaybooksLoader[V *app.Playbook](ctx context.Context, keys []playbookInfo) []*dataloader.Result[V] {
result := make([]*dataloader.Result[V], len(keys))
if len(keys) == 0 {
return result
}
uniquePlaybookIDs := getUniquePlaybookIDs(keys)
var teamID, userID string = keys[0].TeamID, keys[0].UserID
c, err := getContext(ctx)
if err != nil {
return populateResultWithError(err, result)
}
playbookResult, err := c.playbookService.GetPlaybooksForTeam(
app.RequesterInfo{
UserID: userID,
TeamID: teamID,
},
teamID,
app.PlaybookFilterOptions{
PlaybookIDs: uniquePlaybookIDs,
PerPage: loaderBatchCapacity,
},
)
if err != nil {
return populateResultWithError(err, result)
}
playbooksByID := make(map[string]*app.Playbook)
for i := range playbookResult.Items {
playbooksByID[playbookResult.Items[i].ID] = &playbookResult.Items[i]
}
for i, playbookInfo := range keys {
playbook, ok := playbooksByID[playbookInfo.ID]
if !ok {
result[i] = &dataloader.Result[V]{Data: nil}
continue
}
result[i] = &dataloader.Result[V]{
Data: V(playbook),
}
}
return result
}
func getUniquePlaybookIDs(playbooks []playbookInfo) []string {
playbookByID := make(map[string]bool)
for _, playbook := range playbooks {
playbookByID[playbook.ID] = true
}
result := make([]string, 0, len(playbookByID))
for playbookID := range playbookByID {
result = append(result, playbookID)
}
return result
}

View File

@ -1,239 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
"fmt"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/sirupsen/logrus"
)
type PlaybookResolver struct {
app.Playbook
}
func (r *PlaybookResolver) ChannelMode(ctx context.Context) string {
return fmt.Sprint(r.Playbook.ChannelMode)
}
func (r *PlaybookResolver) IsFavorite(ctx context.Context) (bool, error) {
c, err := getContext(ctx)
if err != nil {
return false, err
}
userID := c.r.Header.Get("Mattermost-User-ID")
thunk := c.favoritesLoader.Load(ctx, favoriteInfo{
TeamID: r.TeamID,
UserID: userID,
Type: app.PlaybookItemType,
ID: r.ID,
})
result, err := thunk()
if err != nil {
return false, err
}
return result, nil
}
func (r *PlaybookResolver) DeleteAt() float64 {
return float64(r.Playbook.DeleteAt)
}
func (r *PlaybookResolver) LastRunAt() float64 {
return float64(r.Playbook.LastRunAt)
}
func (r *PlaybookResolver) NumRuns() int32 {
return int32(r.Playbook.NumRuns)
}
func (r *PlaybookResolver) ActiveRuns() int32 {
return int32(r.Playbook.ActiveRuns)
}
func (r *PlaybookResolver) RetrospectiveReminderIntervalSeconds() float64 {
return float64(r.Playbook.RetrospectiveReminderIntervalSeconds)
}
func (r *PlaybookResolver) ReminderTimerDefaultSeconds() float64 {
return float64(r.Playbook.ReminderTimerDefaultSeconds)
}
func (r *PlaybookResolver) Metrics() []*MetricConfigResolver {
metricConfigResolvers := make([]*MetricConfigResolver, 0, len(r.Playbook.Metrics))
for _, metricConfig := range r.Playbook.Metrics {
metricConfigResolvers = append(metricConfigResolvers, &MetricConfigResolver{metricConfig})
}
return metricConfigResolvers
}
type MetricConfigResolver struct {
app.PlaybookMetricConfig
}
func (r *MetricConfigResolver) Target() *int32 {
if r.PlaybookMetricConfig.Target.Valid {
intvalue := int32(r.PlaybookMetricConfig.Target.ValueOrZero())
return &intvalue
}
return nil
}
func (r *PlaybookResolver) Checklists() []*ChecklistResolver {
checklistResolvers := make([]*ChecklistResolver, 0, len(r.Playbook.Checklists))
for _, checklist := range r.Playbook.Checklists {
checklistResolvers = append(checklistResolvers, &ChecklistResolver{checklist})
}
return checklistResolvers
}
type ChecklistResolver struct {
app.Checklist
}
func (r *ChecklistResolver) Items() []*ChecklistItemResolver {
checklistItemResolvers := make([]*ChecklistItemResolver, 0, len(r.Checklist.Items))
for _, items := range r.Checklist.Items {
checklistItemResolvers = append(checklistItemResolvers, &ChecklistItemResolver{items})
}
return checklistItemResolvers
}
type ChecklistItemResolver struct {
app.ChecklistItem
}
func (r *ChecklistItemResolver) StateModified() float64 {
return float64(r.ChecklistItem.StateModified)
}
func (r *ChecklistItemResolver) AssigneeModified() float64 {
return float64(r.ChecklistItem.AssigneeModified)
}
func (r *ChecklistItemResolver) CommandLastRun() float64 {
return float64(r.ChecklistItem.CommandLastRun)
}
func (r *ChecklistItemResolver) DueDate() float64 {
return float64(r.ChecklistItem.DueDate)
}
func (r *ChecklistItemResolver) TaskActions() []*TaskActionResolver {
taskActionsResolvers := make([]*TaskActionResolver, 0, len(r.ChecklistItem.TaskActions))
for _, taskAction := range r.ChecklistItem.TaskActions {
taskActionsResolvers = append(taskActionsResolvers, &TaskActionResolver{taskAction})
}
return taskActionsResolvers
}
type TaskActionResolver struct {
app.TaskAction
}
func (r *TaskActionResolver) Trigger() *TriggerResolver {
return &TriggerResolver{r.TaskAction.Trigger}
}
func (r *TaskActionResolver) Actions() []*ActionResolver {
actionsResolvers := make([]*ActionResolver, 0, len(r.TaskAction.Actions))
for _, action := range r.TaskAction.Actions {
actionsResolvers = append(actionsResolvers, &ActionResolver{action})
}
return actionsResolvers
}
type ActionResolver struct {
app.Action
}
func (r *ActionResolver) Type() string {
return string(r.Action.Type)
}
func (r *ActionResolver) Payload() string {
var payload string
switch r.Action.Type {
case app.MarkItemAsDoneActionType:
payload = r.Action.Payload
default:
logrus.WithField("task_action_type", r.Action.Type).Error("Unknown trigger type")
payload = ""
}
return payload
}
type TriggerResolver struct {
app.Trigger
}
func (r *TriggerResolver) Type() string {
return string(r.Trigger.Type)
}
func (r *TriggerResolver) Payload() string {
var payload string
switch r.Trigger.Type {
case app.KeywordsByUsersTriggerType:
payload = r.Trigger.Payload
default:
logrus.WithField("task_trigger_type", r.Trigger.Type).Error("Unknown trigger type")
payload = ""
}
return payload
}
type UpdateChecklist struct {
Title string `json:"title"`
Items []UpdateChecklistItem `json:"items"`
}
func (c UpdateChecklist) GetItems() []app.ChecklistItemCommon {
items := make([]app.ChecklistItemCommon, len(c.Items))
for i := range c.Items {
items[i] = &c.Items[i]
}
return items
}
type UpdateChecklistItem struct {
Title string `json:"title"`
State string `json:"state"`
StateModified float64 `json:"state_modified"`
AssigneeID string `json:"assignee_id"`
AssigneeModified float64 `json:"assignee_modified"`
Command string `json:"command"`
CommandLastRun float64 `json:"command_last_run"`
Description string `json:"description"`
LastSkipped float64 `json:"delete_at"`
DueDate float64 `json:"due_date"`
TaskActions *[]app.TaskAction `json:"task_actions"`
}
func (ci *UpdateChecklistItem) GetAssigneeID() string {
return ci.AssigneeID
}
func (ci *UpdateChecklistItem) SetAssigneeModified(modified int64) {
ci.AssigneeModified = float64(modified)
}
func (ci *UpdateChecklistItem) SetState(state string) {
ci.State = state
}
func (ci *UpdateChecklistItem) SetStateModified(modified int64) {
ci.StateModified = float64(modified)
}
func (ci *UpdateChecklistItem) SetCommandLastRun(lastRun int64) {
ci.CommandLastRun = float64(lastRun)
}

View File

@ -1,25 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"strings"
)
type RootResolver struct {
RunRootResolver
PlaybookRootResolver
}
func addToSetmap[T any](setmap map[string]interface{}, name string, value *T) {
if value != nil {
setmap[name] = *value
}
}
func addConcatToSetmap(setmap map[string]interface{}, name string, value *[]string) {
if value != nil {
setmap[name] = strings.Join(*value, ",")
}
}

View File

@ -1,549 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
"encoding/json"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/pkg/errors"
"gopkg.in/guregu/null.v4"
)
// RunMutationCollection hold all mutation functions for a playbookRun
type PlaybookRootResolver struct {
}
func getGraphqlPlaybook(ctx context.Context, playbookID string) (*PlaybookResolver, error) {
c, err := getContext(ctx)
if err != nil {
return nil, err
}
userID := c.r.Header.Get("Mattermost-User-ID")
if err = c.permissions.PlaybookView(userID, playbookID); err != nil {
return nil, err
}
playbook, err := c.playbookService.Get(playbookID)
if err != nil {
return nil, err
}
return &PlaybookResolver{playbook}, nil
}
func (r *PlaybookRootResolver) Playbook(ctx context.Context, args struct {
ID string
}) (*PlaybookResolver, error) {
playbookID := args.ID
return getGraphqlPlaybook(ctx, playbookID)
}
func (r *PlaybookRootResolver) Playbooks(ctx context.Context, args struct {
TeamID string
Sort string
Direction string
SearchTerm string
WithMembershipOnly bool
WithArchived bool
}) ([]*PlaybookResolver, error) {
c, err := getContext(ctx)
if err != nil {
return nil, err
}
userID := c.r.Header.Get("Mattermost-User-ID")
if args.TeamID != "" {
if err = c.permissions.PlaybookList(userID, args.TeamID); err != nil {
return nil, err
}
}
isGuest, err := app.IsGuest(userID, c.api)
if err != nil {
return nil, err
}
requesterInfo := app.RequesterInfo{
UserID: userID,
TeamID: args.TeamID,
IsAdmin: app.IsSystemAdmin(userID, c.api),
}
opts := app.PlaybookFilterOptions{
Sort: app.SortField(args.Sort),
Direction: app.SortDirection(args.Direction),
SearchTerm: args.SearchTerm,
WithArchived: args.WithArchived,
WithMembershipOnly: isGuest || args.WithMembershipOnly, // Guests can only see playbooks if they are invited to them
Page: 0,
PerPage: 10000,
}
playbookResults, err := c.playbookService.GetPlaybooksForTeam(requesterInfo, args.TeamID, opts)
if err != nil {
return nil, err
}
ret := make([]*PlaybookResolver, 0, len(playbookResults.Items))
for _, pb := range playbookResults.Items {
ret = append(ret, &PlaybookResolver{pb})
}
return ret, nil
}
func (r *RunRootResolver) UpdatePlaybookFavorite(ctx context.Context, args struct {
ID string
Favorite bool
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
if err = c.permissions.PlaybookView(userID, args.ID); err != nil {
return "", err
}
currentPlaybook, err := c.playbookService.Get(args.ID)
if err != nil {
return "", err
}
if currentPlaybook.DeleteAt != 0 {
return "", errors.New("archived playbooks can not be modified")
}
if args.Favorite {
if err := c.categoryService.AddFavorite(
app.CategoryItem{
ItemID: currentPlaybook.ID,
Type: app.PlaybookItemType,
},
currentPlaybook.TeamID,
userID,
); err != nil {
return "", err
}
} else {
if err := c.categoryService.DeleteFavorite(
app.CategoryItem{
ItemID: currentPlaybook.ID,
Type: app.PlaybookItemType,
},
currentPlaybook.TeamID,
userID,
); err != nil {
return "", err
}
}
return currentPlaybook.ID, nil
}
func (r *PlaybookRootResolver) UpdatePlaybook(ctx context.Context, args struct {
ID string
Updates struct {
Title *string
Description *string
Public *bool
CreatePublicPlaybookRun *bool
ReminderMessageTemplate *string
ReminderTimerDefaultSeconds *float64
StatusUpdateEnabled *bool
InvitedUserIDs *[]string
InvitedGroupIDs *[]string
InviteUsersEnabled *bool
DefaultOwnerID *string
DefaultOwnerEnabled *bool
BroadcastChannelIDs *[]string
BroadcastEnabled *bool
WebhookOnCreationURLs *[]string
WebhookOnCreationEnabled *bool
MessageOnJoin *string
MessageOnJoinEnabled *bool
RetrospectiveReminderIntervalSeconds *float64
RetrospectiveTemplate *string
RetrospectiveEnabled *bool
WebhookOnStatusUpdateURLs *[]string
WebhookOnStatusUpdateEnabled *bool
SignalAnyKeywords *[]string
SignalAnyKeywordsEnabled *bool
CategorizeChannelEnabled *bool
CategoryName *string
RunSummaryTemplateEnabled *bool
RunSummaryTemplate *string
ChannelNameTemplate *string
Checklists *[]UpdateChecklist
CreateChannelMemberOnNewParticipant *bool
RemoveChannelMemberOnRemovedParticipant *bool
ChannelID *string
ChannelMode *string
}
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
currentPlaybook, err := c.playbookService.Get(args.ID)
if err != nil {
return "", err
}
if err := c.permissions.PlaybookManageProperties(userID, currentPlaybook); err != nil {
return "", err
}
if currentPlaybook.DeleteAt != 0 {
return "", errors.New("archived playbooks can not be modified")
}
setmap := map[string]interface{}{}
addToSetmap(setmap, "Title", args.Updates.Title)
addToSetmap(setmap, "Description", args.Updates.Description)
if args.Updates.Public != nil {
if *args.Updates.Public {
if err := c.permissions.PlaybookMakePublic(userID, currentPlaybook); err != nil {
return "", err
}
} else {
if err := c.permissions.PlaybookMakePrivate(userID, currentPlaybook); err != nil {
return "", err
}
}
if !c.licenceChecker.PlaybookAllowed(*args.Updates.Public) {
return "", errors.Wrapf(app.ErrLicensedFeature, "the playbook is not valid with the current license")
}
addToSetmap(setmap, "Public", args.Updates.Public)
}
addToSetmap(setmap, "CreatePublicIncident", args.Updates.CreatePublicPlaybookRun)
addToSetmap(setmap, "ReminderMessageTemplate", args.Updates.ReminderMessageTemplate)
addToSetmap(setmap, "ReminderTimerDefaultSeconds", args.Updates.ReminderTimerDefaultSeconds)
addToSetmap(setmap, "StatusUpdateEnabled", args.Updates.StatusUpdateEnabled)
addToSetmap(setmap, "CreateChannelMemberOnNewParticipant", args.Updates.CreateChannelMemberOnNewParticipant)
addToSetmap(setmap, "RemoveChannelMemberOnRemovedParticipant", args.Updates.RemoveChannelMemberOnRemovedParticipant)
if args.Updates.InvitedUserIDs != nil {
filteredInvitedUserIDs := c.permissions.FilterInvitedUserIDs(*args.Updates.InvitedUserIDs, currentPlaybook.TeamID)
addConcatToSetmap(setmap, "ConcatenatedInvitedUserIDs", &filteredInvitedUserIDs)
}
if args.Updates.InvitedGroupIDs != nil {
filteredInvitedGroupIDs := c.permissions.FilterInvitedGroupIDs(*args.Updates.InvitedGroupIDs)
addConcatToSetmap(setmap, "ConcatenatedInvitedGroupIDs", &filteredInvitedGroupIDs)
}
addToSetmap(setmap, "InviteUsersEnabled", args.Updates.InviteUsersEnabled)
if args.Updates.DefaultOwnerID != nil {
if !c.api.HasPermissionToTeam(*args.Updates.DefaultOwnerID, currentPlaybook.TeamID, model.PermissionViewTeam) {
return "", errors.Wrap(app.ErrNoPermissions, "default owner can't view team")
}
addToSetmap(setmap, "DefaultCommanderID", args.Updates.DefaultOwnerID)
}
addToSetmap(setmap, "DefaultCommanderEnabled", args.Updates.DefaultOwnerEnabled)
if args.Updates.BroadcastChannelIDs != nil {
if err := c.permissions.NoAddedBroadcastChannelsWithoutPermission(userID, *args.Updates.BroadcastChannelIDs, currentPlaybook.BroadcastChannelIDs); err != nil {
return "", err
}
addConcatToSetmap(setmap, "ConcatenatedBroadcastChannelIDs", args.Updates.BroadcastChannelIDs)
}
addToSetmap(setmap, "BroadcastEnabled", args.Updates.BroadcastEnabled)
if args.Updates.WebhookOnCreationURLs != nil {
if err := app.ValidateWebhookURLs(*args.Updates.WebhookOnCreationURLs); err != nil {
return "", err
}
addConcatToSetmap(setmap, "ConcatenatedWebhookOnCreationURLs", args.Updates.WebhookOnCreationURLs)
}
addToSetmap(setmap, "WebhookOnCreationEnabled", args.Updates.WebhookOnCreationEnabled)
addToSetmap(setmap, "MessageOnJoin", args.Updates.MessageOnJoin)
addToSetmap(setmap, "MessageOnJoinEnabled", args.Updates.MessageOnJoinEnabled)
addToSetmap(setmap, "RetrospectiveReminderIntervalSeconds", args.Updates.RetrospectiveReminderIntervalSeconds)
addToSetmap(setmap, "RetrospectiveTemplate", args.Updates.RetrospectiveTemplate)
addToSetmap(setmap, "RetrospectiveEnabled", args.Updates.RetrospectiveEnabled)
if args.Updates.WebhookOnStatusUpdateURLs != nil {
if err := app.ValidateWebhookURLs(*args.Updates.WebhookOnStatusUpdateURLs); err != nil {
return "", err
}
addConcatToSetmap(setmap, "ConcatenatedWebhookOnStatusUpdateURLs", args.Updates.WebhookOnStatusUpdateURLs)
}
addToSetmap(setmap, "WebhookOnStatusUpdateEnabled", args.Updates.WebhookOnStatusUpdateEnabled)
if args.Updates.SignalAnyKeywords != nil {
validSignalAnyKeywords := app.ProcessSignalAnyKeywords(*args.Updates.SignalAnyKeywords)
addConcatToSetmap(setmap, "ConcatenatedSignalAnyKeywords", &validSignalAnyKeywords)
}
addToSetmap(setmap, "SignalAnyKeywordsEnabled", args.Updates.SignalAnyKeywordsEnabled)
addToSetmap(setmap, "CategorizeChannelEnabled", args.Updates.CategorizeChannelEnabled)
if args.Updates.CategoryName != nil {
if err := app.ValidateCategoryName(*args.Updates.CategoryName); err != nil {
return "", err
}
addToSetmap(setmap, "CategoryName", args.Updates.CategoryName)
}
addToSetmap(setmap, "RunSummaryTemplateEnabled", args.Updates.RunSummaryTemplateEnabled)
addToSetmap(setmap, "RunSummaryTemplate", args.Updates.RunSummaryTemplate)
addToSetmap(setmap, "ChannelNameTemplate", args.Updates.ChannelNameTemplate)
addToSetmap(setmap, "ChannelID", args.Updates.ChannelID)
addToSetmap(setmap, "ChannelMode", args.Updates.ChannelMode)
// Not optimal graphql. Stopgap measure. Should be updated separately.
if args.Updates.Checklists != nil {
app.CleanUpChecklists(*args.Updates.Checklists)
if err := validateUpdateTaskActions(*args.Updates.Checklists); err != nil {
return "", errors.Wrapf(err, "failed to validate task actions in graphql json for playbook id: '%s'", args.ID)
}
checklistsJSON, err := json.Marshal(args.Updates.Checklists)
if err != nil {
return "", errors.Wrapf(err, "failed to marshal checklist in graphql json for playbook id: '%s'", args.ID)
}
setmap["ChecklistsJSON"] = checklistsJSON
}
if args.Updates.Checklists != nil || args.Updates.InvitedUserIDs != nil || args.Updates.InviteUsersEnabled != nil {
if err := validatePreAssignmentUpdate(currentPlaybook, args.Updates.Checklists, args.Updates.InvitedUserIDs, args.Updates.InviteUsersEnabled); err != nil {
return "", errors.Wrapf(err, "invalid user pre-assignment for playbook id: '%s'", args.ID)
}
}
if len(setmap) > 0 {
if err := c.playbookStore.GraphqlUpdate(args.ID, setmap); err != nil {
return "", err
}
}
return args.ID, nil
}
func (r *PlaybookRootResolver) AddPlaybookMember(ctx context.Context, args struct {
PlaybookID string
UserID string
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
currentPlaybook, err := c.playbookService.Get(args.PlaybookID)
if err != nil {
return "", err
}
if err := c.permissions.PlaybookManageMembers(userID, currentPlaybook); err != nil {
return "", err
}
if currentPlaybook.DeleteAt != 0 {
return "", errors.New("archived playbooks can not be modified")
}
if err := c.playbookStore.AddPlaybookMember(args.PlaybookID, args.UserID); err != nil {
return "", errors.Wrap(err, "unable to add playbook member")
}
return "", nil
}
func (r *PlaybookRootResolver) RemovePlaybookMember(ctx context.Context, args struct {
PlaybookID string
UserID string
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
currentPlaybook, err := c.playbookService.Get(args.PlaybookID)
if err != nil {
return "", err
}
if currentPlaybook.DeleteAt != 0 {
return "", errors.New("archived playbooks can not be modified")
}
// do not require manageMembers permission if the user want to leave playbook
if userID != args.UserID {
if err := c.permissions.PlaybookManageMembers(userID, currentPlaybook); err != nil {
return "", err
}
}
if err := c.playbookStore.RemovePlaybookMember(args.PlaybookID, args.UserID); err != nil {
return "", errors.Wrap(err, "unable to remove playbook member")
}
return "", nil
}
func (r *PlaybookRootResolver) AddMetric(ctx context.Context, args struct {
PlaybookID string
Title string
Description string
Type string
Target *float64
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
currentPlaybook, err := c.playbookService.Get(args.PlaybookID)
if err != nil {
return "", err
}
if currentPlaybook.DeleteAt != 0 {
return "", errors.New("archived playbooks can not be modified")
}
if err := c.permissions.PlaybookManageProperties(userID, currentPlaybook); err != nil {
return "", err
}
var target null.Int
if args.Target == nil {
target = null.NewInt(0, false)
} else {
target = null.IntFrom(int64(*args.Target))
}
if err := c.playbookStore.AddMetric(args.PlaybookID, app.PlaybookMetricConfig{
Title: args.Title,
Description: args.Description,
Type: args.Type,
Target: target,
}); err != nil {
return "", err
}
return args.PlaybookID, nil
}
func (r *PlaybookRootResolver) UpdateMetric(ctx context.Context, args struct {
ID string
Title *string
Description *string
Target *float64
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
currentMetric, err := c.playbookStore.GetMetric(args.ID)
if err != nil {
return "", err
}
currentPlaybook, err := c.playbookService.Get(currentMetric.PlaybookID)
if err != nil {
return "", err
}
if currentPlaybook.DeleteAt != 0 {
return "", errors.New("archived playbooks can not be modified")
}
if err := c.permissions.PlaybookManageProperties(userID, currentPlaybook); err != nil {
return "", err
}
setmap := map[string]interface{}{}
addToSetmap(setmap, "Title", args.Title)
addToSetmap(setmap, "Description", args.Description)
if args.Target != nil {
setmap["Target"] = null.IntFrom(int64(*args.Target))
}
if len(setmap) > 0 {
if err := c.playbookStore.UpdateMetric(args.ID, setmap); err != nil {
return "", err
}
}
return args.ID, nil
}
func (r *PlaybookRootResolver) DeleteMetric(ctx context.Context, args struct {
ID string
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
currentMetric, err := c.playbookStore.GetMetric(args.ID)
if err != nil {
return "", err
}
currentPlaybook, err := c.playbookService.Get(currentMetric.PlaybookID)
if err != nil {
return "", err
}
if err := c.permissions.PlaybookManageProperties(userID, currentPlaybook); err != nil {
return "", err
}
if err := c.playbookStore.DeleteMetric(args.ID); err != nil {
return "", err
}
return args.ID, nil
}
func validatePreAssignmentUpdate[T app.ChecklistCommon](pb app.Playbook, newChecklists *[]T, newInvitedUsers *[]string, newInviteUsersEnabled *bool) error {
assignees := app.GetDistinctAssignees(pb.Checklists)
if newChecklists != nil {
assignees = app.GetDistinctAssignees(*newChecklists)
}
invitedUsers := pb.InvitedUserIDs
if newInvitedUsers != nil {
invitedUsers = *newInvitedUsers
}
inviteUsersEnabled := pb.InviteUsersEnabled
if newInviteUsersEnabled != nil {
inviteUsersEnabled = *newInviteUsersEnabled
}
return app.ValidatePreAssignment(assignees, invitedUsers, inviteUsersEnabled)
}
// validateUpdateTaskActions validates the taskactions in the given checklist
// NOTE: Any changes to this function must be made to function 'validateTaskActions' for the REST endpoint.
func validateUpdateTaskActions(checklists []UpdateChecklist) error {
for _, checklist := range checklists {
for _, item := range checklist.Items {
if taskActions := item.TaskActions; taskActions != nil {
for _, ta := range *taskActions {
if err := app.ValidateTrigger(ta.Trigger); err != nil {
return err
}
for _, a := range ta.Actions {
if err := app.ValidateAction(a); err != nil {
return err
}
}
}
}
}
}
return nil
}

View File

@ -1,327 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/pkg/errors"
)
// RunRootResolver hold all queries and mutations for a playbookRun
type RunRootResolver struct {
}
func (r *RunRootResolver) Run(ctx context.Context, args struct {
ID string `url:"id,omitempty"`
}) (*RunResolver, error) {
c, err := getContext(ctx)
if err != nil {
return nil, err
}
userID := c.r.Header.Get("Mattermost-User-ID")
if err = c.permissions.RunView(userID, args.ID); err != nil {
return nil, err
}
run, err := c.playbookRunService.GetPlaybookRun(args.ID)
if err != nil {
return nil, err
}
return &RunResolver{*run}, nil
}
func (r *RunRootResolver) Runs(ctx context.Context, args struct {
TeamID string
Sort string
Direction string
Statuses []string
ParticipantOrFollowerID string
ChannelID string
First *int32
After *string
Types []string
}) (*RunConnectionResolver, error) {
c, err := getContext(ctx)
if err != nil {
return nil, err
}
userID := c.r.Header.Get("Mattermost-User-ID")
requesterInfo := app.RequesterInfo{
UserID: userID,
TeamID: args.TeamID,
IsAdmin: app.IsSystemAdmin(userID, c.api),
}
if args.ParticipantOrFollowerID == client.Me {
args.ParticipantOrFollowerID = userID
}
perPage := 10000 // If paging not specified, get "everything"
if args.First != nil {
perPage = int(*args.First)
}
page := 0
if args.After != nil {
page, err = decodeRunConnectionCursor(*args.After)
if err != nil {
return nil, err
}
}
filterOptions := app.PlaybookRunFilterOptions{
Sort: app.SortField(args.Sort),
Direction: app.SortDirection(args.Direction),
TeamID: args.TeamID,
Statuses: args.Statuses,
ParticipantOrFollowerID: args.ParticipantOrFollowerID,
ChannelID: args.ChannelID,
IncludeFavorites: true,
Types: args.Types,
Page: page,
PerPage: perPage,
}
runResults, err := c.playbookRunService.GetPlaybookRuns(requesterInfo, filterOptions)
if err != nil {
return nil, err
}
return &RunConnectionResolver{results: *runResults, page: page}, nil
}
func (r *RunRootResolver) SetRunFavorite(ctx context.Context, args struct {
ID string
Fav bool
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
if err = c.permissions.RunView(userID, args.ID); err != nil {
return "", err
}
playbookRun, err := c.playbookRunService.GetPlaybookRun(args.ID)
if err != nil {
return "", err
}
if args.Fav {
if err := c.categoryService.AddFavorite(
app.CategoryItem{
ItemID: playbookRun.ID,
Type: app.RunItemType,
},
playbookRun.TeamID,
userID,
); err != nil {
return "", err
}
} else {
if err := c.categoryService.DeleteFavorite(
app.CategoryItem{
ItemID: playbookRun.ID,
Type: app.RunItemType,
},
playbookRun.TeamID,
userID,
); err != nil {
return "", err
}
}
return playbookRun.ID, nil
}
type RunUpdates struct {
Name *string
Summary *string
ChannelID *string
CreateChannelMemberOnNewParticipant *bool
RemoveChannelMemberOnRemovedParticipant *bool
StatusUpdateBroadcastChannelsEnabled *bool
StatusUpdateBroadcastWebhooksEnabled *bool
BroadcastChannelIDs *[]string
WebhookOnStatusUpdateURLs *[]string
}
func (r *RunRootResolver) UpdateRun(ctx context.Context, args struct {
ID string
Updates RunUpdates
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
if err = c.permissions.RunManageProperties(userID, args.ID); err != nil {
return "", err
}
playbookRun, err := c.playbookRunService.GetPlaybookRun(args.ID)
if err != nil {
return "", err
}
now := model.GetMillis()
// scalar updates
setmap := map[string]interface{}{}
addToSetmap(setmap, "Name", args.Updates.Name)
addToSetmap(setmap, "Description", args.Updates.Summary)
addToSetmap(setmap, "ChannelID", args.Updates.ChannelID)
addToSetmap(setmap, "CreateChannelMemberOnNewParticipant", args.Updates.CreateChannelMemberOnNewParticipant)
addToSetmap(setmap, "RemoveChannelMemberOnRemovedParticipant", args.Updates.RemoveChannelMemberOnRemovedParticipant)
addToSetmap(setmap, "StatusUpdateBroadcastChannelsEnabled", args.Updates.StatusUpdateBroadcastChannelsEnabled)
addToSetmap(setmap, "StatusUpdateBroadcastWebhooksEnabled", args.Updates.StatusUpdateBroadcastWebhooksEnabled)
if args.Updates.Summary != nil {
addToSetmap(setmap, "SummaryModifiedAt", &now)
}
if args.Updates.BroadcastChannelIDs != nil {
if err := c.permissions.NoAddedBroadcastChannelsWithoutPermission(userID, *args.Updates.BroadcastChannelIDs, playbookRun.BroadcastChannelIDs); err != nil {
return "", err
}
addConcatToSetmap(setmap, "ConcatenatedBroadcastChannelIDs", args.Updates.BroadcastChannelIDs)
}
if args.Updates.WebhookOnStatusUpdateURLs != nil {
if err := app.ValidateWebhookURLs(*args.Updates.WebhookOnStatusUpdateURLs); err != nil {
return "", err
}
addConcatToSetmap(setmap, "ConcatenatedWebhookOnStatusUpdateURLs", args.Updates.WebhookOnStatusUpdateURLs)
}
if err := c.playbookRunService.GraphqlUpdate(args.ID, setmap); err != nil {
return "", err
}
return playbookRun.ID, nil
}
func (r *RunRootResolver) AddRunParticipants(ctx context.Context, args struct {
RunID string
UserIDs []string
ForceAddToChannel bool
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
// When user is joining run RunView permission is enough, otherwise user need manage permissions
if updatesOnlyRequesterMembership(userID, args.UserIDs) {
if err := c.permissions.RunView(userID, args.RunID); err != nil {
return "", errors.Wrap(err, "attempted to join run without permissions")
}
} else {
if err := c.permissions.RunManageProperties(userID, args.RunID); err != nil {
return "", errors.Wrap(err, "attempted to modify participants without permissions")
}
}
if err := c.playbookRunService.AddParticipants(args.RunID, args.UserIDs, userID, args.ForceAddToChannel); err != nil {
return "", errors.Wrap(err, "failed to add participant from run")
}
return "", nil
}
func (r *RunRootResolver) RemoveRunParticipants(ctx context.Context, args struct {
RunID string
UserIDs []string
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
// When user is leaving run RunView permission is enough, otherwise user need manage permissions
if updatesOnlyRequesterMembership(userID, args.UserIDs) {
if err := c.permissions.RunView(userID, args.RunID); err != nil {
return "", errors.Wrap(err, "attempted to modify participants without permissions")
}
} else {
if err := c.permissions.RunManageProperties(userID, args.RunID); err != nil {
return "", errors.Wrap(err, "attempted to modify participants without permissions")
}
}
if err := c.playbookRunService.RemoveParticipants(args.RunID, args.UserIDs, userID); err != nil {
return "", errors.Wrap(err, "failed to remove participant from run")
}
for _, userID := range args.UserIDs {
if err := c.playbookRunService.Unfollow(args.RunID, userID); err != nil {
return "", errors.Wrap(err, "failed to make participant to unfollow run")
}
}
return "", nil
}
func updatesOnlyRequesterMembership(requesterUserID string, userIDs []string) bool {
return len(userIDs) == 1 && userIDs[0] == requesterUserID
}
func (r *RunRootResolver) ChangeRunOwner(ctx context.Context, args struct {
RunID string
OwnerID string
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
requesterID := c.r.Header.Get("Mattermost-User-ID")
if err := c.permissions.RunManageProperties(requesterID, args.RunID); err != nil {
return "", errors.Wrap(err, "attempted to modify the run owner without permissions")
}
if err := c.playbookRunService.ChangeOwner(args.RunID, requesterID, args.OwnerID); err != nil {
return "", errors.Wrap(err, "failed to change the run owner")
}
return "", nil
}
func (r *RunRootResolver) UpdateRunTaskActions(ctx context.Context, args struct {
RunID string
ChecklistNum float64
ItemNum float64
TaskActions *[]app.TaskAction
}) (string, error) {
c, err := getContext(ctx)
if err != nil {
return "", err
}
if args.TaskActions == nil {
return "", err
}
userID := c.r.Header.Get("Mattermost-User-ID")
if err := validateTaskActions(*args.TaskActions); err != nil {
return "", err
}
if err := c.playbookRunService.SetTaskActionsToChecklistItem(args.RunID, userID, int(args.ChecklistNum), int(args.ItemNum), *args.TaskActions); err != nil {
return "", err
}
return "", nil
}

View File

@ -1,266 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
"strconv"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/pkg/errors"
)
type RunResolver struct {
app.PlaybookRun
}
// NumTasks is a computed attribute (not stored in database) which
// returns the number of total tasks in a playbook run:
func (r *RunResolver) NumTasks() int32 {
total := 0
for _, checklist := range r.PlaybookRun.Checklists {
total += len(checklist.Items)
}
return int32(total)
}
// NumTasksClosed is a computed attribute (not stored in database) which
// returns the number of tasks closed in a playbook run:
func (r *RunResolver) NumTasksClosed() int32 {
closed := 0
for _, checklist := range r.PlaybookRun.Checklists {
for _, item := range checklist.Items {
if item.State == app.ChecklistItemStateClosed || item.State == app.ChecklistItemStateSkipped {
closed++
}
}
}
return int32(closed)
}
func (r *RunResolver) Type() string {
return r.PlaybookRun.Type
}
func (r *RunResolver) CreateAt() float64 {
return float64(r.PlaybookRun.CreateAt)
}
func (r *RunResolver) EndAt() float64 {
return float64(r.PlaybookRun.EndAt)
}
func (r *RunResolver) SummaryModifiedAt() float64 {
return float64(r.PlaybookRun.SummaryModifiedAt)
}
func (r *RunResolver) LastStatusUpdateAt() float64 {
return float64(r.PlaybookRun.LastStatusUpdateAt)
}
func (r *RunResolver) RetrospectivePublishedAt() float64 {
return float64(r.PlaybookRun.RetrospectivePublishedAt)
}
func (r *RunResolver) ReminderTimerDefaultSeconds() float64 {
return float64(r.PlaybookRun.ReminderTimerDefaultSeconds)
}
func (r *RunResolver) PreviousReminder() float64 {
return float64(r.PlaybookRun.PreviousReminder)
}
func (r *RunResolver) RetrospectiveReminderIntervalSeconds() float64 {
return float64(r.PlaybookRun.RetrospectiveReminderIntervalSeconds)
}
func (r *RunResolver) Checklists() []*ChecklistResolver {
checklistResolvers := make([]*ChecklistResolver, 0, len(r.PlaybookRun.Checklists))
for _, checklist := range r.PlaybookRun.Checklists {
checklistResolvers = append(checklistResolvers, &ChecklistResolver{checklist})
}
return checklistResolvers
}
func (r *RunResolver) StatusPosts() []*StatusPostResolver {
statusPostResolvers := make([]*StatusPostResolver, 0, len(r.PlaybookRun.StatusPosts))
for _, statusPost := range r.PlaybookRun.StatusPosts {
statusPostResolvers = append(statusPostResolvers, &StatusPostResolver{statusPost})
}
return statusPostResolvers
}
func (r *RunResolver) TimelineEvents() []*TimelineEventResolver {
timelineEventResolvers := make([]*TimelineEventResolver, 0, len(r.PlaybookRun.StatusPosts))
for _, event := range r.PlaybookRun.TimelineEvents {
timelineEventResolvers = append(timelineEventResolvers, &TimelineEventResolver{event})
}
return timelineEventResolvers
}
func (r *RunResolver) IsFavorite(ctx context.Context) (bool, error) {
c, err := getContext(ctx)
if err != nil {
return false, err
}
userID := c.r.Header.Get("Mattermost-User-ID")
thunk := c.favoritesLoader.Load(ctx, favoriteInfo{
TeamID: r.TeamID,
UserID: userID,
Type: app.RunItemType,
ID: r.ID,
})
result, err := thunk()
if err != nil {
return false, err
}
return result, nil
}
type StatusPostResolver struct {
app.StatusPost
}
func (r *StatusPostResolver) CreateAt() float64 {
return float64(r.StatusPost.CreateAt)
}
func (r *StatusPostResolver) DeleteAt() float64 {
return float64(r.StatusPost.DeleteAt)
}
type TimelineEventResolver struct {
app.TimelineEvent
}
func (r *TimelineEventResolver) CreateAt() float64 {
return float64(r.TimelineEvent.CreateAt)
}
func (r *TimelineEventResolver) EventType() string {
return string(r.TimelineEvent.EventType)
}
func (r *TimelineEventResolver) DeleteAt() float64 {
return float64(r.TimelineEvent.DeleteAt)
}
func (r *RunResolver) Followers(ctx context.Context) ([]string, error) {
c, err := getContext(ctx)
if err != nil {
return nil, err
}
metadata, err := c.playbookRunService.GetPlaybookRunMetadata(r.ID)
if err != nil {
return nil, errors.Wrap(err, "can't get metadata")
}
return metadata.Followers, nil
}
func (r *RunResolver) Playbook(ctx context.Context) (*PlaybookResolver, error) {
c, err := getContext(ctx)
if err != nil {
return nil, err
}
userID := c.r.Header.Get("Mattermost-User-ID")
thunk := c.playbooksLoader.Load(ctx, playbookInfo{
UserID: userID,
ID: r.PlaybookID,
TeamID: r.TeamID,
})
result, err := thunk()
if err != nil {
return nil, err
}
if result == nil {
return nil, nil
}
return &PlaybookResolver{*result}, nil
}
func (r *RunResolver) LastUpdatedAt(ctx context.Context) float64 {
if len(r.PlaybookRun.TimelineEvents) < 1 {
return float64(r.PlaybookRun.CreateAt)
}
return float64(r.PlaybookRun.TimelineEvents[len(r.PlaybookRun.TimelineEvents)-1].EventAt)
}
type RunConnectionResolver struct {
results app.GetPlaybookRunsResults
page int
}
func (r *RunConnectionResolver) TotalCount() int32 {
return int32(r.results.TotalCount)
}
func (r *RunConnectionResolver) Edges() []*RunEdgeResolver {
ret := make([]*RunEdgeResolver, 0, len(r.results.Items))
// Cursor is just the end cursor for the page for now
cursor := r.results.PageCount
for _, run := range r.results.Items {
ret = append(ret, &RunEdgeResolver{run, cursor})
}
return ret
}
func (r *RunConnectionResolver) PageInfo() *PageInfoResolver {
startCursor := ""
endCursor := ""
if len(r.results.Items) > 0 {
// "Cursors" are just the page numbers
startCursor = encodeRunConnectionCursor(r.page)
endCursor = encodeRunConnectionCursor(r.page + 1)
}
return &PageInfoResolver{
HasNextPage: r.results.HasMore,
StartCursor: startCursor,
EndCursor: endCursor,
}
}
func encodeRunConnectionCursor(cursor int) string {
return strconv.Itoa(cursor)
}
func decodeRunConnectionCursor(cursor string) (int, error) {
num, err := strconv.Atoi(cursor)
if err != nil {
return 0, errors.Wrap(err, "unable to decode cursor")
}
return num, nil
}
type RunEdgeResolver struct {
run app.PlaybookRun
cursor int
}
func (r *RunEdgeResolver) Node() *RunResolver {
return &RunResolver{r.run}
}
func (r *RunEdgeResolver) Cursor() string {
return encodeRunConnectionCursor(r.cursor)
}
type PageInfoResolver struct {
HasNextPage bool
StartCursor string
EndCursor string
}

View File

@ -1,39 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>GraphiQL editor | Mattermost</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/graphiql/0.11.11/graphiql.min.css" integrity="sha256-gSgd+on4bTXigueyd/NSRNAy4cBY42RAVNaXnQDjOW8=" crossorigin="anonymous"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/es6-promise/4.1.1/es6-promise.auto.min.js" integrity="sha256-OI3N9zCKabDov2rZFzl8lJUXCcP7EmsGcGoP6DMXQCo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.3/fetch.min.js" integrity="sha256-aB35laj7IZhLTx58xw/Gm1EKOoJJKZt6RY+bH1ReHxs=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/umd/react.production.min.js" integrity="sha256-wouRkivKKXA3y6AuyFwcDcF50alCNV8LbghfYCH6Z98=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom.production.min.js" integrity="sha256-9hrJxD4IQsWHdNpzLkJKYGiY/SEZFJJSUqyeZPNKd8g=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/graphiql/0.11.11/graphiql.min.js" integrity="sha256-oeWyQyKKUurcnbFRsfeSgrdOpXXiRYopnPjTVZ+6UmI=" crossorigin="anonymous"></script>
</head>
<body style="width: 100%; height: 100%; margin: 0; overflow: hidden;">
<div id="graphiql" style="height: 100vh;">Loading...</div>
<script>
function graphQLFetcher(graphQLParams) {
return fetch("/plugins/playbooks/api/v0/query", {
method: "post",
body: JSON.stringify(graphQLParams),
credentials: "include",
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function (response) {
return response.text();
}).then(function (responseBody) {
try {
return JSON.parse(responseBody);
} catch (error) {
return responseBody;
}
});
}
ReactDOM.render(
React.createElement(GraphiQL, {fetcher: graphQLFetcher}),
document.getElementById("graphiql")
);
</script>
</body>
</html>

View File

@ -1,62 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"context"
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/sirupsen/logrus"
)
// statusRecorder intercepts and saves the status code written to an http.ResponseWriter.
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *statusRecorder) WriteHeader(code int) {
// Forward the write
r.ResponseWriter.WriteHeader(code)
// Save the status code
r.statusCode = code
}
// LogRequest logs each request, attaching a unique request_id to the request context to trace
// logs throughout the request lifecycle.
func LogRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recorder := statusRecorder{w, 200}
requestID := model.NewId()
startMilis := time.Now().UnixNano() / int64(time.Millisecond)
logger := logrus.WithFields(logrus.Fields{
"method": r.Method,
"url": r.URL.String(),
"user_id": r.Header.Get("Mattermost-User-Id"),
"request_id": requestID,
"user_agent": r.Header.Get("User-Agent"),
})
r = r.WithContext(context.WithValue(r.Context(), requestIDContextKey, requestID))
logger.Debug("Received HTTP request")
next.ServeHTTP(&recorder, r)
gqlOp := r.Header.Get("X-GQL-Operation")
if gqlOp != "" {
logger = logger.WithField("gql_operation", gqlOp)
}
endMilis := time.Now().UnixNano() / int64(time.Millisecond)
logger.WithFields(logrus.Fields{
"time": endMilis - startMilis,
"status": recorder.statusCode,
}).Debug("Handled HTTP request")
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,774 +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"
"net/url"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// PlaybookHandler is the API handler.
type PlaybookHandler struct {
*ErrorHandler
playbookService app.PlaybookService
api playbooks.ServicesAPI
config config.Service
permissions *app.PermissionsService
}
const SettingsKey = "global_settings"
const maxPlaybooksToAutocomplete = 15
// NewPlaybookHandler returns a new playbook api handler
func NewPlaybookHandler(router *mux.Router, playbookService app.PlaybookService, api playbooks.ServicesAPI, configService config.Service, permissions *app.PermissionsService) *PlaybookHandler {
handler := &PlaybookHandler{
ErrorHandler: &ErrorHandler{},
playbookService: playbookService,
api: api,
config: configService,
permissions: permissions,
}
playbooksRouter := router.PathPrefix("/playbooks").Subrouter()
playbooksRouter.HandleFunc("", withContext(handler.createPlaybook)).Methods(http.MethodPost)
playbooksRouter.HandleFunc("", withContext(handler.getPlaybooks)).Methods(http.MethodGet)
playbooksRouter.HandleFunc("/autocomplete", withContext(handler.getPlaybooksAutoComplete)).Methods(http.MethodGet)
playbooksRouter.HandleFunc("/import", withContext(handler.importPlaybook)).Methods(http.MethodPost)
playbookRouter := playbooksRouter.PathPrefix("/{id:[A-Za-z0-9]+}").Subrouter()
playbookRouter.HandleFunc("", withContext(handler.getPlaybook)).Methods(http.MethodGet)
playbookRouter.HandleFunc("", withContext(handler.updatePlaybook)).Methods(http.MethodPut)
playbookRouter.HandleFunc("", withContext(handler.archivePlaybook)).Methods(http.MethodDelete)
playbookRouter.HandleFunc("/restore", withContext(handler.restorePlaybook)).Methods(http.MethodPut)
playbookRouter.HandleFunc("/export", withContext(handler.exportPlaybook)).Methods(http.MethodGet)
playbookRouter.HandleFunc("/duplicate", withContext(handler.duplicatePlaybook)).Methods(http.MethodPost)
autoFollowsRouter := playbookRouter.PathPrefix("/autofollows").Subrouter()
autoFollowsRouter.HandleFunc("", withContext(handler.getAutoFollows)).Methods(http.MethodGet)
autoFollowRouter := autoFollowsRouter.PathPrefix("/{userID:[A-Za-z0-9]+}").Subrouter()
autoFollowRouter.HandleFunc("", withContext(handler.autoFollow)).Methods(http.MethodPut)
autoFollowRouter.HandleFunc("", withContext(handler.autoUnfollow)).Methods(http.MethodDelete)
insightsRouter := playbooksRouter.PathPrefix("/insights").Subrouter()
insightsRouter.HandleFunc("/user/me", withContext(handler.getTopPlaybooksForUser)).Methods(http.MethodGet)
insightsRouter.HandleFunc("/teams/{teamID}", withContext(handler.getTopPlaybooksForTeam)).Methods(http.MethodGet)
return handler
}
func (h *PlaybookHandler) validPlaybook(w http.ResponseWriter, logger logrus.FieldLogger, playbook *app.Playbook) bool {
if playbook.WebhookOnCreationEnabled {
if err := app.ValidateWebhookURLs(playbook.WebhookOnCreationURLs); err != nil {
h.HandleErrorWithCode(w, logger, http.StatusBadRequest, err.Error(), err)
return false
}
}
if playbook.WebhookOnStatusUpdateEnabled {
if err := app.ValidateWebhookURLs(playbook.WebhookOnStatusUpdateURLs); err != nil {
h.HandleErrorWithCode(w, logger, http.StatusBadRequest, err.Error(), err)
return false
}
}
if playbook.CategorizeChannelEnabled {
if err := app.ValidateCategoryName(playbook.CategoryName); err != nil {
h.HandleErrorWithCode(w, logger, http.StatusBadRequest, "invalid category name", err)
return false
}
}
if len(playbook.SignalAnyKeywords) != 0 {
playbook.SignalAnyKeywords = app.ProcessSignalAnyKeywords(playbook.SignalAnyKeywords)
}
if playbook.BroadcastEnabled { //nolint
for _, channelID := range playbook.BroadcastChannelIDs {
channel, err := h.api.GetChannelByID(channelID)
if err != nil {
h.HandleErrorWithCode(w, logger, http.StatusBadRequest, "broadcasting to invalid channel ID", err)
return false
}
// check if channel is archived
if channel.DeleteAt != 0 {
h.HandleErrorWithCode(w, logger, http.StatusBadRequest, "broadcasting to archived channel", err)
return false
}
}
}
for listIndex := range playbook.Checklists {
for itemIndex := range playbook.Checklists[listIndex].Items {
if err := validateTaskActions(playbook.Checklists[listIndex].Items[itemIndex].TaskActions); err != nil {
h.HandleErrorWithCode(w, logger, http.StatusBadRequest, "invalid task actions", err)
return false
}
}
}
return true
}
func (h *PlaybookHandler) createPlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
var playbook app.Playbook
if err := json.NewDecoder(r.Body).Decode(&playbook); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode playbook", err)
return
}
if playbook.ID != "" {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Playbook given already has ID", nil)
return
}
if playbook.ReminderTimerDefaultSeconds <= 0 {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "playbook ReminderTimerDefaultSeconds must be > 0", nil)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookCreate(userID, playbook)) {
return
}
// If not specified make the creator the sole admin
if len(playbook.Members) == 0 {
playbook.Members = []app.PlaybookMember{
{
UserID: userID,
Roles: []string{app.PlaybookRoleMember, app.PlaybookRoleAdmin},
},
}
}
if !h.validPlaybook(w, c.logger, &playbook) {
return
}
if err := h.validateMetrics(playbook); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid metrics configs", err)
return
}
app.CleanUpChecklists(playbook.Checklists)
if err := validatePreAssignment(playbook); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Invalid pre-assignment", err)
return
}
id, err := h.playbookService.Create(playbook, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
result := struct {
ID string `json:"id"`
}{
ID: id,
}
w.Header().Add("Location", makeAPIURL(h.api, "playbooks/%s", id))
ReturnJSON(w, &result, http.StatusCreated)
}
func (h *PlaybookHandler) getPlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
playbookID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookView(userID, playbookID)) {
return
}
playbook, err := h.playbookService.Get(playbookID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
ReturnJSON(w, &playbook, http.StatusOK)
}
func (h *PlaybookHandler) updatePlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID := r.Header.Get("Mattermost-User-ID")
var playbook app.Playbook
if err := json.NewDecoder(r.Body).Decode(&playbook); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode playbook", err)
return
}
// Force parsed playbook id to be URL parameter id
playbook.ID = vars["id"]
oldPlaybook, err := h.playbookService.Get(playbook.ID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
if err = h.validateMetrics(playbook); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid metrics configs", err)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookModifyWithFixes(userID, &playbook, oldPlaybook)) {
return
}
if oldPlaybook.DeleteAt != 0 {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Playbook cannot be modified", fmt.Errorf("playbook with id '%s' cannot be modified because it is archived", playbook.ID))
return
}
if !h.validPlaybook(w, c.logger, &playbook) {
return
}
app.CleanUpChecklists(playbook.Checklists)
if err = validatePreAssignment(playbook); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Invalid user pre-assignment", err)
return
}
err = h.playbookService.Update(playbook, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusOK)
}
func validatePreAssignment(pb app.Playbook) error {
assignees := app.GetDistinctAssignees(pb.Checklists)
return app.ValidatePreAssignment(assignees, pb.InvitedUserIDs, pb.InviteUsersEnabled)
}
// validateTaskActions validates the taskactions in the given checklist
// NOTE: Any changes to this function must be made to function 'validateUpdateTaskActions' for the GraphQL endpoint.
func validateTaskActions(taskActions []app.TaskAction) error {
for _, ta := range taskActions {
if err := app.ValidateTrigger(ta.Trigger); err != nil {
return err
}
for _, a := range ta.Actions {
if err := app.ValidateAction(a); err != nil {
return err
}
}
}
return nil
}
func (h *PlaybookHandler) archivePlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
playbookID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
playbookToArchive, err := h.playbookService.Get(playbookID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.DeletePlaybook(userID, playbookToArchive)) {
return
}
err = h.playbookService.Archive(playbookToArchive, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PlaybookHandler) restorePlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
playbookID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
playbookToRestore, err := h.playbookService.Get(playbookID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.DeletePlaybook(userID, playbookToRestore)) {
return
}
err = h.playbookService.Restore(playbookToRestore, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PlaybookHandler) getPlaybooks(c *Context, w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
teamID := params.Get("team_id")
userID := r.Header.Get("Mattermost-User-ID")
opts, err := parseGetPlaybooksOptions(r.URL)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, fmt.Sprintf("failed to get playbooks: %s", err.Error()), nil)
return
}
if teamID != "" && !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookList(userID, teamID)) {
return
}
requesterInfo := app.RequesterInfo{
UserID: userID,
TeamID: teamID,
IsAdmin: app.IsSystemAdmin(userID, h.api),
}
isGuest, err := app.IsGuest(userID, h.api)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusForbidden, "", err)
return
}
if isGuest {
opts.WithMembershipOnly = true
}
playbookResults, err := h.playbookService.GetPlaybooksForTeam(requesterInfo, teamID, opts)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
ReturnJSON(w, playbookResults, http.StatusOK)
}
func (h *PlaybookHandler) getPlaybooksAutoComplete(c *Context, w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
teamID := query.Get("team_id")
userID := r.Header.Get("Mattermost-User-ID")
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookList(userID, teamID)) {
return
}
requesterInfo := app.RequesterInfo{
UserID: userID,
TeamID: teamID,
IsAdmin: app.IsSystemAdmin(userID, h.api),
}
playbooksResult, err := h.playbookService.GetPlaybooksForTeam(requesterInfo, teamID, app.PlaybookFilterOptions{
Page: 0,
PerPage: maxPlaybooksToAutocomplete,
WithArchived: query.Get("with_archived") == "true",
})
if err != nil {
h.HandleError(w, c.logger, err)
return
}
list := make([]model.AutocompleteListItem, 0)
for _, playbook := range playbooksResult.Items {
list = append(list, model.AutocompleteListItem{
Item: playbook.ID,
HelpText: playbook.Title,
})
}
ReturnJSON(w, list, http.StatusOK)
}
func parseGetPlaybooksOptions(u *url.URL) (app.PlaybookFilterOptions, error) {
params := u.Query()
var sortField app.SortField
param := strings.ToLower(params.Get("sort"))
switch param {
case "title", "":
sortField = app.SortByTitle
case "stages":
sortField = app.SortByStages
case "steps":
sortField = app.SortBySteps
case "runs":
sortField = app.SortByRuns
case "last_run_at":
sortField = app.SortByLastRunAt
case "active_runs":
sortField = app.SortByActiveRuns
default:
return app.PlaybookFilterOptions{}, errors.Errorf("bad parameter 'sort' (%s): it should be empty or one of 'title', 'stages', 'steps', 'runs', 'last_run_at'", param)
}
var sortDirection app.SortDirection
param = strings.ToLower(params.Get("direction"))
switch param {
case "asc", "":
sortDirection = app.DirectionAsc
case "desc":
sortDirection = app.DirectionDesc
default:
return app.PlaybookFilterOptions{}, errors.Errorf("bad parameter 'direction' (%s): it should be empty or one of 'asc' or 'desc'", param)
}
pageParam := params.Get("page")
if pageParam == "" {
pageParam = "0"
}
page, err := strconv.Atoi(pageParam)
if err != nil {
return app.PlaybookFilterOptions{}, errors.Wrapf(err, "bad parameter 'page': it should be a number")
}
if page < 0 {
return app.PlaybookFilterOptions{}, errors.Errorf("bad parameter 'page': it should be a positive number")
}
perPageParam := params.Get("per_page")
if perPageParam == "" || perPageParam == "0" {
perPageParam = "1000"
}
perPage, err := strconv.Atoi(perPageParam)
if err != nil {
return app.PlaybookFilterOptions{}, errors.Wrapf(err, "bad parameter 'per_page': it should be a number")
}
if perPage < 0 {
return app.PlaybookFilterOptions{}, errors.Errorf("bad parameter 'per_page': it should be a positive number")
}
searchTerm := u.Query().Get("search_term")
withArchived, _ := strconv.ParseBool(u.Query().Get("with_archived"))
return app.PlaybookFilterOptions{
Sort: sortField,
Direction: sortDirection,
Page: page,
PerPage: perPage,
SearchTerm: searchTerm,
WithArchived: withArchived,
}, nil
}
func (h *PlaybookHandler) autoFollow(c *Context, w http.ResponseWriter, r *http.Request) {
playbookID := mux.Vars(r)["id"]
currentUserID := r.Header.Get("Mattermost-User-ID")
userID := mux.Vars(r)["userID"]
if currentUserID != userID && !app.IsSystemAdmin(currentUserID, h.api) {
h.HandleErrorWithCode(w, c.logger, http.StatusForbidden, "User doesn't have permissions to make another user autofollow the playbook.", nil)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookView(userID, playbookID)) {
return
}
if err := h.playbookService.AutoFollow(playbookID, userID); err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *PlaybookHandler) autoUnfollow(c *Context, w http.ResponseWriter, r *http.Request) {
playbookID := mux.Vars(r)["id"]
currentUserID := r.Header.Get("Mattermost-User-ID")
userID := mux.Vars(r)["userID"]
if currentUserID != userID && !app.IsSystemAdmin(currentUserID, h.api) {
h.HandleErrorWithCode(w, c.logger, http.StatusForbidden, "User doesn't have permissions to make another user autofollow the playbook.", nil)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookView(userID, playbookID)) {
return
}
if err := h.playbookService.AutoUnfollow(playbookID, userID); err != nil {
h.HandleError(w, c.logger, err)
return
}
w.WriteHeader(http.StatusOK)
}
// getAutoFollows returns the list of users that have marked this playbook for auto-following runs
func (h *PlaybookHandler) getAutoFollows(c *Context, w http.ResponseWriter, r *http.Request) {
playbookID := mux.Vars(r)["id"]
currentUserID := r.Header.Get("Mattermost-User-ID")
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookView(currentUserID, playbookID)) {
return
}
autoFollowers, err := h.playbookService.GetAutoFollows(playbookID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
ReturnJSON(w, autoFollowers, http.StatusOK)
}
func (h *PlaybookHandler) exportPlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
playbookID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
playbook, err := h.playbookService.Get(playbookID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookViewWithPlaybook(userID, playbook)) {
return
}
export, err := app.GeneratePlaybookExport(playbook)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(export)
}
func (h *PlaybookHandler) duplicatePlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
playbookID := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
playbook, err := h.playbookService.Get(playbookID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookViewWithPlaybook(userID, playbook)) {
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookCreate(userID, playbook)) {
return
}
newPlaybookID, err := h.playbookService.Duplicate(playbook, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
result := struct {
ID string `json:"id"`
}{
ID: newPlaybookID,
}
ReturnJSON(w, &result, http.StatusCreated)
}
func (h *PlaybookHandler) importPlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
teamID := params.Get("team_id")
userID := r.Header.Get("Mattermost-User-ID")
var importBlock struct {
app.Playbook
Version int `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&importBlock); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode playbook import", err)
return
}
playbook := importBlock.Playbook
if playbook.ID != "" {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "playbook import should not have ID field", nil)
return
}
if importBlock.Version != app.CurrentPlaybookExportVersion {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Unsupported import version", nil)
return
}
// Make the importer the sole admin of the playbook.
playbook.Members = []app.PlaybookMember{
{
UserID: userID,
Roles: []string{app.PlaybookRoleMember, app.PlaybookRoleAdmin},
},
}
// Force the imported playbook to be public to avoid licencing issues
playbook.Public = true
if teamID != "" {
playbook.TeamID = teamID
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookCreate(userID, playbook)) {
return
}
if !h.validPlaybook(w, c.logger, &playbook) {
return
}
id, err := h.playbookService.Import(playbook, userID)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
result := struct {
ID string `json:"id"`
}{
ID: id,
}
w.Header().Add("Location", makeAPIURL(h.api, "playbooks/%s", id))
ReturnJSON(w, &result, http.StatusCreated)
}
func (h *PlaybookHandler) validateMetrics(pb app.Playbook) error {
if len(pb.Metrics) > app.MaxMetricsPerPlaybook {
return errors.Errorf(fmt.Sprintf("playbook cannot have more than %d key metrics", app.MaxMetricsPerPlaybook))
}
//check if titles are unique
titles := make(map[string]bool)
for _, m := range pb.Metrics {
if titles[m.Title] {
return errors.Errorf("metrics names must be unique")
}
titles[m.Title] = true
}
return nil
}
func (h *PlaybookHandler) getTopPlaybooksForUser(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
params := r.URL.Query()
timeRange := params.Get("time_range")
teamID := params.Get("team_id")
if teamID == "" {
h.HandleErrorWithCode(w, c.logger, http.StatusNotImplemented, "invalid team_id parameter", errors.New("teamID cannot be empty"))
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookList(userID, teamID)) {
return
}
page, err := strconv.Atoi(params.Get("page"))
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "error converting page parameter to integer", err)
return
}
perPage, err := strconv.Atoi(params.Get("per_page"))
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "error converting per_page parameter to integer", err)
return
}
// setting startTime as per user's location
user, err := h.api.GetUserByID(userID)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to get user", err)
return
}
timezone := user.GetTimezoneLocation()
// get unix time for duration
startTime, appErr := model.GetStartOfDayForTimeRange(timeRange, timezone)
if appErr != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid time parameter", appErr)
return
}
topPlaybooks, err := h.playbookService.GetTopPlaybooksForUser(teamID, userID, &model.InsightsOpts{
StartUnixMilli: model.GetMillisForTime(*startTime),
Page: page,
PerPage: perPage,
})
if err != nil {
h.HandleError(w, c.logger, err)
return
}
ReturnJSON(w, &topPlaybooks, http.StatusOK)
}
func (h *PlaybookHandler) getTopPlaybooksForTeam(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
teamID := vars["teamID"]
userID := r.Header.Get("Mattermost-User-ID")
params := r.URL.Query()
timeRange := params.Get("time_range")
if teamID == "" {
h.HandleErrorWithCode(w, c.logger, http.StatusNotImplemented, "invalid team_id parameter", errors.New("teamID cannot be empty"))
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookList(userID, teamID)) {
return
}
page, err := strconv.Atoi(params.Get("page"))
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "error converting page parameter to integer", err)
return
}
perPage, err := strconv.Atoi(params.Get("per_page"))
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "error converting per_page parameter to integer", err)
return
}
// setting startTime as per user's location
user, err := h.api.GetUserByID(userID)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to get user", err)
return
}
timezone := user.GetTimezoneLocation()
// get unix time for duration
startTime, appErr := model.GetStartOfDayForTimeRange(timeRange, timezone)
if appErr != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid time parameter", appErr)
return
}
topPlaybooks, err := h.playbookService.GetTopPlaybooksForTeam(teamID, userID, &model.InsightsOpts{
StartUnixMilli: model.GetMillisForTime(*startTime),
Page: page,
PerPage: perPage,
})
if err != nil {
h.HandleError(w, c.logger, err)
return
}
ReturnJSON(w, &topPlaybooks, http.StatusOK)
}

View File

@ -1,324 +0,0 @@
type Query {
playbook(id: String!): Playbook
playbooks(
teamID: String = "",
sort: String = "title",
direction: String = "ASC",
searchTerm: String = "",
withArchived: Boolean = false,
withMembershipOnly: Boolean = false,
): [Playbook!]!
run(id: String!): Run
runs(
teamID: String = "",
sort: String = "",
direction: String = "",
statuses: [String!] = [],
participantOrFollowerID: String = "",
channelID: String = "",
first: Int,
after: String,
types: [PlaybookRunType!] = [],
): RunConnection!
}
type Mutation {
updatePlaybookFavorite(id: String!, favorite: Boolean!): String!
updatePlaybook(id: String!, updates: PlaybookUpdates!): String!
addMetric(playbookID: String!, title: String!, description: String!, type: String!, target: Int): String!
updateMetric(id: String!, title: String, description: String, target: Int): String!
deleteMetric(id: String!): String!
addPlaybookMember(playbookID: String!, userID: String!): String!
removePlaybookMember(playbookID: String!, userID: String!): String!
setRunFavorite(id: String!, fav: Boolean!): String!
updateRun(id: String!, updates: RunUpdates!): String!
addRunParticipants(runID: String!, userIDs: [String!]!, forceAddToChannel: Boolean = false): String!
removeRunParticipants(runID: String!, userIDs: [String!]!): String!
changeRunOwner(runID: String!, ownerID: String!): String!
updateRunTaskActions(runID: String!, checklistNum: Float!, itemNum: Float!, taskActions: [TaskActionUpdates!]): String!
}
type PageInfo {
hasNextPage: Boolean!
startCursor: String!
endCursor: String!
}
input PlaybookUpdates {
title: String
description: String
public: Boolean
createPublicPlaybookRun: Boolean
reminderMessageTemplate: String
reminderTimerDefaultSeconds: Float
statusUpdateEnabled: Boolean
invitedUserIDs: [String!]
invitedGroupIDs: [String!]
inviteUsersEnabled: Boolean
defaultOwnerID: String
defaultOwnerEnabled: Boolean
broadcastChannelIDs: [String!]
broadcastEnabled: Boolean
webhookOnCreationURLs: [String!]
webhookOnCreationEnabled: Boolean
messageOnJoin: String
messageOnJoinEnabled: Boolean
retrospectiveReminderIntervalSeconds: Float
retrospectiveTemplate: String
retrospectiveEnabled: Boolean
webhookOnStatusUpdateURLs: [String!]
webhookOnStatusUpdateEnabled: Boolean
signalAnyKeywords: [String!]
signalAnyKeywordsEnabled: Boolean
categorizeChannelEnabled: Boolean
categoryName: String
runSummaryTemplateEnabled: Boolean
runSummaryTemplate: String
channelNameTemplate: String
checklists: [ChecklistUpdates!]
createChannelMemberOnNewParticipant: Boolean
removeChannelMemberOnRemovedParticipant: Boolean
channelId: String
channelMode: String
}
input ChecklistUpdates {
title: String!
items: [ChecklistItemUpdates!]!
}
input ChecklistItemUpdates {
title: String!
description: String!
state: String!
stateModified: Float!
assigneeID: String!
assigneeModified: Float!
command: String!
commandLastRun: Float!
dueDate: Float!
taskActions: [TaskActionUpdates!]
}
input TaskActionUpdates {
trigger: TriggerUpdates!
actions: [ActionUpdates!]!
}
input TriggerUpdates {
type: String!
payload: String!
}
input ActionUpdates {
type: String!
payload: String!
}
type Playbook {
id: String!
title: String!
description: String!
teamID: String!
createPublicPlaybookRun: Boolean!
deleteAt: Float!
lastRunAt: Float!
numRuns: Int!
activeRuns: Int!
runSummaryTemplateEnabled: Boolean!
defaultPlaybookMemberRole: String!
public: Boolean!
checklists: [Checklist!]!
members: [Member!]!
reminderMessageTemplate: String!
reminderTimerDefaultSeconds: Float!
statusUpdateEnabled: Boolean!
invitedUserIDs: [String!]!
invitedGroupIDs: [String!]!
inviteUsersEnabled: Boolean!
defaultOwnerID: String!
defaultOwnerEnabled: Boolean!
broadcastChannelIDs: [String!]!
broadcastEnabled: Boolean!
webhookOnCreationURLs: [String!]!
webhookOnCreationEnabled: Boolean!
messageOnJoin: String!
messageOnJoinEnabled: Boolean!
retrospectiveReminderIntervalSeconds: Float!
retrospectiveTemplate: String!
retrospectiveEnabled: Boolean!
webhookOnStatusUpdateURLs: [String!]!
webhookOnStatusUpdateEnabled: Boolean!
signalAnyKeywords: [String!]!
signalAnyKeywordsEnabled: Boolean!
categorizeChannelEnabled: Boolean!
categoryName: String!
runSummaryTemplateEnabled: Boolean!
runSummaryTemplate: String!
channelNameTemplate: String!
defaultPlaybookAdminRole: String!
defaultPlaybookMemberRole: String!
defaultRunAdminRole: String!
defaultRunMemberRole: String!
metrics: [PlaybookMetricConfig!]!
isFavorite: Boolean!
createChannelMemberOnNewParticipant: Boolean!
removeChannelMemberOnRemovedParticipant: Boolean!
channelID: String!
channelMode: String!
}
type Checklist {
title: String!
items: [ChecklistItem!]!
}
type Member {
userID: String!
roles: [String!]!
schemeRoles: [String!]!
}
type ChecklistItem {
title: String!
description: String!
state: String!
stateModified: Float!
assigneeID: String!
assigneeModified: Float!
command: String!
commandLastRun: Float!
dueDate: Float!
taskActions: [TaskAction!]!
}
type TaskAction {
trigger: Trigger!
actions: [Action!]!
}
type Trigger {
type: String!
payload: String!
}
type Action {
type: String!
payload: String!
}
enum MetricType {
metric_duration
metric_currency
metric_integer
}
type PlaybookMetricConfig {
id: String!
title: String!
description: String!
type: MetricType!
target: Int
}
enum PlaybookRunType {
playbook
channelChecklist
}
type Run {
id: String!
playbookID: String!
playbook: Playbook
name: String!
ownerUserID: String!
channelID: String!
postID: String!
teamID: String!
isFavorite: Boolean!
currentStatus: String!
createAt: Float!
endAt: Float!
participantIDs: [String!]!
summary: String!
summaryModifiedAt: Float!
checklists: [Checklist!]!
retrospective: String!
retrospectivePublishedAt: Float!
retrospectiveReminderIntervalSeconds: Float!
retrospectiveEnabled: Boolean!
retrospectiveWasCanceled: Boolean!
statusUpdateEnabled: Boolean!
statusUpdateBroadcastWebhooksEnabled: Boolean!
lastStatusUpdateAt: Float!
statusPosts: [StatusPost!]!
reminderPostId: String!
reminderMessageTemplate: String!
reminderTimerDefaultSeconds: Float!
previousReminder: Float!
statusUpdateBroadcastChannelsEnabled: Boolean!
statusUpdateBroadcastWebhooksEnabled: Boolean!
broadcastChannelIDs: [String!]!
webhookOnStatusUpdateURLs: [String!]!
createChannelMemberOnNewParticipant: Boolean!
removeChannelMemberOnRemovedParticipant: Boolean!
lastUpdatedAt: Float!
timelineEvents: [TimelineEvent!]!
followers: [String!]!
numTasks: Int!
numTasksClosed: Int!
type: PlaybookRunType!
}
type RunConnection {
totalCount: Int!
edges: [RunEdge!]!
pageInfo: PageInfo!
}
type RunEdge {
cursor: String!
node: Run!
}
type StatusPost {
id: String!
createAt: Float!
deleteAt: Float!
}
type TimelineEvent {
id: String!
createAt: Float!
deleteAt: Float!
eventType: String!
details: String!
postID: String!
summary: String!
subjectUserID: String!
creatorUserID: String!
}
input RunUpdates {
name: String
summary: String
createChannelMemberOnNewParticipant: Boolean
removeChannelMemberOnRemovedParticipant: Boolean
statusUpdateBroadcastChannelsEnabled: Boolean
statusUpdateBroadcastWebhooksEnabled: Boolean
broadcastChannelIDs: [String!]
webhookOnStatusUpdateURLs: [String!]
channelID: String
}

View File

@ -1,43 +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/playbooks/client"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
)
// SettingsHandler is the API handler.
type SettingsHandler struct {
*ErrorHandler
api playbooks.ServicesAPI
config config.Service
}
// NewSettingsHandler returns a new settings api handler
func NewSettingsHandler(router *mux.Router, api playbooks.ServicesAPI, configService config.Service) *SettingsHandler {
handler := &SettingsHandler{
ErrorHandler: &ErrorHandler{},
api: api,
config: configService,
}
settingsRouter := router.PathPrefix("/settings").Subrouter()
settingsRouter.HandleFunc("", handler.getSettings).Methods(http.MethodGet)
return handler
}
func (h *SettingsHandler) getSettings(w http.ResponseWriter, r *http.Request) {
cfg := h.config.GetConfiguration()
settings := client.GlobalSettings{
EnableExperimentalFeatures: cfg.EnableExperimentalFeatures,
}
ReturnJSON(w, &settings, http.StatusOK)
}

View File

@ -1,134 +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/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type SignalHandler struct {
*ErrorHandler
api playbooks.ServicesAPI
playbookRunService app.PlaybookRunService
playbookService app.PlaybookService
keywordsThreadIgnorer app.KeywordsThreadIgnorer
}
func NewSignalHandler(router *mux.Router, api playbooks.ServicesAPI, playbookRunService app.PlaybookRunService, playbookService app.PlaybookService, keywordsThreadIgnorer app.KeywordsThreadIgnorer) *SignalHandler {
handler := &SignalHandler{
ErrorHandler: &ErrorHandler{},
api: api,
playbookRunService: playbookRunService,
playbookService: playbookService,
keywordsThreadIgnorer: keywordsThreadIgnorer,
}
signalRouter := router.PathPrefix("/signal").Subrouter()
keywordsRouter := signalRouter.PathPrefix("/keywords").Subrouter()
keywordsRouter.HandleFunc("/run-playbook", withContext(handler.playbookRun)).Methods(http.MethodPost)
keywordsRouter.HandleFunc("/ignore-thread", withContext(handler.ignoreKeywords)).Methods(http.MethodPost)
return handler
}
func (h *SignalHandler) playbookRun(c *Context, w http.ResponseWriter, r *http.Request) {
publicErrorMessage := "unable to decode post action integration request"
var req *model.PostActionIntegrationRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
h.returnError(publicErrorMessage, err, c.logger, w)
return
}
if req == nil {
h.returnError(publicErrorMessage, errors.New("nil request"), c.logger, w)
return
}
id, err := getStringField("selected_option", req.Context, w)
if err != nil {
h.returnError(publicErrorMessage, err, c.logger, w)
return
}
pbook, err := h.playbookService.Get(id)
if err != nil {
h.returnError("can't get chosen playbook", errors.Wrapf(err, "can't get chosen playbook, id - %s", id), c.logger, w)
return
}
if err := h.playbookRunService.OpenCreatePlaybookRunDialog(req.TeamId, req.UserId, req.TriggerId, "", "", []app.Playbook{pbook}); err != nil {
h.returnError("can't open dialog", errors.Wrap(err, "can't open a dialog"), c.logger, w)
return
}
ReturnJSON(w, &model.PostActionIntegrationResponse{}, http.StatusOK)
if _, err := h.api.DeletePost(req.PostId); err != nil {
h.returnError("unable to delete original post", err, c.logger, w)
return
}
}
func (h *SignalHandler) ignoreKeywords(c *Context, w http.ResponseWriter, r *http.Request) {
publicErrorMessage := "unable to decode post action integration request"
var req *model.PostActionIntegrationRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil || req == nil {
h.returnError(publicErrorMessage, err, c.logger, w)
return
}
postID, err := getStringField("postID", req.Context, w)
if err != nil {
h.returnError(publicErrorMessage, err, c.logger, w)
return
}
post, err := h.api.GetPost(postID)
if err != nil {
h.returnError(publicErrorMessage, err, c.logger, w)
return
}
h.keywordsThreadIgnorer.Ignore(postID, post.UserId)
if post.RootId != "" {
h.keywordsThreadIgnorer.Ignore(post.RootId, post.UserId)
}
ReturnJSON(w, &model.PostActionIntegrationResponse{}, http.StatusOK)
if _, err := h.api.DeletePost(req.PostId); err != nil {
h.returnError("unable to delete original post", err, c.logger, w)
return
}
}
func (h *SignalHandler) returnError(returnMessage string, err error, logger logrus.FieldLogger, w http.ResponseWriter) {
resp := model.PostActionIntegrationResponse{
EphemeralText: fmt.Sprintf("Error: %s", returnMessage),
}
logger.WithError(err).Warn(returnMessage)
ReturnJSON(w, &resp, http.StatusOK)
}
func getStringField(field string, context map[string]interface{}, w http.ResponseWriter) (string, error) {
fieldInt, ok := context[field]
if !ok {
return "", errors.Errorf("no %s field in the request context", field)
}
fieldValue, ok := fieldInt.(string)
if !ok {
return "", errors.Errorf("%s field is not a string", field)
}
return fieldValue, nil
}

View File

@ -1,168 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"math"
"net/http"
"net/url"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"gopkg.in/guregu/null.v4"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/v8/playbooks/server/sqlstore"
"github.com/pkg/errors"
)
type StatsHandler struct {
*ErrorHandler
api playbooks.ServicesAPI
statsStore *sqlstore.StatsStore
playbookService app.PlaybookService
permissions *app.PermissionsService
licenseChecker app.LicenseChecker
}
func NewStatsHandler(router *mux.Router, api playbooks.ServicesAPI, statsStore *sqlstore.StatsStore, playbookService app.PlaybookService, permissions *app.PermissionsService, licenseChecker app.LicenseChecker) *StatsHandler {
handler := &StatsHandler{
ErrorHandler: &ErrorHandler{},
api: api,
statsStore: statsStore,
playbookService: playbookService,
permissions: permissions,
licenseChecker: licenseChecker,
}
statsRouter := router.PathPrefix("/stats").Subrouter()
statsRouter.HandleFunc("/site", withContext(handler.playbookSiteStats)).Methods(http.MethodGet)
statsRouter.HandleFunc("/playbook", withContext(handler.playbookStats)).Methods(http.MethodGet)
return handler
}
type PlaybookStats struct {
RunsInProgress int `json:"runs_in_progress"`
ParticipantsActive int `json:"participants_active"`
RunsFinishedPrev30Days int `json:"runs_finished_prev_30_days"`
RunsFinishedPercentageChange int `json:"runs_finished_percentage_change"`
RunsStartedPerWeek []int `json:"runs_started_per_week"`
RunsStartedPerWeekTimes [][]int64 `json:"runs_started_per_week_times"`
ActiveRunsPerDay []int `json:"active_runs_per_day"`
ActiveRunsPerDayTimes [][]int64 `json:"active_runs_per_day_times"`
ActiveParticipantsPerDay []int `json:"active_participants_per_day"`
ActiveParticipantsPerDayTimes [][]int64 `json:"active_participants_per_day_times"`
MetricOverallAverage []null.Int `json:"metric_overall_average"`
MetricRollingAverage []null.Int `json:"metric_rolling_average"`
MetricRollingAverageChange []null.Int `json:"metric_rolling_average_change"`
MetricValueRange [][]int64 `json:"metric_value_range"`
MetricRollingValues [][]int64 `json:"metric_rolling_values"`
LastXRunNames []string `json:"last_x_run_names"`
}
const (
MetricChartPeriod = 10
MetricRollingAveragePeriod = 10
)
func parsePlaybookStatsFilters(u *url.URL) (*sqlstore.StatsFilters, error) {
playbookID := u.Query().Get("playbook_id")
if playbookID == "" {
return nil, errors.New("bad parameter 'playbook_id'; 'playbook_id' is required")
}
return &sqlstore.StatsFilters{
PlaybookID: playbookID,
}, nil
}
// playbookStats handles the internal plugin stats
func (h *StatsHandler) playbookStats(c *Context, w http.ResponseWriter, r *http.Request) {
if !h.licenseChecker.StatsAllowed() {
h.HandleErrorWithCode(w, c.logger, http.StatusForbidden, "timeline feature is not covered by current server license", nil)
return
}
userID := r.Header.Get("Mattermost-User-ID")
filters, err := parsePlaybookStatsFilters(r.URL)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "Bad filters", err)
return
}
if !h.PermissionsCheck(w, c.logger, h.permissions.PlaybookView(userID, filters.PlaybookID)) {
return
}
runsFinishedLast30Days := h.statsStore.RunsFinishedBetweenDays(filters, 30, 0)
runsFinishedBetween60and30DaysAgo := h.statsStore.RunsFinishedBetweenDays(filters, 60, 31)
var percentageChange int
if runsFinishedBetween60and30DaysAgo == 0 {
percentageChange = 99999999
} else {
percentageChange = int(math.Floor(float64((runsFinishedLast30Days-runsFinishedBetween60and30DaysAgo)/runsFinishedBetween60and30DaysAgo) * 100))
}
runsStartedPerWeek, runsStartedPerWeekTimes := h.statsStore.RunsStartedPerWeekLastXWeeks(12, filters)
activeRunsPerDay, activeRunsPerDayTimes := h.statsStore.ActiveRunsPerDayLastXDays(14, filters)
activeParticipantsPerDay, activeParticipantsPerDayTimes := h.statsStore.ActiveParticipantsPerDayLastXDays(14, filters)
metricOverallAverage := h.statsStore.MetricOverallAverage(*filters)
metricRollingValues, lastXRunNames := h.statsStore.MetricRollingValuesLastXRuns(MetricChartPeriod, 0, *filters)
metricRollingAverage, metricRollingAverageChange := h.statsStore.MetricRollingAverageAndChange(MetricRollingAveragePeriod, *filters)
metricValueRange := h.statsStore.MetricValueRange(*filters)
ReturnJSON(w, &PlaybookStats{
RunsInProgress: h.statsStore.TotalInProgressPlaybookRuns(filters),
ParticipantsActive: h.statsStore.TotalActiveParticipants(filters),
RunsFinishedPrev30Days: runsFinishedLast30Days,
RunsFinishedPercentageChange: percentageChange,
RunsStartedPerWeek: runsStartedPerWeek,
RunsStartedPerWeekTimes: runsStartedPerWeekTimes,
ActiveRunsPerDay: activeRunsPerDay,
ActiveRunsPerDayTimes: activeRunsPerDayTimes,
ActiveParticipantsPerDay: activeParticipantsPerDay,
ActiveParticipantsPerDayTimes: activeParticipantsPerDayTimes,
MetricOverallAverage: metricOverallAverage,
MetricRollingValues: metricRollingValues,
MetricValueRange: metricValueRange,
MetricRollingAverage: metricRollingAverage,
MetricRollingAverageChange: metricRollingAverageChange,
LastXRunNames: lastXRunNames,
}, http.StatusOK)
}
type PlaybookSiteStats struct {
TotalPlaybooks int `json:"total_playbooks"`
TotalPlaybookRuns int `json:"total_playbook_runs"`
}
// playbooSitekStats collects and sends the stats used for system-console > statistics
//
// Response 200: PlaybookSiteStats
// Response 401: when user is not authenticated
// Response 403: when user has no permissions to see stats
func (h *StatsHandler) playbookSiteStats(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
// user must have right to access analytics
if !h.api.HasPermissionTo(userID, model.PermissionGetAnalytics) {
h.HandleErrorWithCode(w, c.logger, http.StatusForbidden, "user is not allowed to get site stats", nil)
return
}
totalPlaybooks, err := h.statsStore.TotalPlaybooks()
if err != nil {
c.logger.WithError(err).Warn("playbookSiteStats failed fetching total playbooks")
}
totalRuns, err := h.statsStore.TotalPlaybookRuns()
if err != nil {
c.logger.WithError(err).Warn("playbookSiteStats failed fetching total playbook runs")
}
ReturnJSON(w, &PlaybookSiteStats{
TotalPlaybooks: totalPlaybooks,
TotalPlaybookRuns: totalRuns,
}, http.StatusOK)
}

View File

@ -1,256 +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/pkg/errors"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/mattermost/mattermost/server/v8/playbooks/server/bot"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
)
// TelemetryHandler is the API handler.
type TelemetryHandler struct {
*ErrorHandler
playbookRunService app.PlaybookRunService
playbookRunTelemetry app.PlaybookRunTelemetry
playbookService app.PlaybookService
permissions *app.PermissionsService
playbookTelemetry app.PlaybookTelemetry
genericTelemetry app.GenericTelemetry
botTelemetry bot.Telemetry
api playbooks.ServicesAPI
}
// NewTelemetryHandler Creates a new Plugin API handler.
func NewTelemetryHandler(
router *mux.Router,
playbookRunService app.PlaybookRunService,
api playbooks.ServicesAPI,
playbookRunTelemetry app.PlaybookRunTelemetry,
playbookService app.PlaybookService,
playbookTelemetry app.PlaybookTelemetry,
genericTelemetry app.GenericTelemetry,
botTelemetry bot.Telemetry,
permissions *app.PermissionsService,
) *TelemetryHandler {
handler := &TelemetryHandler{
ErrorHandler: &ErrorHandler{},
playbookRunService: playbookRunService,
playbookRunTelemetry: playbookRunTelemetry,
playbookService: playbookService,
playbookTelemetry: playbookTelemetry,
genericTelemetry: genericTelemetry,
botTelemetry: botTelemetry,
api: api,
permissions: permissions,
}
telemetryRouter := router.PathPrefix("/telemetry").Subrouter()
telemetryRouter.HandleFunc("", withContext(handler.createEvent)).Methods(http.MethodPost)
startTrialRouter := telemetryRouter.PathPrefix("/start-trial").Subrouter()
startTrialRouter.HandleFunc("", withContext(handler.startTrial)).Methods(http.MethodPost)
playbookRunTelemetryRouterAuthorized := telemetryRouter.PathPrefix("/run").Subrouter()
playbookRunTelemetryRouterAuthorized.Use(handler.checkPlaybookRunViewPermissions)
playbookRunTelemetryRouterAuthorized.HandleFunc("/{id:[A-Za-z0-9]+}", withContext(handler.telemetryForPlaybookRun)).Methods(http.MethodPost)
playbookTelemetryRouterAuthorized := telemetryRouter.PathPrefix("/playbook").Subrouter()
playbookTelemetryRouterAuthorized.Use(handler.checkPlaybookViewPermissions)
playbookTelemetryRouterAuthorized.HandleFunc("/{id:[A-Za-z0-9]+}", withContext(handler.telemetryForPlaybook)).Methods(http.MethodPost)
templateRouter := telemetryRouter.PathPrefix("/template").Subrouter()
templateRouter.HandleFunc("", withContext(handler.telemetryForTemplate))
return handler
}
type EventData struct {
Name string
Type app.TelemetryType
Properties map[string]interface{}
}
func (h *TelemetryHandler) createEvent(c *Context, w http.ResponseWriter, r *http.Request) {
var event EventData
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode post body", err)
return
}
if event.Properties == nil {
event.Properties = map[string]interface{}{}
}
event.Properties["UserActualID"] = r.Header.Get("Mattermost-User-ID")
switch event.Type {
case app.TelemetryTypePage:
name, err := app.NewTelemetryPage(event.Name)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid page tracking", err)
return
}
h.genericTelemetry.Page(*name, event.Properties)
case app.TelemetryTypeTrack:
name, err := app.NewTelemetryTrack(event.Name)
if err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid event tracking", err)
return
}
h.genericTelemetry.Track(*name, event.Properties)
default:
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "invalid type to be tracked", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TelemetryHandler) checkPlaybookRunViewPermissions(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID := r.Header.Get("Mattermost-User-ID")
runID := vars["id"]
if err := h.permissions.RunView(userID, runID); err != nil {
logger := getLogger(r)
if errors.Is(err, app.ErrNoPermissions) {
h.HandleErrorWithCode(w, logger, http.StatusForbidden, "Not authorized", err)
return
}
h.HandleError(w, logger, err)
return
}
next.ServeHTTP(w, r)
})
}
func (h *TelemetryHandler) checkPlaybookViewPermissions(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID := r.Header.Get("Mattermost-User-ID")
playbookID := vars["id"]
if err := h.permissions.PlaybookView(userID, playbookID); err != nil {
logger := getLogger(r)
if errors.Is(err, app.ErrNoPermissions) {
h.HandleErrorWithCode(w, logger, http.StatusForbidden, "Not authorized", err)
return
}
h.HandleError(w, logger, err)
return
}
next.ServeHTTP(w, r)
})
}
type TrackerPayload struct {
Action string `json:"action"`
}
// telemetryForPlaybookRun handles the /telemetry/run/{id}?action=the_action endpoint. The frontend
// can use this endpoint to track events that occur in the context of a playbook run.
func (h *TelemetryHandler) telemetryForPlaybookRun(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
var params TrackerPayload
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode post body", err)
return
}
if params.Action == "" {
h.HandleError(w, c.logger, errors.New("must provide action"))
return
}
playbookRun, err := h.playbookRunService.GetPlaybookRun(id)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
h.playbookRunTelemetry.FrontendTelemetryForPlaybookRun(playbookRun, userID, params.Action)
w.WriteHeader(http.StatusNoContent)
}
func (h *TelemetryHandler) startTrial(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
var params TrackerPayload
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode post body", err)
return
}
h.botTelemetry.StartTrial(userID, params.Action)
w.WriteHeader(http.StatusNoContent)
}
// telemetryForPlaybook handles the /telemetry/playbook/{id}?action=the_action endpoint. The frontend
// can use this endpoint to track events that occur in the context of a playbook.
func (h *TelemetryHandler) telemetryForPlaybook(c *Context, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID := r.Header.Get("Mattermost-User-ID")
var params TrackerPayload
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode post body", err)
return
}
if params.Action == "" {
h.HandleError(w, c.logger, errors.New("must provide action"))
return
}
playbook, err := h.playbookService.Get(id)
if err != nil {
h.HandleError(w, c.logger, err)
return
}
h.playbookTelemetry.FrontendTelemetryForPlaybook(playbook, userID, params.Action)
w.WriteHeader(http.StatusNoContent)
}
func (h *TelemetryHandler) telemetryForTemplate(c *Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")
var params struct {
TemplateName string `json:"template_name"`
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to decode post body", err)
return
}
if params.TemplateName == "" {
h.HandleError(w, c.logger, errors.New("must provide template_name"))
return
}
if params.Action == "" {
h.HandleError(w, c.logger, errors.New("must provide action"))
return
}
h.playbookTelemetry.FrontendTelemetryForPlaybookTemplate(params.TemplateName, userID, params.Action)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -1,41 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api
import (
"fmt"
"net/url"
"path"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const defaultBaseAPIURL = "plugins/playbooks/api/v0"
func getAPIBaseURL(api playbooks.ServicesAPI) (string, error) {
siteURL := model.ServiceSettingsDefaultSiteURL
if api.GetConfig().ServiceSettings.SiteURL != nil {
siteURL = *api.GetConfig().ServiceSettings.SiteURL
}
parsedSiteURL, err := url.Parse(siteURL)
if err != nil {
return "", errors.Wrapf(err, "failed to parse siteURL %s", siteURL)
}
return path.Join(parsedSiteURL.Path, defaultBaseAPIURL), nil
}
func makeAPIURL(api playbooks.ServicesAPI, apiPath string, args ...interface{}) string {
apiBaseURL, err := getAPIBaseURL(api)
if err != nil {
logrus.WithError(err).Error("failed to build api base url")
apiBaseURL = defaultBaseAPIURL
}
return path.Join("/", apiBaseURL, fmt.Sprintf(apiPath, args...))
}

View File

@ -1,457 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package server
import (
"context"
"net/http"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
"github.com/mitchellh/mapstructure"
"github.com/stretchr/testify/assert"
)
func TestActionCreation(t *testing.T) {
e := Setup(t)
e.CreateBasic()
createNewChannel := func(t *testing.T, name string) *model.Channel {
t.Helper()
pubChannel, _, err := e.ServerAdminClient.CreateChannel(context.Background(), &model.Channel{
DisplayName: name,
Name: name,
Type: model.ChannelTypeOpen,
TeamId: e.BasicTeam.Id,
})
assert.NoError(t, err)
_, _, err = e.ServerAdminClient.AddChannelMember(context.Background(), pubChannel.Id, e.RegularUser.Id)
assert.NoError(t, err)
return pubChannel
}
t.Run("create valid action", func(t *testing.T) {
// Create a brand new channel
channel := createNewChannel(t, "create-valid-action")
// Create a valid action
actionID, err := e.PlaybooksClient.Actions.Create(context.Background(), channel.Id, client.ChannelActionCreateOptions{
ChannelID: channel.Id,
Enabled: true,
ActionType: client.ActionTypeWelcomeMessage,
TriggerType: client.TriggerTypeNewMemberJoins,
Payload: client.WelcomeMessagePayload{
Message: "Hello!",
},
})
// Verify that the API succeeds
assert.NoError(t, err)
assert.NotEmpty(t, actionID)
})
t.Run("create valid partial action", func(t *testing.T) {
// Create a brand new channel
channel := createNewChannel(t, "create-valid-partial-action")
// Create an action with only keywords, but no playbook ID
actionID, err := e.PlaybooksClient.Actions.Create(context.Background(), channel.Id, client.ChannelActionCreateOptions{
ChannelID: channel.Id,
Enabled: true,
ActionType: client.ActionTypePromptRunPlaybook,
TriggerType: client.TriggerTypeKeywordsPosted,
Payload: client.PromptRunPlaybookFromKeywordsPayload{
Keywords: []string{"one"},
},
})
// Verify that the API succeeds
assert.NoError(t, err)
assert.NotEmpty(t, actionID)
})
t.Run("create invalid action - duplicate action and trigger types", func(t *testing.T) {
// Create a brand new channel
channel := createNewChannel(t, "create-invalid-action-duplicate")
// Define an action
action := client.ChannelActionCreateOptions{
ChannelID: channel.Id,
Enabled: true,
ActionType: client.ActionTypeCategorizeChannel,
TriggerType: client.TriggerTypeNewMemberJoins,
Payload: client.CategorizeChannelPayload{
CategoryName: "category",
},
}
// Create a valid action
actionID, err := e.PlaybooksClient.Actions.Create(context.Background(), channel.Id, action)
// Verify that the API succeeds
assert.NoError(t, err)
assert.NotEmpty(t, actionID)
// Try to create the same action again
_, err = e.PlaybooksClient.Actions.Create(context.Background(), channel.Id, action)
// Verify that the API fails with a 500 error
requireErrorWithStatusCode(t, err, http.StatusInternalServerError)
})
t.Run("create invalid action - wrong action type", func(t *testing.T) {
// Create a brand new channel
channel := createNewChannel(t, "create-invalid-action-wrong-action")
// Create an action with a wrong action type
_, err := e.PlaybooksClient.Actions.Create(context.Background(), channel.Id, client.ChannelActionCreateOptions{
ChannelID: channel.Id,
Enabled: true,
ActionType: "wrong action type",
TriggerType: client.TriggerTypeNewMemberJoins,
Payload: client.WelcomeMessagePayload{
Message: "Hello!",
},
})
// Verify that the API fails with a 400 error
requireErrorWithStatusCode(t, err, http.StatusBadRequest)
})
t.Run("create invalid action - wrong trigger type", func(t *testing.T) {
// Create a brand new channel
channel := createNewChannel(t, "create-invalid-action-wrong-trigger")
// Create an action with a wrong trigger type
_, err := e.PlaybooksClient.Actions.Create(context.Background(), channel.Id, client.ChannelActionCreateOptions{
ChannelID: channel.Id,
Enabled: true,
ActionType: client.ActionTypeWelcomeMessage,
TriggerType: "wrong trigger type",
Payload: client.WelcomeMessagePayload{
Message: "Hello!",
},
})
// Verify that the API fails with a 400 error
requireErrorWithStatusCode(t, err, http.StatusBadRequest)
})
t.Run("create action forbidden - not channel admin", func(t *testing.T) {
// Create a brand new channel
channel := createNewChannel(t, "create-action-forbidden")
defaultRolePermissions := e.Permissions.SaveDefaultRolePermissions()
defer func() {
e.Permissions.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
// Tweak the permissions so that the user is no longer channel admin
e.Permissions.RemovePermissionFromRole(model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
// Attempt to create the action without those permissions
_, err := e.PlaybooksClient.Actions.Create(context.Background(), channel.Id, client.ChannelActionCreateOptions{
ChannelID: channel.Id,
Enabled: true,
ActionType: client.ActionTypeWelcomeMessage,
TriggerType: client.TriggerTypeNewMemberJoins,
Payload: client.WelcomeMessagePayload{
Message: "Hello!",
},
})
// Verify that the API fails with a 403 error
requireErrorWithStatusCode(t, err, http.StatusForbidden)
})
t.Run("create action allowed - not channel admin, but system admin", func(t *testing.T) {
// Create a brand new channel
channel := createNewChannel(t, "create-action-allowed")
defaultRolePermissions := e.Permissions.SaveDefaultRolePermissions()
defer func() {
e.Permissions.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
// Tweak the permissions so that the user is no longer channel admin
e.Permissions.RemovePermissionFromRole(model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
// Attempt to create the action as a sysadmin without being a channel admin
actionID, err := e.PlaybooksAdminClient.Actions.Create(context.Background(), channel.Id, client.ChannelActionCreateOptions{
ChannelID: channel.Id,
Enabled: true,
ActionType: client.ActionTypePromptRunPlaybook,
TriggerType: client.TriggerTypeKeywordsPosted,
Payload: client.PromptRunPlaybookFromKeywordsPayload{
Keywords: []string{"one", "two"},
PlaybookID: model.NewId(),
},
})
// Verify that the API succeeds
assert.NoError(t, err)
assert.NotEmpty(t, actionID)
})
}
func TestActionList(t *testing.T) {
e := Setup(t)
e.CreateBasic()
// Create three valid actions
welcomeActionID, err := e.PlaybooksClient.Actions.Create(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionCreateOptions{
ChannelID: e.BasicPublicChannel.Id,
Enabled: true,
ActionType: client.ActionTypeWelcomeMessage,
TriggerType: client.TriggerTypeNewMemberJoins,
Payload: client.WelcomeMessagePayload{
Message: "msg",
},
})
assert.NoError(t, err)
categorizeActionID, err := e.PlaybooksClient.Actions.Create(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionCreateOptions{
ChannelID: e.BasicPublicChannel.Id,
Enabled: true,
ActionType: client.ActionTypeCategorizeChannel,
TriggerType: client.TriggerTypeNewMemberJoins,
Payload: client.CategorizeChannelPayload{
CategoryName: "category",
},
})
assert.NoError(t, err)
playbookID := model.NewId()
promptActionID, err := e.PlaybooksClient.Actions.Create(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionCreateOptions{
ChannelID: e.BasicPublicChannel.Id,
Enabled: true,
ActionType: client.ActionTypePromptRunPlaybook,
TriggerType: client.TriggerTypeKeywordsPosted,
Payload: client.PromptRunPlaybookFromKeywordsPayload{
Keywords: []string{"one", "two"},
PlaybookID: playbookID,
},
})
assert.NoError(t, err)
t.Run("view list allowed", func(t *testing.T) {
// List the actions with the default options
actions, err := e.PlaybooksClient.Actions.List(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionListOptions{})
// Verify that the API succeeds and that it returns the correct number of actions
assert.NoError(t, err)
assert.Len(t, actions, 3)
// Verify that the returned actions contain the correct payloads
for _, action := range actions {
switch action.ID {
case welcomeActionID:
var payload client.WelcomeMessagePayload
err = mapstructure.Decode(action.Payload, &payload)
assert.NoError(t, err)
assert.Equal(t, "msg", payload.Message)
case categorizeActionID:
var payload client.CategorizeChannelPayload
err = mapstructure.Decode(action.Payload, &payload)
assert.NoError(t, err)
assert.Equal(t, "category", payload.CategoryName)
case promptActionID:
var payload client.PromptRunPlaybookFromKeywordsPayload
err = mapstructure.Decode(action.Payload, &payload)
assert.NoError(t, err)
assert.EqualValues(t, []string{"one", "two"}, payload.Keywords)
assert.Equal(t, playbookID, payload.PlaybookID)
}
}
})
t.Run("view list forbidden", func(t *testing.T) {
defaultRolePermissions := e.Permissions.SaveDefaultRolePermissions()
defer func() {
e.Permissions.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
// Tweak the permissions so that the user is no longer channel admin
e.Permissions.RemovePermissionFromRole(model.PermissionReadChannel.Id, model.ChannelUserRoleId)
// Attempt to list the actions
_, err := e.PlaybooksClient.Actions.List(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionListOptions{})
// Verify that the API fails with a 403 error
requireErrorWithStatusCode(t, err, http.StatusForbidden)
})
}
func TestActionUpdate(t *testing.T) {
e := Setup(t)
e.CreateBasic()
// Create a valid action
action := client.GenericChannelAction{
GenericChannelActionWithoutPayload: client.GenericChannelActionWithoutPayload{
ChannelID: e.BasicPublicChannel.Id,
Enabled: true,
ActionType: client.ActionTypeWelcomeMessage,
TriggerType: client.TriggerTypeNewMemberJoins,
},
Payload: client.WelcomeMessagePayload{
Message: "msg",
},
}
id, err := e.PlaybooksClient.Actions.Create(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionCreateOptions{
ChannelID: e.BasicPublicChannel.Id,
Enabled: action.Enabled,
ActionType: action.ActionType,
TriggerType: action.TriggerType,
Payload: action.Payload,
})
assert.NoError(t, err)
assert.NotEmpty(t, id)
action.ID = id
t.Run("valid update", func(t *testing.T) {
// Make a valid modification
action.Enabled = false
// Make the Update request
err := e.PlaybooksClient.Actions.Update(context.Background(), action)
// Verify that the API succeeds
assert.NoError(t, err)
})
t.Run("valid update - remove keywords from action", func(t *testing.T) {
payload := client.PromptRunPlaybookFromKeywordsPayload{
Keywords: []string{"one"},
PlaybookID: e.BasicPlaybook.ID,
}
newAction := client.GenericChannelAction{
GenericChannelActionWithoutPayload: client.GenericChannelActionWithoutPayload{
ChannelID: e.BasicPublicChannel.Id,
Enabled: true,
ActionType: client.ActionTypePromptRunPlaybook,
TriggerType: client.TriggerTypeKeywordsPosted,
},
Payload: payload,
}
// Create an action with keywords and playbook ID
actionID, err := e.PlaybooksClient.Actions.Create(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionCreateOptions{
ChannelID: newAction.ChannelID,
Enabled: newAction.Enabled,
ActionType: newAction.ActionType,
TriggerType: newAction.TriggerType,
Payload: newAction.Payload,
})
newAction.ID = actionID
// Verify that the API succeeds
assert.NoError(t, err)
assert.NotEmpty(t, actionID)
// Retrieve the newly created action and decode its payload
actions, err := e.PlaybooksClient.Actions.List(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionListOptions{
TriggerType: client.TriggerTypeKeywordsPosted,
ActionType: client.ActionTypePromptRunPlaybook,
})
assert.NoError(t, err)
assert.Len(t, actions, 1)
fetchedAction := actions[0]
var fetchedPayload client.PromptRunPlaybookFromKeywordsPayload
err = mapstructure.Decode(fetchedAction.Payload, &fetchedPayload)
assert.NoError(t, err)
// Verify that the payload of the created action has one keyword
assert.Len(t, fetchedPayload.Keywords, 1)
// Remove the keywords from the payload in the action
payload.Keywords = []string{}
newAction.Payload = payload
// Make the Update request with the new action
err = e.PlaybooksClient.Actions.Update(context.Background(), newAction)
// Verify that the API succeeds
assert.NoError(t, err)
// Retrieve the updated action and decode its payload
updatedActions, err := e.PlaybooksClient.Actions.List(context.Background(), e.BasicPublicChannel.Id, client.ChannelActionListOptions{
TriggerType: client.TriggerTypeKeywordsPosted,
ActionType: client.ActionTypePromptRunPlaybook,
})
assert.NoError(t, err)
assert.Len(t, updatedActions, 1)
updatedAction := updatedActions[0]
var updatedPayload client.PromptRunPlaybookFromKeywordsPayload
err = mapstructure.Decode(updatedAction.Payload, &updatedPayload)
assert.NoError(t, err)
// Verify that the payload of the updated action has no keywords
assert.Len(t, updatedPayload.Keywords, 0)
})
t.Run("invalid update - wrong action type", func(t *testing.T) {
// Make an invalid modification
action.ActionType = "wrong"
// Make the Update request
err := e.PlaybooksClient.Actions.Update(context.Background(), action)
// Verify that the API fails with a 400 error
requireErrorWithStatusCode(t, err, http.StatusBadRequest)
})
t.Run("invalid update - wrong trigger type", func(t *testing.T) {
// Make an invalid modification
action.TriggerType = "wrong"
// Make the Update request
err := e.PlaybooksClient.Actions.Update(context.Background(), action)
// Verify that the API fails with a 400 error
requireErrorWithStatusCode(t, err, http.StatusBadRequest)
})
t.Run("invalid update - wrong payload type", func(t *testing.T) {
// Make an invalid modification
action.Payload = client.WelcomeMessagePayload{Message: ""}
// Make the Update request
err := e.PlaybooksClient.Actions.Update(context.Background(), action)
// Verify that the API fails with a 400 error
requireErrorWithStatusCode(t, err, http.StatusBadRequest)
})
t.Run("update action forbidden - not channel admin", func(t *testing.T) {
defaultRolePermissions := e.Permissions.SaveDefaultRolePermissions()
defer func() {
e.Permissions.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
// Tweak the permissions so that the user is no longer channel admin
e.Permissions.RemovePermissionFromRole(model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
// Make a valid modification
action.Enabled = false
// Make the Update request
err := e.PlaybooksClient.Actions.Update(context.Background(), action)
// Verify that the API fails with a 403 error
requireErrorWithStatusCode(t, err, http.StatusForbidden)
})
}

View File

@ -1,54 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package server
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
)
func TestTrialLicences(t *testing.T) {
// This test is flaky due to upstream connectivity issues.
t.Skip()
e := Setup(t)
e.CreateBasic()
t.Run("request trial license without permissions", func(t *testing.T) {
dialogRequest := model.PostActionIntegrationRequest{
UserId: e.RegularUser.Id,
PostId: e.BasicPublicChannelPost.Id,
Context: map[string]interface{}{
"users": 10,
"termsAccepted": true,
"receiveEmailsAccepted": true,
},
}
dialogRequestBytes, _ := json.Marshal(dialogRequest)
resp, err := e.ServerClient.DoAPIRequestBytes(context.Background(), "POST", e.ServerClient.URL+"/plugins/"+"playbooks"+"/api/v0/bot/notify-admins/button-start-trial", dialogRequestBytes, "")
assert.Error(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("request trial license with permissions", func(t *testing.T) {
dialogRequest := model.PostActionIntegrationRequest{
UserId: e.AdminUser.Id,
PostId: e.BasicPublicChannelPost.Id,
Context: map[string]interface{}{
"users": 10,
"termsAccepted": true,
"receiveEmailsAccepted": true,
},
}
dialogRequestBytes, _ := json.Marshal(dialogRequest)
resp, err := e.ServerAdminClient.DoAPIRequestBytes(context.Background(), "POST", e.ServerClient.URL+"/plugins/"+"playbooks"+"/api/v0/bot/notify-admins/button-start-trial", dialogRequestBytes, "")
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}

View File

@ -1,23 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package server
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAPI(t *testing.T) {
e := Setup(t)
e.CreateClients()
t.Run("404", func(t *testing.T) {
resp, err := e.ServerClient.DoAPIRequestBytes(context.Background(), "POST", e.ServerClient.URL+"/plugins/"+"playbooks"+"/api/v0/nothing", nil, "")
assert.Error(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}

View File

@ -1,691 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package server
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
"github.com/graph-gophers/graphql-go"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
"github.com/mattermost/mattermost/server/v8/playbooks/server/api"
"github.com/mattermost/mattermost/server/v8/playbooks/server/app"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGraphQLPlaybooks(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("basic get", func(t *testing.T) {
var pbResultTest struct {
Data struct {
Playbook struct {
ID string
Title string
}
}
}
testPlaybookQuery := `
query Playbook($id: String!) {
playbook(id: $id) {
id
title
}
}
`
err := e.PlaybooksAdminClient.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testPlaybookQuery,
OperationName: "Playbook",
Variables: map[string]interface{}{"id": e.BasicPlaybook.ID},
}, &pbResultTest)
require.NoError(t, err)
assert.Equal(t, e.BasicPlaybook.ID, pbResultTest.Data.Playbook.ID)
assert.Equal(t, e.BasicPlaybook.Title, pbResultTest.Data.Playbook.Title)
})
t.Run("list", func(t *testing.T) {
var pbResultTest struct {
Data struct {
Playbooks []struct {
ID string
Title string
}
}
}
testPlaybookQuery := `
query Playbooks {
playbooks {
id
title
}
}
`
err := e.PlaybooksAdminClient.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testPlaybookQuery,
OperationName: "Playbooks",
}, &pbResultTest)
require.NoError(t, err)
assert.Len(t, pbResultTest.Data.Playbooks, 3)
})
t.Run("playbook mutate", func(t *testing.T) {
newUpdatedTitle := "graphqlmutatetitle"
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{"title": newUpdatedTitle})
require.NoError(t, err)
updatedPlaybook, err := e.PlaybooksAdminClient.Playbooks.Get(context.Background(), e.BasicPlaybook.ID)
require.NoError(t, err)
require.Equal(t, newUpdatedTitle, updatedPlaybook.Title)
})
t.Run("update playbook no permissions to broadcast", func(t *testing.T) {
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{"broadcastChannelIDs": []string{e.BasicPrivateChannel.Id}})
require.Error(t, err)
})
t.Run("update playbook without modifying broadcast channel ids without permission. should succeed because no modification.", func(t *testing.T) {
e.BasicPlaybook.BroadcastChannelIDs = []string{e.BasicPrivateChannel.Id}
err := e.PlaybooksAdminClient.Playbooks.Update(context.Background(), *e.BasicPlaybook)
require.NoError(t, err)
err = gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{"description": "unrelatedupdate"})
require.NoError(t, err)
})
t.Run("update playbook with too many webhoooks", func(t *testing.T) {
urls := []string{}
for i := 0; i < 65; i++ {
urls = append(urls, "http://localhost/"+strconv.Itoa(i))
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"webhookOnCreationEnabled": true,
"webhookOnCreationURLs": urls,
})
require.Error(t, err)
})
t.Run("change default owner", func(t *testing.T) {
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"defaultOwnerID": e.RegularUser.Id,
})
require.NoError(t, err)
err = gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"defaultOwnerID": e.RegularUserNotInTeam.Id,
})
require.Error(t, err)
})
t.Run("checklist with preset values that need to be cleared", func(t *testing.T) {
items := []map[string]interface{}{
{
"title": "title1",
"description": "description1",
"assigneeID": "",
"assigneeModified": 101,
"state": "Closed",
"stateModified": 102,
"command": "",
"commandLastRun": 103,
"lastSkipped": 104,
"dueDate": 100,
},
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": map[string]interface{}{
"title": "A",
"items": items,
},
})
require.NoError(t, err)
actual := []client.Checklist{
{
Title: "A",
Items: []client.ChecklistItem{
{
Title: "title1",
Description: "description1",
AssigneeID: "",
AssigneeModified: 0,
State: "",
StateModified: 0,
Command: "",
CommandLastRun: 0,
LastSkipped: 0,
DueDate: 100,
},
},
},
}
updatedPlaybook, err := e.PlaybooksAdminClient.Playbooks.Get(context.Background(), e.BasicPlaybook.ID)
require.NoError(t, err)
require.Equal(t, updatedPlaybook.Checklists, actual)
})
t.Run("update playbook with pre-assigned task, valid invite user list, and invitations enabled", func(t *testing.T) {
items := []map[string]interface{}{
{
"title": "title1",
"description": "description1",
"assigneeID": e.RegularUser.Id,
"assigneeModified": 0,
"state": "",
"stateModified": 0,
"command": "",
"commandLastRun": 0,
"lastSkipped": 0,
"dueDate": 0,
},
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": map[string]interface{}{
"title": "A",
"items": items,
},
"invitedUserIDs": []string{e.RegularUser.Id},
"inviteUsersEnabled": true,
})
require.NoError(t, err)
})
}
func TestGraphQLUpdatePlaybookFails(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("update playbook fails because size constraints.", func(t *testing.T) {
e.BasicPlaybook.BroadcastChannelIDs = []string{e.BasicPrivateChannel.Id}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": []api.UpdateChecklist{
{
Title: strings.Repeat("A", (256*1024)+1),
Items: []api.UpdateChecklistItem{},
},
},
})
require.Error(t, err)
err = gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{"title": strings.Repeat("A", 1025)})
require.Error(t, err)
err = gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{"description": strings.Repeat("A", 4097)})
require.Error(t, err)
})
t.Run("update playbook with pre-assigned task fails due to disabled invitations", func(t *testing.T) {
items := []map[string]interface{}{
{
"title": "title1",
"description": "description1",
"assigneeID": e.RegularUser.Id,
"assigneeModified": 0,
"state": "",
"stateModified": 0,
"command": "",
"commandLastRun": 0,
"lastSkipped": 0,
"dueDate": 0,
},
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": map[string]interface{}{
"title": "A",
"items": items,
},
"invitedUserIDs": []string{e.RegularUser.Id},
})
require.Error(t, err)
})
t.Run("update playbook with pre-assigned task fails due to missing assignee in existing invite user list", func(t *testing.T) {
items := []map[string]interface{}{
{
"title": "title1",
"description": "description1",
"assigneeID": e.RegularUser.Id,
"assigneeModified": 0,
"state": "",
"stateModified": 0,
"command": "",
"commandLastRun": 0,
"lastSkipped": 0,
"dueDate": 0,
},
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": map[string]interface{}{
"title": "A",
"items": items,
},
"inviteUsersEnabled": true,
})
require.Error(t, err)
})
t.Run("update playbook with pre-assigned task fails due to assignee missing in new invite user list", func(t *testing.T) {
items := []map[string]interface{}{
{
"title": "title1",
"description": "description1",
"assigneeID": e.RegularUser.Id,
"assigneeModified": 0,
"state": "",
"stateModified": 0,
"command": "",
"commandLastRun": 0,
"lastSkipped": 0,
"dueDate": 0,
},
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": map[string]interface{}{
"title": "A",
"items": items,
},
"invitedUserIDs": []string{e.RegularUser2.Id},
"inviteUsersEnabled": true,
})
require.Error(t, err)
})
t.Run("update playbook with invite user list fails due to missing a pre-assignee", func(t *testing.T) {
items := []map[string]interface{}{
{
"title": "title1",
"description": "description1",
"assigneeID": e.RegularUser.Id,
"assigneeModified": 0,
"state": "",
"stateModified": 0,
"command": "",
"commandLastRun": 0,
"lastSkipped": 0,
"dueDate": 0,
},
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": map[string]interface{}{
"title": "A",
"items": items,
},
"invitedUserIDs": []string{e.RegularUser.Id},
"inviteUsersEnabled": true,
})
require.NoError(t, err)
err = gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"invitedUserIDs": []string{e.RegularUser2.Id},
})
require.Error(t, err)
})
t.Run("update playbook fails if invitations are getting disabled but there are pre-assigned users", func(t *testing.T) {
items := []map[string]interface{}{
{
"title": "title1",
"description": "description1",
"assigneeID": e.RegularUser.Id,
"assigneeModified": 0,
"state": "",
"stateModified": 0,
"command": "",
"commandLastRun": 0,
"lastSkipped": 0,
"dueDate": 0,
},
}
err := gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"checklists": map[string]interface{}{
"title": "A",
"items": items,
},
"invitedUserIDs": []string{e.RegularUser.Id},
"inviteUsersEnabled": true,
})
require.NoError(t, err)
err = gqlTestPlaybookUpdate(e, t, e.BasicPlaybook.ID, map[string]interface{}{
"inviteUsersEnabled": false,
})
require.Error(t, err)
})
}
func TestUpdatePlaybookFavorite(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("favorite", func(t *testing.T) {
isFavorite, err := getPlaybookFavorite(e.PlaybooksClient, e.BasicPlaybook.ID)
require.NoError(t, err)
require.False(t, isFavorite)
response, err := updatePlaybookFavorite(e.PlaybooksClient, e.BasicPlaybook.ID, true)
require.Empty(t, response.Errors)
require.NoError(t, err)
isFavorite, err = getPlaybookFavorite(e.PlaybooksClient, e.BasicPlaybook.ID)
require.NoError(t, err)
require.True(t, isFavorite)
})
t.Run("unfavorite", func(t *testing.T) {
response, err := updatePlaybookFavorite(e.PlaybooksClient, e.BasicPlaybook.ID, false)
require.Empty(t, response.Errors)
require.NoError(t, err)
isFavorite, err := getPlaybookFavorite(e.PlaybooksClient, e.BasicPlaybook.ID)
require.NoError(t, err)
require.False(t, isFavorite)
})
t.Run("favorite playbook with read access", func(t *testing.T) {
response, err := updatePlaybookFavorite(e.PlaybooksClient2, e.BasicPlaybook.ID, true)
require.Empty(t, response.Errors)
require.NoError(t, err)
isFavorite, err := getPlaybookFavorite(e.PlaybooksClient2, e.BasicPlaybook.ID)
require.NoError(t, err)
require.True(t, isFavorite)
})
t.Run("favorite private playbook no access", func(t *testing.T) {
response, _ := updatePlaybookFavorite(e.PlaybooksClient, e.PrivatePlaybookNoMembers.ID, false)
require.NotEmpty(t, response.Errors)
})
}
func updatePlaybookFavorite(c *client.Client, playbookID string, favorite bool) (graphql.Response, error) {
mutation := `mutation UpdatePlaybookFavorite($id: String!, $favorite: Boolean!) {
updatePlaybookFavorite(id: $id, favorite: $favorite)
}
`
var response graphql.Response
err := c.DoGraphql(context.Background(), &client.GraphQLInput{
Query: mutation,
OperationName: "UpdatePlaybookFavorite",
Variables: map[string]interface{}{
"id": playbookID,
"favorite": favorite,
},
}, &response)
return response, err
}
func getPlaybookFavorite(c *client.Client, playbookID string) (bool, error) {
query := `
query GetPlaybookFavorite($id: String!) {
playbook(id: $id) {
isFavorite
}
}
`
var response graphql.Response
err := c.DoGraphql(context.Background(), &client.GraphQLInput{
Query: query,
OperationName: "GetPlaybookFavorite",
Variables: map[string]interface{}{
"id": playbookID,
},
}, &response)
if err != nil {
return false, err
}
if len(response.Errors) > 0 {
return false, fmt.Errorf("error from query %v", response.Errors)
}
favoriteResponse := struct {
Playbook struct {
IsFavorite bool `json:"isFavorite"`
} `json:"playbook"`
}{}
err = json.Unmarshal(response.Data, &favoriteResponse)
if err != nil {
return false, err
}
return favoriteResponse.Playbook.IsFavorite, nil
}
func gqlTestPlaybookUpdate(e *TestEnvironment, t *testing.T, playbookID string, updates map[string]interface{}) error {
testPlaybookMutateQuery := `
mutation UpdatePlaybook($id: String!, $updates: PlaybookUpdates!) {
updatePlaybook(id: $id, updates: $updates)
}
`
var response graphql.Response
err := e.PlaybooksClient.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testPlaybookMutateQuery,
OperationName: "UpdatePlaybook",
Variables: map[string]interface{}{"id": playbookID, "updates": updates},
}, &response)
if err != nil {
return errors.Wrapf(err, "gqlTestPlaybookUpdate graphql failure")
}
if len(response.Errors) != 0 {
return errors.Errorf("gqlTestPlaybookUpdate graphql failure %+v", response.Errors)
}
return err
}
func TestGraphQLPlaybooksMetrics(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("metrics get", func(t *testing.T) {
var pbResultTest struct {
Data struct {
Playbook struct {
ID string
Title string
Metrics []client.PlaybookMetricConfig
}
}
}
testPlaybookQuery :=
`
query Playbook($id: String!) {
playbook(id: $id) {
id
metrics {
id
title
description
type
target
}
}
}
`
err := e.PlaybooksAdminClient.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testPlaybookQuery,
OperationName: "Playbook",
Variables: map[string]interface{}{"id": e.BasicPlaybook.ID},
}, &pbResultTest)
require.NoError(t, err)
require.Len(t, pbResultTest.Data.Playbook.Metrics, len(e.BasicPlaybook.Metrics))
require.Equal(t, e.BasicPlaybook.Metrics[0].Title, pbResultTest.Data.Playbook.Metrics[0].Title)
require.Equal(t, e.BasicPlaybook.Metrics[0].Type, pbResultTest.Data.Playbook.Metrics[0].Type)
require.Equal(t, e.BasicPlaybook.Metrics[0].Target, pbResultTest.Data.Playbook.Metrics[0].Target)
})
t.Run("add metric", func(t *testing.T) {
testAddMetricQuery := `
mutation AddMetric($playbookID: String!, $title: String!, $description: String!, $type: String!, $target: Int) {
addMetric(playbookID: $playbookID, title: $title, description: $description, type: $type, target: $target)
}
`
var response graphql.Response
err := e.PlaybooksClient.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testAddMetricQuery,
OperationName: "AddMetric",
Variables: map[string]interface{}{
"playbookID": e.BasicPlaybook.ID,
"title": "New Metric",
"description": "the description",
"type": app.MetricTypeDuration,
},
}, &response)
require.NoError(t, err)
require.Empty(t, response.Errors)
updatedPlaybook, err := e.PlaybooksAdminClient.Playbooks.Get(context.Background(), e.BasicPlaybook.ID)
require.NoError(t, err)
require.Len(t, updatedPlaybook.Metrics, 2)
assert.Equal(t, updatedPlaybook.Metrics[1].Title, "New Metric")
})
t.Run("update metric", func(t *testing.T) {
testUpdateMetricQuery := `
mutation UpdateMetric($id: String!, $title: String, $description: String, $target: Int) {
updateMetric(id: $id, title: $title, description: $description, target: $target)
}
`
var response graphql.Response
err := e.PlaybooksClient.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testUpdateMetricQuery,
OperationName: "UpdateMetric",
Variables: map[string]interface{}{
"id": e.BasicPlaybook.Metrics[0].ID,
"title": "Updated Title",
},
}, &response)
require.NoError(t, err)
require.Empty(t, response.Errors)
updatedPlaybook, err := e.PlaybooksAdminClient.Playbooks.Get(context.Background(), e.BasicPlaybook.ID)
require.NoError(t, err)
require.Len(t, updatedPlaybook.Metrics, 2)
assert.Equal(t, "Updated Title", updatedPlaybook.Metrics[0].Title)
})
t.Run("delete metric", func(t *testing.T) {
testDeleteMetricQuery := `
mutation DeleteMetric($id: String!) {
deleteMetric(id: $id)
}
`
var response graphql.Response
err := e.PlaybooksClient.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testDeleteMetricQuery,
OperationName: "DeleteMetric",
Variables: map[string]interface{}{
"id": e.BasicPlaybook.Metrics[0].ID,
},
}, &response)
require.NoError(t, err)
require.Empty(t, response.Errors)
updatedPlaybook, err := e.PlaybooksAdminClient.Playbooks.Get(context.Background(), e.BasicPlaybook.ID)
require.NoError(t, err)
require.Len(t, updatedPlaybook.Metrics, 1)
})
}
func gqlTestPlaybookUpdateGuest(e *TestEnvironment, t *testing.T, playbookID string, updates map[string]interface{}) error {
testPlaybookMutateQuery := `
mutation UpdatePlaybook($id: String!, $updates: PlaybookUpdates!) {
updatePlaybook(id: $id, updates: $updates)
}
`
var response graphql.Response
err := e.PlaybooksClientGuest.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testPlaybookMutateQuery,
OperationName: "UpdatePlaybook",
Variables: map[string]interface{}{"id": playbookID, "updates": updates},
}, &response)
if err != nil {
return errors.Wrapf(err, "gqlTestPlaybookUpdate graphql failure")
}
if len(response.Errors) != 0 {
return errors.Errorf("gqlTestPlaybookUpdate graphql failure %+v", response.Errors)
}
return err
}
func TestGraphQLPlaybooksGuests(t *testing.T) {
e := Setup(t)
e.SetE20Licence()
e.CreateBasic()
e.CreateGuest()
t.Run("update playbook guest not member", func(t *testing.T) {
err := gqlTestPlaybookUpdateGuest(e, t, e.BasicPlaybook.ID, map[string]interface{}{"title": "mutated"})
require.Error(t, err)
})
t.Run("basic get guest not member", func(t *testing.T) {
testPlaybookQuery := `
query Playbook($id: String!) {
playbook(id: $id) {
id
title
}
}
`
var response graphql.Response
err := e.PlaybooksClientGuest.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testPlaybookQuery,
OperationName: "Playbook",
Variables: map[string]interface{}{"id": e.BasicPlaybook.ID},
}, &response)
require.NoError(t, err)
require.NotZero(t, len(response.Errors))
})
t.Run("list guest", func(t *testing.T) {
var pbResultTest struct {
Data struct {
Playbooks []struct {
ID string
Title string
}
}
}
testPlaybookQuery := `
query Playbooks {
playbooks {
id
title
}
}
`
err := e.PlaybooksClientGuest.DoGraphql(context.Background(), &client.GraphQLInput{
Query: testPlaybookQuery,
OperationName: "Playbooks",
}, &pbResultTest)
require.NoError(t, err)
assert.Len(t, pbResultTest.Data.Playbooks, 0)
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package server
import (
"context"
"net/http"
"testing"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSettings(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("get settings", func(t *testing.T) {
t.Run("unauthenticated", func(t *testing.T) {
settings, err := e.UnauthenticatedPlaybooksClient.Settings.Get(context.Background())
assert.Nil(t, settings)
requireErrorWithStatusCode(t, err, http.StatusUnauthorized)
})
t.Run("get some settings", func(t *testing.T) {
defaultSettings := &client.GlobalSettings{
EnableExperimentalFeatures: false,
}
settings, err := e.PlaybooksClient.Settings.Get(context.Background())
require.NoError(t, err)
assert.Equal(t, defaultSettings, settings)
})
})
}

View File

@ -1,234 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package server
import (
"context"
"fmt"
"net/http"
"testing"
"github.com/mattermost/mattermost/server/v8/playbooks/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/guregu/null.v4"
)
func TestGetSiteStats(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("get sites stats", func(t *testing.T) {
t.Run("unauthenticated", func(t *testing.T) {
stats, err := e.UnauthenticatedPlaybooksClient.Stats.GetSiteStats(context.Background())
assert.Nil(t, stats)
requireErrorWithStatusCode(t, err, http.StatusUnauthorized)
})
t.Run("get stats for basic server", func(t *testing.T) {
stats, err := e.PlaybooksAdminClient.Stats.GetSiteStats(context.Background())
require.NoError(t, err)
assert.NotEmpty(t, stats)
assert.Equal(t, 4, stats.TotalPlaybooks)
assert.Equal(t, 1, stats.TotalPlaybookRuns)
})
t.Run("add extra playbooks/runs and get stats again", func(t *testing.T) {
e.CreateBasicPlaybook()
e.CreateBasicRun()
e.CreateBasicRun()
stats, err := e.PlaybooksAdminClient.Stats.GetSiteStats(context.Background())
require.NoError(t, err)
assert.NotEmpty(t, stats)
assert.Equal(t, 6, stats.TotalPlaybooks)
assert.Equal(t, 3, stats.TotalPlaybookRuns)
})
})
}
func TestPlaybookKeyMetricsStats(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("3 runs with published metrics, 2 runs without publishing", func(t *testing.T) {
playbookID, err := e.PlaybooksAdminClient.Playbooks.Create(context.Background(), client.PlaybookCreateOptions{
Title: "pb1",
TeamID: e.BasicTeam.Id,
Public: true,
Metrics: createMetricsConfigs([]string{client.MetricTypeCurrency, client.MetricTypeDuration}),
})
require.NoError(e.T, err)
pb, err := e.PlaybooksClient.Playbooks.Get(context.Background(), playbookID)
require.NoError(e.T, err)
metricsData := createMetricsData(pb.Metrics, [][]int64{{12312, 9123}, {653, 7262}, {322, 76575}})
// create runs and publish metrics data
createRunsWithMetrics(t, e, playbookID, metricsData, true)
// create runs, set metrics data, but do not publish
createRunsWithMetrics(t, e, playbookID, metricsData[1:], false)
stats, err := e.PlaybooksClient.Playbooks.Stats(context.Background(), playbookID)
require.NoError(t, err)
require.Equal(t, stats.MetricOverallAverage, intsToNullInts([]int64{4429, 30986}))
require.Equal(t, stats.MetricRollingAverage, intsToNullInts([]int64{4429, 30986}))
require.Equal(t, stats.MetricRollingAverageChange, []null.Int{null.NewInt(0, false), null.NewInt(0, false)})
require.Equal(t, stats.MetricRollingValues, [][]int64{{322, 653, 12312}, {76575, 7262, 9123}})
require.Equal(t, stats.MetricValueRange, [][]int64{{322, 12312}, {7262, 76575}})
})
t.Run("13 runs with published metrics, 7 runs without publishing", func(t *testing.T) {
playbookID, err := e.PlaybooksAdminClient.Playbooks.Create(context.Background(), client.PlaybookCreateOptions{
Title: "pb2",
TeamID: e.BasicTeam.Id,
Public: true,
Metrics: createMetricsConfigs([]string{client.MetricTypeCurrency, client.MetricTypeInteger, client.MetricTypeDuration}),
})
require.NoError(e.T, err)
pb, err := e.PlaybooksClient.Playbooks.Get(context.Background(), playbookID)
require.NoError(e.T, err)
data := make([][]int64, 15)
for i := range data {
data[i] = []int64{100 + int64(i), 2000000 + int64(i), 3000000000 + int64(i)}
}
metricsData := createMetricsData(pb.Metrics, data)
createRunsWithMetrics(t, e, playbookID, metricsData, true)
createRunsWithMetrics(t, e, playbookID, metricsData[8:], false)
stats, err := e.PlaybooksClient.Playbooks.Stats(context.Background(), playbookID)
require.NoError(t, err)
require.Equal(t, stats.MetricOverallAverage, intsToNullInts([]int64{107, 2000007, 3000000007}))
require.Equal(t, stats.MetricRollingAverage, intsToNullInts([]int64{109, 2000009, 3000000009})) // last 10 runs average
require.Equal(t, stats.MetricRollingAverageChange, intsToNullInts([]int64{6, 0, 0}))
require.Equal(t, stats.MetricRollingValues,
[][]int64{
{114, 113, 112, 111, 110, 109, 108, 107, 106, 105},
{2000014, 2000013, 2000012, 2000011, 2000010, 2000009, 2000008, 2000007, 2000006, 2000005},
{3000000014, 3000000013, 3000000012, 3000000011, 3000000010, 3000000009, 3000000008, 3000000007, 3000000006, 3000000005},
})
require.Equal(t, stats.MetricValueRange, [][]int64{{100, 114}, {2000000, 2000014}, {3000000000, 3000000014}})
})
t.Run("23 runs with published metrics", func(t *testing.T) {
playbookID, err := e.PlaybooksAdminClient.Playbooks.Create(context.Background(), client.PlaybookCreateOptions{
Title: "pb3",
TeamID: e.BasicTeam.Id,
Public: true,
Metrics: createMetricsConfigs([]string{client.MetricTypeCurrency}),
})
require.NoError(e.T, err)
pb, err := e.PlaybooksClient.Playbooks.Get(context.Background(), playbookID)
require.NoError(e.T, err)
data := make([][]int64, 23)
for i := range data {
data[i] = []int64{10 + int64(i)} //11, 12, 13 ... 32
}
metricsData := createMetricsData(pb.Metrics, data)
createRunsWithMetrics(t, e, playbookID, metricsData, true)
stats, err := e.PlaybooksClient.Playbooks.Stats(context.Background(), playbookID)
require.NoError(t, err)
require.Equal(t, stats.MetricOverallAverage, intsToNullInts([]int64{21}))
require.Equal(t, stats.MetricRollingAverage, intsToNullInts([]int64{27})) // last 10 runs average
require.Equal(t, stats.MetricRollingAverageChange, intsToNullInts([]int64{58}))
require.Equal(t, stats.MetricRollingValues, [][]int64{{32, 31, 30, 29, 28, 27, 26, 25, 24, 23}})
require.Equal(t, stats.MetricValueRange, [][]int64{{10, 32}})
})
t.Run("publish runs with metrics, then add additional metric to the playbook", func(t *testing.T) {
playbookID, err := e.PlaybooksAdminClient.Playbooks.Create(context.Background(), client.PlaybookCreateOptions{
Title: "pb4",
TeamID: e.BasicTeam.Id,
Public: true,
Metrics: createMetricsConfigs([]string{client.MetricTypeCurrency}),
})
require.NoError(e.T, err)
pb, err := e.PlaybooksClient.Playbooks.Get(context.Background(), playbookID)
require.NoError(e.T, err)
metricsData := createMetricsData(pb.Metrics, [][]int64{{2}, {1}, {2}, {7}, {3}, {5}, {1}, {7}, {2}, {3}, {5}, {6}, {7}, {1}})
createRunsWithMetrics(t, e, playbookID, metricsData, true)
// add a metric to the playbook at first position
pb.Metrics = append(pb.Metrics, pb.Metrics[0])
pb.Metrics[0] = client.PlaybookMetricConfig{
Title: "metric2",
Type: client.MetricTypeInteger,
}
err = e.PlaybooksClient.Playbooks.Update(context.Background(), *pb)
require.NoError(e.T, err)
stats, err := e.PlaybooksClient.Playbooks.Stats(context.Background(), playbookID)
require.NoError(t, err)
require.Equal(t, stats.MetricOverallAverage, []null.Int{null.NewInt(0, false), null.IntFrom(3)})
require.Equal(t, stats.MetricRollingAverage, []null.Int{null.NewInt(0, false), null.IntFrom(4)}) // last 10 runs average
require.Equal(t, stats.MetricRollingAverageChange, []null.Int{null.NewInt(0, false), null.IntFrom(33)})
require.Equal(t, stats.MetricRollingValues, [][]int64{nil, {1, 7, 6, 5, 3, 2, 7, 1, 5, 3}})
require.Equal(t, stats.MetricValueRange, [][]int64{nil, {1, 7}})
})
}
func createRunsWithMetrics(t *testing.T, e *TestEnvironment, playbookID string, metricsData [][]client.RunMetricData, publish bool) {
for i, md := range metricsData {
run, err := e.PlaybooksClient.PlaybookRuns.Create(context.Background(), client.PlaybookRunCreateOptions{
Name: fmt.Sprint("run", i),
OwnerUserID: e.RegularUser.Id,
TeamID: e.BasicTeam.Id,
PlaybookID: playbookID,
})
assert.NoError(t, err)
assert.NotNil(t, run)
retrospective := client.RetrospectiveUpdate{
Text: fmt.Sprint("retro text", i),
Metrics: md,
}
//publish or save retro info
if publish {
err = e.PlaybooksClient.PlaybookRuns.PublishRetrospective(context.Background(), run.ID, e.RegularUser.Id, retrospective)
} else {
err = e.PlaybooksClient.PlaybookRuns.UpdateRetrospective(context.Background(), run.ID, e.RegularUser.Id, retrospective)
}
assert.NoError(t, err)
}
}
func createMetricsData(metricsConfigs []client.PlaybookMetricConfig, data [][]int64) [][]client.RunMetricData {
metricsData := make([][]client.RunMetricData, len(data))
for i, d := range data {
md := make([]client.RunMetricData, len(metricsConfigs))
for j, c := range metricsConfigs {
md[j] = client.RunMetricData{MetricConfigID: c.ID, Value: null.IntFrom(d[j])}
}
metricsData[i] = md
}
return metricsData
}
func createMetricsConfigs(types []string) []client.PlaybookMetricConfig {
configs := make([]client.PlaybookMetricConfig, len(types))
for i, t := range types {
configs[i] = client.PlaybookMetricConfig{
Title: fmt.Sprint("metric", i),
Type: t,
}
}
return configs
}
func intsToNullInts(nums []int64) []null.Int {
res := make([]null.Int, len(nums))
for i := range nums {
res[i] = null.IntFrom(nums[i])
}
return res
}

View File

@ -1,40 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package server
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestCreateEvent(t *testing.T) {
e := Setup(t)
e.CreateBasic()
t.Run("create an event with bad type fails", func(t *testing.T) {
err := e.PlaybooksClient.Telemetry.CreateEvent(context.Background(), "run_status_update", "bad_type", nil)
require.Error(t, err)
})
t.Run("create an event with bad name fails", func(t *testing.T) {
err := e.PlaybooksClient.Telemetry.CreateEvent(context.Background(), "bad_name", "page", nil)
require.Error(t, err)
})
t.Run("create an event correctly with no extra data", func(t *testing.T) {
err := e.PlaybooksClient.Telemetry.CreateEvent(context.Background(), "run_status_update", "page", nil)
require.NoError(t, err)
})
t.Run("create an event correctly with extra data", func(t *testing.T) {
extra := map[string]interface{}{
"foo": "bar",
"baz": 5,
}
err := e.PlaybooksClient.Telemetry.CreateEvent(context.Background(), "run_status_update", "page", extra)
require.NoError(t, err)
})
}

View File

@ -1,128 +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/public/model"
type GenericChannelActionWithoutPayload struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Enabled bool `json:"enabled"`
DeleteAt int64 `json:"delete_at"`
ActionType ActionType `json:"action_type"`
TriggerType TriggerType `json:"trigger_type"`
}
type GenericChannelAction struct {
GenericChannelActionWithoutPayload
Payload interface{} `json:"payload"`
}
type WelcomeMessagePayload struct {
Message string `json:"message" mapstructure:"message"`
}
type PromptRunPlaybookFromKeywordsPayload struct {
Keywords []string `json:"keywords" mapstructure:"keywords"`
PlaybookID string `json:"playbook_id" mapstructure:"playbook_id"`
}
type CategorizeChannelPayload struct {
CategoryName string `json:"category_name" mapstructure:"category_name"`
}
type ActionType string
type TriggerType string
const (
// Action types: add new types to the ValidTriggerTypes array below
ActionTypeWelcomeMessage ActionType = "send_welcome_message"
ActionTypePromptRunPlaybook ActionType = "prompt_run_playbook"
ActionTypeCategorizeChannel ActionType = "categorize_channel"
// Trigger types: add new types to the ValidTriggerTypes array below
TriggerTypeNewMemberJoins TriggerType = "new_member_joins"
TriggerTypeKeywordsPosted TriggerType = "keywords"
)
var ValidActionTypes = []ActionType{
ActionTypeWelcomeMessage,
ActionTypePromptRunPlaybook,
ActionTypeCategorizeChannel,
}
var ValidTriggerTypes = []TriggerType{
TriggerTypeNewMemberJoins,
TriggerTypeKeywordsPosted,
}
type GetChannelActionOptions struct {
ActionType ActionType
TriggerType TriggerType
}
type ChannelActionService interface {
// Create creates a new action
Create(action GenericChannelAction) (string, error)
// Get returns the action identified by id
Get(id string) (GenericChannelAction, error)
// GetChannelActions returns all actions in channelID,
// filtered with the options if different from its zero value
GetChannelActions(channelID string, options GetChannelActionOptions) ([]GenericChannelAction, error)
// Validate checks that the action type, trigger type and
// payload are all valid and consistent with each other
Validate(action GenericChannelAction) error
// Update updates an existing action identified by action.ID
Update(action GenericChannelAction, userID string) error
// UserHasJoinedChannel is called when userID has joined channelID. If actorID is not blank, userID
// was invited by actorID.
UserHasJoinedChannel(userID, channelID, actorID string)
// CheckAndSendMessageOnJoin checks if userID has viewed channelID and sends
// the registered welcome message action. Returns true if the message was sent.
CheckAndSendMessageOnJoin(userID, channelID string) bool
// MessageHasBeenPosted suggests playbooks to the user if triggered
MessageHasBeenPosted(post *model.Post)
}
type ChannelActionStore interface {
// Create creates a new action
Create(action GenericChannelAction) (string, error)
// Get returns the action identified by id
Get(id string) (GenericChannelAction, error)
// GetChannelActions returns all actions in channelID,
// filtered with the options if different from its zero value
GetChannelActions(channelID string, options GetChannelActionOptions) ([]GenericChannelAction, error)
// Update updates an existing action identified by action.ID
Update(action GenericChannelAction) error
// HasViewedChannel returns true if userID has viewed channelID
HasViewedChannel(userID, channelID string) bool
// SetViewedChannel records that userID has viewed channelID. NOTE: does not check if there is already a
// record of that userID/channelID (i.e., will create duplicate rows)
SetViewedChannel(userID, channelID string) error
// SetViewedChannel records that all users in userIDs have viewed channelID.
SetMultipleViewedChannel(userIDs []string, channelID string) error
}
// ChannelActionTelemetry defines the methods that the ChannelAction service needs from RudderTelemetry.
// userID is the user initiating the event.
type ChannelActionTelemetry interface {
// RunChannelAction tracks the execution of a channel action, performed by the specified user.
RunChannelAction(action GenericChannelAction, userID string)
// UpdateChannelAction tracks the update of a channel action, performed by the specified user.
UpdateChannelAction(action GenericChannelAction, userID string)
}

View File

@ -1,578 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"strings"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/bot"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type PlaybookGetter interface {
Get(id string) (Playbook, error)
}
type channelActionServiceImpl struct {
poster bot.Poster
configService config.Service
store ChannelActionStore
api playbooks.ServicesAPI
playbookGetter PlaybookGetter
keywordsThreadIgnorer KeywordsThreadIgnorer
telemetry ChannelActionTelemetry
}
func NewChannelActionsService(api playbooks.ServicesAPI, poster bot.Poster, configService config.Service, store ChannelActionStore, playbookGetter PlaybookGetter, keywordsThreadIgnorer KeywordsThreadIgnorer, telemetry ChannelActionTelemetry) ChannelActionService {
return &channelActionServiceImpl{
poster: poster,
configService: configService,
store: store,
api: api,
playbookGetter: playbookGetter,
keywordsThreadIgnorer: keywordsThreadIgnorer,
telemetry: telemetry,
}
}
// setViewedChannelForEveryMember mark channelID as viewed for all its existing members
func (a *channelActionServiceImpl) setViewedChannelForEveryMember(channelID string) error {
// TODO: this is a magic number, we should load test this function to find a
// good threshold to share the workload between the goroutines
perPage := 200
page := 0
var wg sync.WaitGroup
var goroutineErr error
for {
members, err := a.api.GetChannelMembers(channelID, page, perPage)
if err != nil {
return fmt.Errorf("unable to retrieve members of channel with ID %q", channelID)
}
if len(members) == 0 {
break
}
wg.Add(1)
go func() {
defer wg.Done()
userIDs := make([]string, 0, len(members))
for _, member := range members {
userIDs = append(userIDs, member.UserId)
}
if err := a.store.SetMultipleViewedChannel(userIDs, channelID); err != nil {
// We don't care whether multiple goroutines assign this value, as we're
// only interested in knowing if there was at least one error
goroutineErr = errors.Wrapf(err, "unable to mark channel with ID %q as viewed for users %v", channelID, userIDs)
}
}()
page++
}
wg.Wait()
return goroutineErr
}
func (a *channelActionServiceImpl) Create(action GenericChannelAction) (string, error) {
actions, err := a.store.GetChannelActions(action.ChannelID, GetChannelActionOptions{
ActionType: action.ActionType,
TriggerType: action.TriggerType,
})
if err != nil {
return "", err
}
if len(actions) > 0 {
return "", fmt.Errorf("only one action of action type %q and trigger type %q is allowed", string(action.ActionType), string(action.TriggerType))
}
if action.ActionType == ActionTypeWelcomeMessage && action.Enabled {
if err := a.setViewedChannelForEveryMember(action.ChannelID); err != nil {
return "", err
}
}
return a.store.Create(action)
}
func (a *channelActionServiceImpl) Get(id string) (GenericChannelAction, error) {
return a.store.Get(id)
}
func (a *channelActionServiceImpl) GetChannelActions(channelID string, options GetChannelActionOptions) ([]GenericChannelAction, error) {
return a.store.GetChannelActions(channelID, options)
}
func (a *channelActionServiceImpl) Validate(action GenericChannelAction) error {
// Validate the trigger type and action types
switch action.TriggerType {
case TriggerTypeNewMemberJoins:
switch action.ActionType {
case ActionTypeWelcomeMessage:
break
case ActionTypeCategorizeChannel:
break
default:
return fmt.Errorf("action type %q is not valid for trigger type %q", action.ActionType, action.TriggerType)
}
case TriggerTypeKeywordsPosted:
if action.ActionType != ActionTypePromptRunPlaybook {
return fmt.Errorf("action type %q is not valid for trigger type %q", action.ActionType, action.TriggerType)
}
default:
return fmt.Errorf("trigger type %q not recognized", action.TriggerType)
}
// Validate the payload depending on the action type
switch action.ActionType {
case ActionTypeWelcomeMessage:
var payload WelcomeMessagePayload
if err := mapstructure.Decode(action.Payload, &payload); err != nil {
return fmt.Errorf("unable to decode payload from action")
}
case ActionTypePromptRunPlaybook:
var payload PromptRunPlaybookFromKeywordsPayload
if err := mapstructure.Decode(action.Payload, &payload); err != nil {
return fmt.Errorf("unable to decode payload from action")
}
if err := checkValidPromptRunPlaybookFromKeywordsPayload(payload); err != nil {
return err
}
case ActionTypeCategorizeChannel:
var payload CategorizeChannelPayload
if err := mapstructure.Decode(action.Payload, &payload); err != nil {
return fmt.Errorf("unable to decode payload from action")
}
default:
return fmt.Errorf("action type %q not recognized", action.ActionType)
}
return nil
}
func checkValidPromptRunPlaybookFromKeywordsPayload(payload PromptRunPlaybookFromKeywordsPayload) error {
for _, keyword := range payload.Keywords {
if keyword == "" {
return fmt.Errorf("payload field 'keywords' must contain only non-empty keywords")
}
}
if payload.PlaybookID != "" && !model.IsValidId(payload.PlaybookID) {
return fmt.Errorf("payload field 'playbook_id' must be a valid ID")
}
return nil
}
func (a *channelActionServiceImpl) Update(action GenericChannelAction, userID string) error {
oldAction, err := a.Get(action.ID)
if err != nil {
return fmt.Errorf("unable to retrieve existing action with ID %q", action.ID)
}
if action.ActionType == ActionTypeWelcomeMessage && !oldAction.Enabled && action.Enabled {
if err := a.setViewedChannelForEveryMember(action.ChannelID); err != nil {
return err
}
}
if err := a.store.Update(action); err != nil {
return err
}
a.telemetry.UpdateChannelAction(action, userID)
return nil
}
// UserHasJoinedChannel is called when userID has joined channelID. If actorID is not blank, userID
// was invited by actorID.
func (a *channelActionServiceImpl) UserHasJoinedChannel(userID, channelID, actorID string) {
user, err := a.api.GetUserByID(userID)
if err != nil {
logrus.WithError(err).WithField("user_id", userID).Error("failed to resolve user")
return
}
channel, err := a.api.GetChannelByID(channelID)
if err != nil {
logrus.WithError(err).WithField("channel_id", channelID).Error("failed to resolve channel")
return
}
if user.IsBot {
return
}
actions, err := a.GetChannelActions(channelID, GetChannelActionOptions{
ActionType: ActionTypeCategorizeChannel,
TriggerType: TriggerTypeNewMemberJoins,
})
if err != nil {
logrus.WithError(err).WithField("channel_id", channelID).Error("failed to get the channel actions")
return
}
if len(actions) > 1 {
logrus.WithFields(logrus.Fields{
"action_type": ActionTypeCategorizeChannel,
"trigger_type": TriggerTypeNewMemberJoins,
"num_actions": len(actions),
}).Error("expected only one action to be retrieved")
}
if len(actions) != 1 {
return
}
action := actions[0]
if !action.Enabled {
return
}
var payload CategorizeChannelPayload
if err = mapstructure.Decode(action.Payload, &payload); err != nil {
logrus.WithError(err).Error("unable to decode payload of CategorizeChannelPayload")
return
}
if payload.CategoryName != "" {
// Update sidebar category in the go-routine not to block the UserHasJoinedChannel hook
go func() {
// Wait for 5 seconds(a magic number) for the webapp to get the `user_added` event,
// finish channel categorization and update it's state in redux.
// Currently there is no way to detect when webapp finishes the job.
// After that we can update the categories safely.
// Technically if user starts multiple runs simultaneously we will still get the race condition
// on category update. Since that's not realistic at the moment we are not adding the
// distributed lock here.
time.Sleep(5 * time.Second)
err = a.createOrUpdatePlaybookRunSidebarCategory(userID, channelID, channel.TeamId, payload.CategoryName)
if err != nil {
logrus.WithError(err).Error("failed to categorize channel")
}
a.telemetry.RunChannelAction(action, userID)
}()
}
}
// createOrUpdatePlaybookRunSidebarCategory creates or updates a "Playbook Runs" sidebar category if
// it does not already exist and adds the channel within the sidebar category
func (a *channelActionServiceImpl) createOrUpdatePlaybookRunSidebarCategory(userID, channelID, teamID, categoryName string) error {
sidebar, err := a.api.GetChannelSidebarCategories(userID, teamID)
if err != nil {
return err
}
var categoryID string
for _, category := range sidebar.Categories {
if strings.EqualFold(category.DisplayName, categoryName) {
categoryID = category.Id
if !sliceContains(category.Channels, channelID) {
category.Channels = append(category.Channels, channelID)
}
break
}
}
if categoryID == "" {
_, err = a.api.CreateChannelSidebarCategory(userID, teamID, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
UserId: userID,
TeamId: teamID,
DisplayName: categoryName,
Muted: false,
},
Channels: []string{channelID},
})
if err != nil {
return err
}
return nil
}
// remove channel from previous category
for _, category := range sidebar.Categories {
if strings.EqualFold(category.DisplayName, categoryName) {
continue
}
for i, channel := range category.Channels {
if channel == channelID {
category.Channels = append(category.Channels[:i], category.Channels[i+1:]...)
break
}
}
}
_, err = a.api.UpdateChannelSidebarCategories(userID, teamID, sidebar.Categories)
if err != nil {
return err
}
return nil
}
func sliceContains(strs []string, target string) bool {
for _, s := range strs {
if s == target {
return true
}
}
return false
}
// CheckAndSendMessageOnJoin checks if userID has viewed channelID and sends
// playbookRun.MessageOnJoin if it exists. Returns true if the message was sent.
func (a *channelActionServiceImpl) CheckAndSendMessageOnJoin(userID, channelID string) bool {
hasViewed := a.store.HasViewedChannel(userID, channelID)
if hasViewed {
return true
}
actions, err := a.store.GetChannelActions(channelID, GetChannelActionOptions{
TriggerType: TriggerTypeNewMemberJoins,
})
if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"channel_id": channelID,
"trigger_type": TriggerTypeNewMemberJoins,
}).Error("failed to resolve actions")
return false
}
if err = a.store.SetViewedChannel(userID, channelID); err != nil {
// If duplicate entry, userID has viewed channelID. If not a duplicate, assume they haven't.
return errors.Is(err, ErrDuplicateEntry)
}
// Look for the ActionTypeWelcomeMessage action
for _, action := range actions {
if action.ActionType == ActionTypeWelcomeMessage {
var payload WelcomeMessagePayload
if err := mapstructure.Decode(action.Payload, &payload); err != nil {
logrus.WithError(err).WithField("action_type", action.ActionType).Error("payload of action is not valid")
}
// Run the action
a.poster.SystemEphemeralPost(userID, channelID, &model.Post{
Message: payload.Message,
})
a.telemetry.RunChannelAction(action, userID)
}
}
return true
}
func (a *channelActionServiceImpl) MessageHasBeenPosted(post *model.Post) {
if post.IsSystemMessage() || a.keywordsThreadIgnorer.IsIgnored(post.RootId, post.UserId) || a.poster.IsFromPoster(post) {
return
}
actions, err := a.GetChannelActions(post.ChannelId, GetChannelActionOptions{
TriggerType: TriggerTypeKeywordsPosted,
ActionType: ActionTypePromptRunPlaybook,
})
if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"channel_id": post.ChannelId,
"trigger_type": TriggerTypeKeywordsPosted,
}).Error("unable to retrieve channel actions")
return
}
// Finish early if there are no actions to prompt running a playbook
if len(actions) == 0 {
return
}
triggeredPlaybooksMap := make(map[string]Playbook)
presentTriggers := []string{}
for _, action := range actions {
if !action.Enabled {
continue
}
var payload PromptRunPlaybookFromKeywordsPayload
if err := mapstructure.Decode(action.Payload, &payload); err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"payload": payload,
"actionType": action.ActionType,
"triggerType": action.TriggerType,
}).Error("unable to decode payload from action")
continue
}
if len(payload.Keywords) == 0 || payload.PlaybookID == "" {
continue
}
suggestedPlaybook, err := a.playbookGetter.Get(payload.PlaybookID)
if err != nil {
logrus.WithError(err).WithField("playbook_id", payload.PlaybookID).Error("unable to get playbook to run action")
continue
}
triggers := payload.Keywords
actionExecuted := false
for _, trigger := range triggers {
if strings.Contains(post.Message, trigger) || containsAttachments(post.Attachments(), trigger) {
triggeredPlaybooksMap[payload.PlaybookID] = suggestedPlaybook
presentTriggers = append(presentTriggers, trigger)
actionExecuted = true
}
}
if actionExecuted {
a.telemetry.RunChannelAction(action, post.UserId)
}
}
if len(triggeredPlaybooksMap) == 0 {
return
}
triggeredPlaybooks := []Playbook{}
for _, playbook := range triggeredPlaybooksMap {
triggeredPlaybooks = append(triggeredPlaybooks, playbook)
}
message := getPlaybookSuggestionsMessage(triggeredPlaybooks, presentTriggers)
attachment := getPlaybookSuggestionsSlackAttachment(triggeredPlaybooks, post.Id, "playbooks")
rootID := post.RootId
if rootID == "" {
rootID = post.Id
}
newPost := &model.Post{
Message: message,
ChannelId: post.ChannelId,
}
model.ParseSlackAttachment(newPost, []*model.SlackAttachment{attachment})
if err := a.poster.PostMessageToThread(rootID, newPost); err != nil {
logrus.WithError(err).Error("unable to post message with suggestions to run playbooks")
}
}
func getPlaybookSuggestionsMessage(suggestedPlaybooks []Playbook, triggers []string) string {
message := ""
triggerMessage := ""
if len(triggers) == 1 {
triggerMessage = fmt.Sprintf("`%s` is a trigger", triggers[0])
} else {
triggerMessage = fmt.Sprintf("`%s` are triggers", strings.Join(triggers, "`, `"))
}
if len(suggestedPlaybooks) == 1 {
playbookURL := fmt.Sprintf("[%s](%s)", suggestedPlaybooks[0].Title, GetPlaybookDetailsRelativeURL(suggestedPlaybooks[0].ID))
message = fmt.Sprintf("%s for the %s playbook, would you like to run it?", triggerMessage, playbookURL)
} else {
message = fmt.Sprintf("%s for the multiple playbooks, would you like to run one of them?", triggerMessage)
}
return message
}
func getPlaybookSuggestionsSlackAttachment(playbooks []Playbook, triggeringPostID string, pluginID string) *model.SlackAttachment {
ignoreButton := &model.PostAction{
Id: "ignoreKeywordsButton",
Name: "No, ignore thread",
Type: model.PostActionTypeButton,
Integration: &model.PostActionIntegration{
URL: fmt.Sprintf("/plugins/%s/api/v0/signal/keywords/ignore-thread", pluginID),
Context: map[string]interface{}{
"postID": triggeringPostID,
},
},
}
if len(playbooks) == 1 {
yesButton := &model.PostAction{
Id: "runPlaybookButton",
Name: "Yes, run playbook",
Type: model.PostActionTypeButton,
Integration: &model.PostActionIntegration{
URL: fmt.Sprintf("/plugins/%s/api/v0/signal/keywords/run-playbook", pluginID),
Context: map[string]interface{}{
"postID": triggeringPostID,
"selected_option": playbooks[0].ID,
},
},
Style: "primary",
}
attachment := &model.SlackAttachment{
Actions: []*model.PostAction{yesButton, ignoreButton},
Text: "Open Channel Actions in the channel header to view and edit keywords.",
}
return attachment
}
options := []*model.PostActionOptions{}
for _, playbook := range playbooks {
option := &model.PostActionOptions{
Value: playbook.ID,
Text: playbook.Title,
}
options = append(options, option)
}
playbookChooser := &model.PostAction{
Id: "playbookChooser",
Name: "Select a playbook to run",
Type: model.PostActionTypeSelect,
Integration: &model.PostActionIntegration{
URL: fmt.Sprintf("/plugins/%s/api/v0/signal/keywords/run-playbook", pluginID),
Context: map[string]interface{}{
"postID": triggeringPostID,
},
},
Options: options,
Style: "primary",
}
attachment := &model.SlackAttachment{
Actions: []*model.PostAction{playbookChooser, ignoreButton},
}
return attachment
}
func containsAttachments(attachments []*model.SlackAttachment, trigger string) bool {
// Check PreText, Title, Text and Footer SlackAttachments fields for trigger.
for _, attachment := range attachments {
switch {
case strings.Contains(attachment.Pretext, trigger):
return true
case strings.Contains(attachment.Title, trigger):
return true
case strings.Contains(attachment.Text, trigger):
return true
case strings.Contains(attachment.Footer, trigger):
return true
default:
continue
}
}
return false
}

View File

@ -1,153 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"strings"
)
type CategoryItemType string
const (
PlaybookItemType CategoryItemType = "p"
RunItemType CategoryItemType = "r"
)
func StringToItemType(item string) (CategoryItemType, error) {
var convertedItem CategoryItemType
if item == string(PlaybookItemType) {
convertedItem = PlaybookItemType
} else if item == string(RunItemType) {
convertedItem = RunItemType
} else {
return PlaybookItemType, errors.New("unknown item type")
}
return convertedItem, nil
}
type CategoryItem struct {
ItemID string `json:"item_id"`
Type CategoryItemType `json:"type"`
Name string `json:"name"`
Public bool `json:"public"`
}
// Category represents sidebar category with items
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
TeamID string `json:"team_id"`
UserID string `json:"user_id"`
Collapsed bool `json:"collapsed"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
Items []CategoryItem `json:"items"`
}
func (c *Category) IsValid() error {
if strings.TrimSpace(c.ID) == "" {
return errors.New("category ID cannot be empty")
}
if strings.TrimSpace(c.Name) == "" {
return errors.New("category name cannot be empty")
}
if strings.TrimSpace(c.UserID) == "" {
return errors.New("category user ID cannot be empty")
}
if strings.TrimSpace(c.TeamID) == "" {
return errors.New("category team id ID cannot be empty")
}
for _, item := range c.Items {
if item.ItemID == "" {
return errors.New("item ID cannot be empty")
}
if item.Type != PlaybookItemType && item.Type != RunItemType {
return errors.New("item type is incorrect")
}
}
return nil
}
func (c *Category) ContainsItem(item CategoryItem) bool {
for _, catItem := range c.Items {
if catItem.ItemID == item.ItemID && catItem.Type == item.Type {
return true
}
}
return false
}
// CategoryService is the category service for managing categories
type CategoryService interface {
// Create creates a new Category
Create(category Category) (string, error)
// Get retrieves category with categoryID for user for team
Get(categoryID string) (Category, error)
// GetCategories retrieves all categories for user for team
GetCategories(teamID, userID string) ([]Category, error)
// Update updates a category
Update(category Category) error
// Delete deletes a category
Delete(categoryID string) error
// AddFavorite favorites an item, which may be either run or playbook
AddFavorite(item CategoryItem, teamID, userID string) error
// DeleteFavorite unfavorites an item, which may be either run or playbook
DeleteFavorite(item CategoryItem, teamID, userID string) error
// IsItemFavorite returns whether item was favorited or not
IsItemFavorite(item CategoryItem, teamID, userID string) (bool, error)
AreItemsFavorites(items []CategoryItem, teamID, userID string) ([]bool, error)
}
type CategoryStore interface {
// Get retrieves a Category. Returns ErrNotFound if not found.
Get(id string) (Category, error)
// Create creates a new Category
Create(category Category) error
// GetCategories retrieves all categories for user for team
GetCategories(teamID, userID string) ([]Category, error)
// Update updates a category
Update(category Category) error
// Delete deletes a category
Delete(categoryID string) error
// GetFavoriteCategory returns favorite category
GetFavoriteCategory(teamID, userID string) (Category, error)
// AddItemToFavoriteCategory adds an item to favorite category,
// if favorite category does not exist it creates one
AddItemToFavoriteCategory(item CategoryItem, teamID, userID string) error
// AddItemToCategory adds an item to category
AddItemToCategory(item CategoryItem, categoryID string) error
// DeleteItemFromCategory adds an item to category
DeleteItemFromCategory(item CategoryItem, categoryID string) error
}
type CategoryTelemetry interface {
// FavoriteItem tracks run favoriting of an item. Item can be run or a playbook
FavoriteItem(item CategoryItem, userID string)
// UnfavoriteItem tracks run unfavoriting of an item. Item can be run or a playbook
UnfavoriteItem(item CategoryItem, userID string)
}

View File

@ -1,167 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
)
type categoryService struct {
store CategoryStore
api playbooks.ServicesAPI
telemetry CategoryTelemetry
}
// NewPlaybookService returns a new playbook service
func NewCategoryService(store CategoryStore, api playbooks.ServicesAPI, categoryTelemetry CategoryTelemetry) CategoryService {
return &categoryService{
store: store,
api: api,
telemetry: categoryTelemetry,
}
}
// Create creates a new Category
func (c *categoryService) Create(category Category) (string, error) {
if category.ID != "" {
return "", errors.New("ID should be empty")
}
category.ID = model.NewId()
category.CreateAt = model.GetMillis()
category.UpdateAt = category.CreateAt
if err := category.IsValid(); err != nil {
return "", errors.Wrap(err, "invalid category")
}
if err := c.store.Create(category); err != nil {
return "", errors.Wrap(err, "Can't create category")
}
return category.ID, nil
}
func (c *categoryService) Get(categoryID string) (Category, error) {
category, err := c.store.Get(categoryID)
if err != nil {
return Category{}, errors.Wrap(err, "Can't get category")
}
return category, nil
}
// GetCategories retrieves all categories for user for team
func (c *categoryService) GetCategories(teamID, userID string) ([]Category, error) {
if !model.IsValidId(teamID) {
return nil, errors.New("teamID is not valid")
}
if !model.IsValidId(userID) {
return nil, errors.New("userID is not valid")
}
return c.store.GetCategories(teamID, userID)
}
// Update updates a category
func (c *categoryService) Update(category Category) error {
if category.ID == "" {
return errors.New("id should not be empty")
}
if category.Name == "" {
return errors.New("name should not be empty")
}
category.UpdateAt = model.GetMillis()
if err := category.IsValid(); err != nil {
return errors.Wrap(err, "invalid category")
}
if err := c.store.Update(category); err != nil {
return errors.Wrap(err, "can't update category")
}
return nil
}
// Delete deletes a category
func (c *categoryService) Delete(categoryID string) error {
if err := c.store.Delete(categoryID); err != nil {
return errors.Wrap(err, "can't delete category")
}
return nil
}
// AddFavorite favorites an item, which may be either run or playbook
func (c *categoryService) AddFavorite(item CategoryItem, teamID, userID string) error {
if err := c.store.AddItemToFavoriteCategory(item, teamID, userID); err != nil {
return errors.Wrap(err, "failed to add favorite")
}
c.telemetry.FavoriteItem(item, userID)
return nil
}
func (c *categoryService) DeleteFavorite(item CategoryItem, teamID, userID string) error {
favoriteCategory, err := c.store.GetFavoriteCategory(teamID, userID)
if err != nil {
return errors.Wrap(err, "can't get favorite category")
}
found := false
for _, favItem := range favoriteCategory.Items {
if favItem.ItemID == item.ItemID && favItem.Type == item.Type {
found = true
}
}
if !found {
return errors.New("Item is not favorited")
}
if err := c.store.DeleteItemFromCategory(item, favoriteCategory.ID); err != nil {
return errors.Wrap(err, "can't delete item from favorite category")
}
c.telemetry.UnfavoriteItem(item, userID)
return nil
}
func (c *categoryService) IsItemFavorite(item CategoryItem, teamID, userID string) (bool, error) {
favoriteCategory, err := c.store.GetFavoriteCategory(teamID, userID)
if err == sql.ErrNoRows {
return false, nil
} else if err != nil {
return false, errors.Wrap(err, "can't get favorite category")
}
found := false
for _, favItem := range favoriteCategory.Items {
if favItem.ItemID == item.ItemID && favItem.Type == item.Type {
found = true
}
}
return found, nil
}
func (c *categoryService) AreItemsFavorites(items []CategoryItem, teamID, userID string) ([]bool, error) {
result := make([]bool, len(items))
favoriteCategory, err := c.store.GetFavoriteCategory(teamID, userID)
if err == sql.ErrNoRows {
return result, nil
} else if err != nil {
return result, errors.Wrap(err, "can't get favorite category")
}
categoryResult := make(map[CategoryItem]bool)
for _, favItem := range favoriteCategory.Items {
categoryResult[CategoryItem{
ItemID: favItem.ItemID,
Type: favItem.Type,
}] = true
}
for i, item := range items {
result[i] = categoryResult[item]
}
return result, nil
}

View File

@ -1,24 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import "github.com/pkg/errors"
// ErrNotFound used when an entity is not found.
var ErrNotFound = errors.New("not found")
// ErrChannelDisplayNameInvalid is used when a channel name is too long.
var ErrChannelDisplayNameInvalid = errors.New("channel name is invalid or too long")
// ErrPlaybookRunNotActive occurs when trying to run a command on a playbook run that has ended.
var ErrPlaybookRunNotActive = errors.New("already ended")
// ErrPlaybookRunActive occurs when trying to run a command on a playbook run that is active.
var ErrPlaybookRunActive = errors.New("already active")
// ErrMalformedPlaybookRun occurs when a playbook run is not valid.
var ErrMalformedPlaybookRun = errors.New("malformed")
// ErrDuplicateEntry occurs when failing to insert because the entry already existed.
var ErrDuplicateEntry = errors.New("duplicate entry")

View File

@ -1,75 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"reflect"
)
const CurrentPlaybookExportVersion = 1
func getFieldsForExport(in interface{}) map[string]interface{} {
out := map[string]interface{}{}
inType := reflect.TypeOf(in)
inValue := reflect.ValueOf(in)
for i := 0; i < inType.NumField(); i++ {
field := inType.Field(i)
tag := field.Tag.Get("export")
fieldValue := inValue.Field(i)
if tag != "" && tag != "-" && !fieldValue.IsZero() {
out[tag] = fieldValue.Interface()
}
}
return out
}
func generateChecklistItemExport(checklistItems []ChecklistItem) []interface{} {
exported := make([]interface{}, 0, len(checklistItems))
for _, item := range checklistItems {
exportItem := getFieldsForExport(item)
exported = append(exported, exportItem)
}
return exported
}
func generateChecklistExport(checklists []Checklist) []interface{} {
exported := make([]interface{}, 0, len(checklists))
for _, checklist := range checklists {
exportList := getFieldsForExport(checklist)
exportList["items"] = generateChecklistItemExport(checklist.Items)
exported = append(exported, exportList)
}
return exported
}
func generateMetricsExport(metrics []PlaybookMetricConfig) []interface{} {
exported := make([]interface{}, 0, len(metrics))
for _, checklist := range metrics {
exportList := getFieldsForExport(checklist)
exported = append(exported, exportList)
}
return exported
}
// GeneratePlaybookExport returns a playbook in export format.
// Fields marked with the stuct tag "export" are included using the given string.
func GeneratePlaybookExport(playbook Playbook) ([]byte, error) {
export := getFieldsForExport(playbook)
export["version"] = CurrentPlaybookExportVersion
export["checklists"] = generateChecklistExport(playbook.Checklists)
export["metrics"] = generateMetricsExport(playbook.Metrics)
result, err := json.MarshalIndent(export, "", " ")
if err != nil {
return nil, err
}
return result, nil
}

View File

@ -1,82 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"reflect"
"strings"
"testing"
"gopkg.in/guregu/null.v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeneratePlaybookExport(t *testing.T) {
pb := Playbook{
Title: "Testing",
CreateAt: 23423234,
Checklists: []Checklist{
{
Title: "checklist 1",
Items: []ChecklistItem{
{
Title: "This is an item",
Description: "It's an item",
},
},
},
},
Metrics: []PlaybookMetricConfig{
{
ID: "1",
PlaybookID: "11",
Title: "Title 1",
Description: "Description 1",
Type: MetricTypeCurrency,
Target: null.IntFrom(147),
},
},
}
output, err := GeneratePlaybookExport(pb)
require.NoError(t, err)
result := Playbook{}
err = json.Unmarshal(output, &result)
require.NoError(t, err)
// Should copy the specified stuff
assert.Equal(t, result.Title, pb.Title)
// Shouldn't copy the not specificed stuff
assert.Equal(t, result.CreateAt, int64(0))
// Shouldn't copy metrics ID and PlaybookID fields
assert.NotEqual(t, result.Metrics, pb.Metrics)
//After cleaning ID and PlaybookID, should be equal
pb.Metrics[0].ID = ""
pb.Metrics[0].PlaybookID = ""
assert.Equal(t, result.Metrics, pb.Metrics)
}
func definesExports(t *testing.T, thing interface{}) {
inType := reflect.TypeOf(thing)
for i := 0; i < inType.NumField(); i++ {
field := inType.Field(i)
tag := strings.TrimSpace(field.Tag.Get("export"))
if tag == "" {
t.Errorf("%s struct does not define export for field %s. Please define this struct tag, see comment above playbook struct.", inType.Name(), field.Name)
}
}
}
func TestPlaybookDefinesExports(t *testing.T) {
definesExports(t, Playbook{})
definesExports(t, Checklist{})
definesExports(t, ChecklistItem{})
}

View File

@ -1,44 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import "sync"
type KeywordsThreadIgnorer interface {
Ignore(postID, userID string)
IsIgnored(postID, userID string) bool
}
type keywordsThreadIgnorerImpl struct {
ignoredThreads map[string]map[string]bool // [postID][userID]
mutex sync.RWMutex
}
func NewKeywordsThreadIgnorer() KeywordsThreadIgnorer {
return &keywordsThreadIgnorerImpl{
ignoredThreads: map[string]map[string]bool{},
mutex: sync.RWMutex{},
}
}
// Ignores ignores thread postID for the userID,
// other users will still get notifications in this thread
func (i *keywordsThreadIgnorerImpl) Ignore(postID, userID string) {
i.mutex.Lock()
defer i.mutex.Unlock()
if _, ok := i.ignoredThreads[postID]; !ok {
i.ignoredThreads[postID] = map[string]bool{}
}
i.ignoredThreads[postID][userID] = true
}
// IsIgnored checks whether this thread should be ignored for userID
func (i *keywordsThreadIgnorerImpl) IsIgnored(postID, userID string) bool {
i.mutex.RLock()
defer i.mutex.RUnlock()
if _, ok := i.ignoredThreads[postID]; !ok {
return false
}
return i.ignoredThreads[postID][userID]
}

View File

@ -1,109 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/v8/playbooks/server/app (interfaces: JobOnceScheduler)
// Package mock_app is a generated GoMock package.
package mock_app
import (
reflect "reflect"
time "time"
gomock "github.com/golang/mock/gomock"
cluster "github.com/mattermost/mattermost/server/v8/playbooks/product/pluginapi/cluster"
)
// MockJobOnceScheduler is a mock of JobOnceScheduler interface.
type MockJobOnceScheduler struct {
ctrl *gomock.Controller
recorder *MockJobOnceSchedulerMockRecorder
}
// MockJobOnceSchedulerMockRecorder is the mock recorder for MockJobOnceScheduler.
type MockJobOnceSchedulerMockRecorder struct {
mock *MockJobOnceScheduler
}
// NewMockJobOnceScheduler creates a new mock instance.
func NewMockJobOnceScheduler(ctrl *gomock.Controller) *MockJobOnceScheduler {
mock := &MockJobOnceScheduler{ctrl: ctrl}
mock.recorder = &MockJobOnceSchedulerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockJobOnceScheduler) EXPECT() *MockJobOnceSchedulerMockRecorder {
return m.recorder
}
// Cancel mocks base method.
func (m *MockJobOnceScheduler) Cancel(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Cancel", arg0)
}
// Cancel indicates an expected call of Cancel.
func (mr *MockJobOnceSchedulerMockRecorder) Cancel(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cancel", reflect.TypeOf((*MockJobOnceScheduler)(nil).Cancel), arg0)
}
// ListScheduledJobs mocks base method.
func (m *MockJobOnceScheduler) ListScheduledJobs() ([]cluster.JobOnceMetadata, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListScheduledJobs")
ret0, _ := ret[0].([]cluster.JobOnceMetadata)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListScheduledJobs indicates an expected call of ListScheduledJobs.
func (mr *MockJobOnceSchedulerMockRecorder) ListScheduledJobs() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListScheduledJobs", reflect.TypeOf((*MockJobOnceScheduler)(nil).ListScheduledJobs))
}
// ScheduleOnce mocks base method.
func (m *MockJobOnceScheduler) ScheduleOnce(arg0 string, arg1 time.Time) (*cluster.JobOnce, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ScheduleOnce", arg0, arg1)
ret0, _ := ret[0].(*cluster.JobOnce)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ScheduleOnce indicates an expected call of ScheduleOnce.
func (mr *MockJobOnceSchedulerMockRecorder) ScheduleOnce(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScheduleOnce", reflect.TypeOf((*MockJobOnceScheduler)(nil).ScheduleOnce), arg0, arg1)
}
// SetCallback mocks base method.
func (m *MockJobOnceScheduler) SetCallback(arg0 func(string)) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetCallback", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// SetCallback indicates an expected call of SetCallback.
func (mr *MockJobOnceSchedulerMockRecorder) SetCallback(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCallback", reflect.TypeOf((*MockJobOnceScheduler)(nil).SetCallback), arg0)
}
// Start mocks base method.
func (m *MockJobOnceScheduler) Start() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Start")
ret0, _ := ret[0].(error)
return ret0
}
// Start indicates an expected call of Start.
func (mr *MockJobOnceSchedulerMockRecorder) Start() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockJobOnceScheduler)(nil).Start))
}

View File

@ -1,545 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"reflect"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/playbooks/server/config"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// ErrNoPermissions if the error is caused by the user not having permissions
var ErrNoPermissions = errors.New("does not have permissions")
// ErrLicensedFeature if the error is caused by the server not having the needed license for the feature
var ErrLicensedFeature = errors.New("not covered by current server license")
type LicenseChecker interface {
PlaybookAllowed(isPlaybookPublic bool) bool
RetrospectiveAllowed() bool
TimelineAllowed() bool
StatsAllowed() bool
ChecklistItemDueDateAllowed() bool
}
type PermissionsService struct {
playbookService PlaybookService
runService PlaybookRunService
api playbooks.ServicesAPI
configService config.Service
licenseChecker LicenseChecker
}
func NewPermissionsService(
playbookService PlaybookService,
runService PlaybookRunService,
api playbooks.ServicesAPI,
configService config.Service,
licenseChecker LicenseChecker,
) *PermissionsService {
return &PermissionsService{
playbookService,
runService,
api,
configService,
licenseChecker,
}
}
func (p *PermissionsService) PlaybookIsPublic(playbook Playbook) bool {
return playbook.Public
}
func (p *PermissionsService) getPlaybookRole(userID string, playbook Playbook) []string {
if !p.canViewTeam(userID, playbook.TeamID) {
return []string{}
}
for _, member := range playbook.Members {
if member.UserID == userID {
return member.SchemeRoles
}
}
// Public playbooks
if playbook.Public {
// Public playbooks are public to those who can list channels on a team. (Not guests)
if p.api.HasPermissionToTeam(userID, playbook.TeamID, model.PermissionListTeamChannels) {
if playbook.DefaultPlaybookMemberRole == "" {
return []string{playbook.DefaultPlaybookMemberRole}
}
return []string{PlaybookRoleMember}
}
}
return []string{}
}
func (p *PermissionsService) hasPermissionsToPlaybook(userID string, playbook Playbook, permission *model.Permission) bool {
// Check at playbook level
if p.api.RolesGrantPermission(p.getPlaybookRole(userID, playbook), permission.Id) {
return true
}
// Cascade normally to higher level permissions
return p.api.HasPermissionToTeam(userID, playbook.TeamID, permission)
}
func (p *PermissionsService) HasPermissionsToRun(userID string, run *PlaybookRun, permission *model.Permission) bool {
// Check at run level
if err := p.runManagePropertiesWithPlaybookRun(userID, run); err != nil {
return false
}
// Cascade normally to higher level permissions
return p.api.HasPermissionToTeam(userID, run.TeamID, permission)
}
func (p *PermissionsService) canViewTeam(userID string, teamID string) bool {
if teamID == "" || userID == "" {
return false
}
// This is list team channels so that Guests are excluded.
return p.api.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam)
}
func (p *PermissionsService) PlaybookCreate(userID string, playbook Playbook) error {
if !p.licenseChecker.PlaybookAllowed(p.PlaybookIsPublic(playbook)) {
return errors.Wrapf(ErrLicensedFeature, "the playbook is not valid with the current license")
}
// Check the user has permissions over all broadcast channels
for _, channelID := range playbook.BroadcastChannelIDs {
if !p.api.HasPermissionToChannel(userID, channelID, model.PermissionCreatePost) {
return errors.Errorf("user `%s` does not have permission to create posts in channel `%s`", userID, channelID)
}
}
// Check all invited users have permissions to the team.
for _, userID := range playbook.InvitedUserIDs {
if !p.api.HasPermissionToTeam(userID, playbook.TeamID, model.PermissionViewTeam) {
return errors.Errorf(
"invited user `%s` does not have permission to playbook's team `%s`",
userID,
playbook.TeamID,
)
}
}
// Respect setting for not allowing mentions of a group.
for _, groupID := range playbook.InvitedGroupIDs {
group, err := p.api.GetGroup(groupID)
if err != nil {
return errors.Wrap(err, "invalid group")
}
if !group.AllowReference {
return errors.Errorf(
"group `%s` does not allow references",
groupID,
)
}
}
// Check general permissions
permission := model.PermissionPrivatePlaybookCreate
if p.PlaybookIsPublic(playbook) {
permission = model.PermissionPublicPlaybookCreate
}
if p.api.HasPermissionToTeam(userID, playbook.TeamID, permission) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to create playbook", userID)
}
func (p *PermissionsService) PlaybookManageProperties(userID string, playbook Playbook) error {
permission := model.PermissionPrivatePlaybookManageProperties
if p.PlaybookIsPublic(playbook) {
permission = model.PermissionPublicPlaybookManageProperties
}
if p.hasPermissionsToPlaybook(userID, playbook, permission) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have access to playbook `%s`", userID, playbook.ID)
}
// PlaybookodifyWithFixes checks both ManageProperties and ManageMembers permissions
// performs permissions checks that can be resolved though modification of the input.
// This function modifies the playbook argument.
func (p *PermissionsService) PlaybookModifyWithFixes(userID string, playbook *Playbook, oldPlaybook Playbook) error {
// It is assumed that if you are calling this function there are properties changes
// This means that you need the manage properties permission to manage members for now.
if err := p.PlaybookManageProperties(userID, oldPlaybook); err != nil {
return err
}
if err := p.NoAddedBroadcastChannelsWithoutPermission(userID, playbook.BroadcastChannelIDs, oldPlaybook.BroadcastChannelIDs); err != nil {
return err
}
filteredUsers := p.FilterInvitedUserIDs(playbook.InvitedUserIDs, playbook.TeamID)
playbook.InvitedUserIDs = filteredUsers
filteredGroups := p.FilterInvitedGroupIDs(playbook.InvitedGroupIDs)
playbook.InvitedGroupIDs = filteredGroups
if playbook.DefaultOwnerID != "" {
if !p.api.HasPermissionToTeam(playbook.DefaultOwnerID, playbook.TeamID, model.PermissionViewTeam) {
logrus.WithFields(logrus.Fields{
"team_id": playbook.TeamID,
"user_id": playbook.DefaultOwnerID,
}).Warn("owner is not a member of the playbook's team, disabling default owner")
playbook.DefaultOwnerID = ""
playbook.DefaultOwnerEnabled = false
}
}
// Check if we have changed members, if so check that permission.
if !reflect.DeepEqual(oldPlaybook.Members, playbook.Members) {
if err := p.PlaybookManageMembers(userID, oldPlaybook); err != nil {
return errors.Wrap(err, "attempted to modify members without permissions")
}
oldMemberRoles := map[string]string{}
for _, member := range oldPlaybook.Members {
oldMemberRoles[member.UserID] = strings.Join(member.Roles, ",")
}
// Also need to check if roles changed. If so we need to check manage roles permission.
for _, member := range playbook.Members {
oldRoles, memberExisted := oldMemberRoles[member.UserID]
userAddedAsMember := !memberExisted && len(member.Roles) == 1 && member.Roles[0] == PlaybookRoleMember
rolesHaveNotChanged := memberExisted && strings.Join(member.Roles, ",") == oldRoles
if !(userAddedAsMember || rolesHaveNotChanged) {
if err := p.PlaybookManageRoles(userID, oldPlaybook); err != nil {
return errors.Wrap(err, "attempted to modify members without permissions")
}
break
}
}
}
// Check if we have done a public conversion
if oldPlaybook.Public != playbook.Public {
if oldPlaybook.Public {
if err := p.PlaybookMakePrivate(userID, oldPlaybook); err != nil {
return errors.Wrap(err, "attempted to make playbook private without permissions")
}
} else {
if err := p.PlaybookMakePublic(userID, oldPlaybook); err != nil {
return errors.Wrap(err, "attempted to make playbook public without permissions")
}
}
}
if !p.licenseChecker.PlaybookAllowed(p.PlaybookIsPublic(*playbook)) {
return errors.Wrapf(ErrLicensedFeature, "the playbook is not valid with the current license")
}
return nil
}
func (p *PermissionsService) FilterInvitedUserIDs(invitedUserIDs []string, teamID string) []string {
filteredUsers := []string{}
for _, userID := range invitedUserIDs {
if !p.api.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
logrus.WithFields(logrus.Fields{
"team_id": teamID,
"user_id": userID,
}).Warn("user does not have permissions to playbook's team, removing from automated invite list")
continue
}
filteredUsers = append(filteredUsers, userID)
}
return filteredUsers
}
func (p *PermissionsService) FilterInvitedGroupIDs(invitedGroupIDs []string) []string {
filteredGroups := []string{}
for _, groupID := range invitedGroupIDs {
var group *model.Group
group, err := p.api.GetGroup(groupID)
if err != nil {
logrus.WithField("group_id", groupID).Error("failed to query group")
continue
}
if !group.AllowReference {
logrus.WithField("group_id", groupID).Warn("group does not allow references, removing from automated invite list")
continue
}
filteredGroups = append(filteredGroups, groupID)
}
return filteredGroups
}
func (p *PermissionsService) DeletePlaybook(userID string, playbook Playbook) error {
return p.PlaybookManageProperties(userID, playbook)
}
func (p *PermissionsService) NoAddedBroadcastChannelsWithoutPermission(userID string, broadcastChannelIDs, oldBroadcastChannelIDs []string) error {
oldChannelsSet := make(map[string]bool)
for _, channelID := range oldBroadcastChannelIDs {
oldChannelsSet[channelID] = true
}
for _, channelID := range broadcastChannelIDs {
if !oldChannelsSet[channelID] &&
!p.api.HasPermissionToChannel(userID, channelID, model.PermissionCreatePost) {
return errors.Wrapf(
ErrNoPermissions,
"user `%s` does not have permission to create posts in channel `%s`",
userID,
channelID,
)
}
}
return nil
}
func (p *PermissionsService) PlaybookManageMembers(userID string, playbook Playbook) error {
permission := model.PermissionPrivatePlaybookManageMembers
if p.PlaybookIsPublic(playbook) {
permission = model.PermissionPublicPlaybookManageMembers
}
if p.hasPermissionsToPlaybook(userID, playbook, permission) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to manage members for playbook `%s`", userID, playbook.ID)
}
func (p *PermissionsService) PlaybookManageRoles(userID string, playbook Playbook) error {
permission := model.PermissionPrivatePlaybookManageRoles
if p.PlaybookIsPublic(playbook) {
permission = model.PermissionPublicPlaybookManageRoles
}
if p.hasPermissionsToPlaybook(userID, playbook, permission) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to manage roles for playbook `%s`", userID, playbook.ID)
}
func (p *PermissionsService) PlaybookView(userID string, playbookID string) error {
playbook, err := p.playbookService.Get(playbookID)
if err != nil {
return errors.Wrapf(err, "Unable to get playbook to determine permissions, playbook id `%s`", playbookID)
}
return p.PlaybookViewWithPlaybook(userID, playbook)
}
func (p *PermissionsService) PlaybookList(userID, teamID string) error {
// Can list playbooks if you are on the team
if p.canViewTeam(userID, teamID) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to list playbooks for team `%s`", userID, teamID)
}
func (p *PermissionsService) PlaybookViewWithPlaybook(userID string, playbook Playbook) error {
noAccessErr := errors.Wrapf(
ErrNoPermissions,
"user `%s` to access playbook `%s`",
userID,
playbook.ID,
)
// Playbooks are tied to teams. You must have permission to the team to have permission to the playbook.
if !p.canViewTeam(userID, playbook.TeamID) {
return errors.Wrapf(noAccessErr, "no playbook access; no team view permission for team `%s`", playbook.TeamID)
}
if p.PlaybookIsPublic(playbook) {
if p.hasPermissionsToPlaybook(userID, playbook, model.PermissionPublicPlaybookView) {
return nil
}
}
if p.hasPermissionsToPlaybook(userID, playbook, model.PermissionPrivatePlaybookView) {
return nil
}
return noAccessErr
}
func (p *PermissionsService) PlaybookMakePrivate(userID string, playbook Playbook) error {
if p.hasPermissionsToPlaybook(userID, playbook, model.PermissionPublicPlaybookMakePrivate) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to make playbook `%s` private", userID, playbook.ID)
}
func (p *PermissionsService) PlaybookMakePublic(userID string, playbook Playbook) error {
if p.hasPermissionsToPlaybook(userID, playbook, model.PermissionPrivatePlaybookMakePublic) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to make playbook `%s` public", userID, playbook.ID)
}
func (p *PermissionsService) RunCreate(userID string, playbook Playbook) error {
if p.hasPermissionsToPlaybook(userID, playbook, model.PermissionRunCreate) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to run playbook `%s`", userID, playbook.ID)
}
func (p *PermissionsService) RunManageProperties(userID, runID string) error {
run, err := p.runService.GetPlaybookRun(runID)
if err != nil {
return errors.Wrapf(err, "Unable to get run to determine permissions, run id `%s`", runID)
}
return p.runManagePropertiesWithPlaybookRun(userID, run)
}
func (p *PermissionsService) runManagePropertiesWithPlaybookRun(userID string, run *PlaybookRun) error {
if run.OwnerUserID == userID {
return nil
}
for _, participantID := range run.ParticipantIDs {
if participantID == userID {
return nil
}
}
if IsSystemAdmin(userID, p.api) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to manage run `%s`", userID, run.ID)
}
func (p *PermissionsService) RunView(userID, runID string) error {
run, err := p.runService.GetPlaybookRun(runID)
if err != nil {
return errors.Wrapf(err, "Unable to get run to determine permissions, run id `%s`", runID)
}
// Has permission if is the owner of the run
if run.OwnerUserID == userID {
return nil
}
// Or if is a participant of the run
for _, participantID := range run.ParticipantIDs {
if participantID == userID {
return nil
}
}
// Or has view access to the playbook that created it
return p.PlaybookView(userID, run.PlaybookID)
}
func (p *PermissionsService) ChannelActionCreate(userID, channelID string) error {
if IsSystemAdmin(userID, p.api) || CanManageChannelProperties(userID, channelID, p.api) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to create actions for channel `%s`", userID, channelID)
}
func (p *PermissionsService) ChannelActionView(userID, channelID string) error {
if p.api.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to view actions for channel `%s`", userID, channelID)
}
func (p *PermissionsService) ChannelActionUpdate(userID, channelID string) error {
if IsSystemAdmin(userID, p.api) || CanManageChannelProperties(userID, channelID, p.api) {
return nil
}
return errors.Wrapf(ErrNoPermissions, "user `%s` does not have permission to update actions for channel `%s`", userID, channelID)
}
// IsSystemAdmin returns true if the userID is a system admin
func IsSystemAdmin(userID string, api playbooks.ServicesAPI) bool {
return api.HasPermissionTo(userID, model.PermissionManageSystem)
}
// CanManageChannelProperties returns true if the userID is allowed to manage the properties of channelID
func CanManageChannelProperties(userID, channelID string, api playbooks.ServicesAPI) bool {
channel, err := api.GetChannelByID(channelID)
if err != nil {
return false
}
permission := model.PermissionManagePublicChannelProperties
if channel.Type == model.ChannelTypePrivate {
permission = model.PermissionManagePrivateChannelProperties
}
return api.HasPermissionToChannel(userID, channelID, permission)
}
func CanPostToChannel(userID, channelID string, api playbooks.ServicesAPI) bool {
return api.HasPermissionToChannel(userID, channelID, model.PermissionCreatePost)
}
func IsMemberOfTeam(userID, teamID string, api playbooks.ServicesAPI) bool {
teamMember, err := api.GetTeamMember(teamID, userID)
if err != nil {
return false
}
return teamMember.DeleteAt == 0
}
// RequesterInfo holds the userID and teamID that this request is regarding, and permissions
// for the user making the request
type RequesterInfo struct {
UserID string
TeamID string
IsAdmin bool
IsGuest bool
}
// IsGuest returns true if the userID is a system guest
func IsGuest(userID string, api playbooks.ServicesAPI) (bool, error) {
user, err := api.GetUserByID(userID)
if err != nil {
return false, errors.Wrapf(err, "Unable to get user to determine permissions, user id `%s`", userID)
}
return user.IsGuest(), nil
}
func GetRequesterInfo(userID string, api playbooks.ServicesAPI) (RequesterInfo, error) {
isAdmin := IsSystemAdmin(userID, api)
isGuest, err := IsGuest(userID, api)
if err != nil {
return RequesterInfo{}, err
}
return RequesterInfo{
UserID: userID,
IsAdmin: isAdmin,
IsGuest: isGuest,
}, nil
}

View File

@ -1,741 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql/driver"
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"gopkg.in/guregu/null.v4"
"github.com/pkg/errors"
)
// Playbook represents a desired business outcome, from which playbook runs are started to solve
// a specific instance.
// The tag export supports the export/import feature. If the field makes sense for export, the value should be
// the JSON name of the item in the export format. If the field should not be exported the value should be "-".
// Fields should be exported if they are not server specific like InvitedUserIDs or are tracking metadata like CreateAt.
type Playbook struct {
ID string `json:"id" export:"-"`
Title string `json:"title" export:"title"`
Description string `json:"description" export:"description"`
Public bool `json:"public" export:"-"`
TeamID string `json:"team_id" export:"-"`
CreatePublicPlaybookRun bool `json:"create_public_playbook_run" export:"-"`
CreateAt int64 `json:"create_at" export:"-"`
UpdateAt int64 `json:"update_at" export:"-"`
DeleteAt int64 `json:"delete_at" export:"-"`
NumStages int64 `json:"num_stages" export:"-"`
NumSteps int64 `json:"num_steps" export:"-"`
NumRuns int64 `json:"num_runs" export:"-"`
NumActions int64 `json:"num_actions" export:"-"`
LastRunAt int64 `json:"last_run_at" export:"-"`
Checklists []Checklist `json:"checklists" export:"-"`
Members []PlaybookMember `json:"members" export:"-"`
ReminderMessageTemplate string `json:"reminder_message_template" export:"reminder_message_template"`
ReminderTimerDefaultSeconds int64 `json:"reminder_timer_default_seconds" export:"reminder_timer_default_seconds"`
StatusUpdateEnabled bool `json:"status_update_enabled" export:"status_update_enabled"`
InvitedUserIDs []string `json:"invited_user_ids" export:"-"`
InvitedGroupIDs []string `json:"invited_group_ids" export:"-"`
InviteUsersEnabled bool `json:"invite_users_enabled" export:"-"`
DefaultOwnerID string `json:"default_owner_id" export:"-"`
DefaultOwnerEnabled bool `json:"default_owner_enabled" export:"-"`
BroadcastChannelIDs []string `json:"broadcast_channel_ids" export:"-"`
WebhookOnCreationURLs []string `json:"webhook_on_creation_urls" export:"-"`
WebhookOnCreationEnabled bool `json:"webhook_on_creation_enabled" export:"-"`
MessageOnJoin string `json:"message_on_join" export:"message_on_join"`
MessageOnJoinEnabled bool `json:"message_on_join_enabled" export:"message_on_join_enabled"`
RetrospectiveReminderIntervalSeconds int64 `json:"retrospective_reminder_interval_seconds" export:"retrospective_reminder_interval_seconds"`
RetrospectiveTemplate string `json:"retrospective_template" export:"retrospective_template"`
RetrospectiveEnabled bool `json:"retrospective_enabled" export:"retrospective_enabled"`
WebhookOnStatusUpdateURLs []string `json:"webhook_on_status_update_urls" export:"-"`
SignalAnyKeywords []string `json:"signal_any_keywords" export:"signal_any_keywords"`
SignalAnyKeywordsEnabled bool `json:"signal_any_keywords_enabled" export:"signal_any_keywords_enabled"`
CategorizeChannelEnabled bool `json:"categorize_channel_enabled" export:"categorize_channel_enabled"`
CategoryName string `json:"category_name" export:"category_name"`
RunSummaryTemplateEnabled bool `json:"run_summary_template_enabled" export:"run_summary_template_enabled"`
RunSummaryTemplate string `json:"run_summary_template" export:"run_summary_template"`
ChannelNameTemplate string `json:"channel_name_template" export:"channel_name_template"`
DefaultPlaybookAdminRole string `json:"default_playbook_admin_role" export:"-"`
DefaultPlaybookMemberRole string `json:"default_playbook_member_role" export:"-"`
DefaultRunAdminRole string `json:"default_run_admin_role" export:"-"`
DefaultRunMemberRole string `json:"default_run_member_role" export:"-"`
Metrics []PlaybookMetricConfig `json:"metrics" export:"metrics"`
ActiveRuns int64 `json:"active_runs" export:"-"`
CreateChannelMemberOnNewParticipant bool `json:"create_channel_member_on_new_participant" export:"create_channel_member_on_new_participant"`
RemoveChannelMemberOnRemovedParticipant bool `json:"remove_channel_member_on_removed_participant" export:"create_channel_member_on_removed_participant"`
// ChannelID is the identifier of the channel that would be -potentially- linked
// to any new run of this playbook
ChannelID string `json:"channel_id" export:"channel_id"`
// ChannelMode is the playbook>run>channel flow used
ChannelMode ChannelPlaybookMode `json:"channel_mode" export:"channel_mode"`
// Deprecated: preserved for backwards compatibility with v1.27
BroadcastEnabled bool `json:"broadcast_enabled" export:"-"`
WebhookOnStatusUpdateEnabled bool `json:"webhook_on_status_update_enabled" export:"-"`
}
const (
PlaybookRoleMember = "playbook_member"
PlaybookRoleAdmin = "playbook_admin"
)
const (
MetricTypeDuration = "metric_duration"
MetricTypeCurrency = "metric_currency"
MetricTypeInteger = "metric_integer"
)
const MaxMetricsPerPlaybook = 4
type PlaybookMember struct {
UserID string `json:"user_id"`
Roles []string `json:"roles"`
SchemeRoles []string `json:"scheme_roles"`
}
type PlaybookMetricConfig struct {
ID string `json:"id" export:"-"`
PlaybookID string `json:"playbook_id" export:"-"`
Title string `json:"title" export:"title"`
Description string `json:"description" export:"description"`
Type string `json:"type" export:"type"`
Target null.Int `json:"target" export:"target"`
}
func (pm PlaybookMember) Clone() PlaybookMember {
newPlaybookMember := pm
if len(pm.Roles) != 0 {
newPlaybookMember.Roles = append([]string(nil), pm.Roles...)
}
if len(pm.SchemeRoles) != 0 {
newPlaybookMember.SchemeRoles = append([]string(nil), pm.SchemeRoles...)
}
return newPlaybookMember
}
func (p Playbook) Clone() Playbook {
newPlaybook := p
var newChecklists []Checklist
for _, c := range p.Checklists {
newChecklists = append(newChecklists, c.Clone())
}
newPlaybook.Checklists = newChecklists
newPlaybook.Metrics = append([]PlaybookMetricConfig(nil), p.Metrics...)
var newMembers []PlaybookMember
for _, m := range p.Members {
newMembers = append(newMembers, m.Clone())
}
newPlaybook.Members = newMembers
if len(p.InvitedUserIDs) != 0 {
newPlaybook.InvitedUserIDs = append([]string(nil), p.InvitedUserIDs...)
}
if len(p.InvitedGroupIDs) != 0 {
newPlaybook.InvitedGroupIDs = append([]string(nil), p.InvitedGroupIDs...)
}
if len(p.SignalAnyKeywords) != 0 {
newPlaybook.SignalAnyKeywords = append([]string(nil), p.SignalAnyKeywords...)
}
if len(p.BroadcastChannelIDs) != 0 {
newPlaybook.BroadcastChannelIDs = append([]string(nil), p.BroadcastChannelIDs...)
}
if len(p.WebhookOnCreationURLs) != 0 {
newPlaybook.WebhookOnCreationURLs = append([]string(nil), p.WebhookOnCreationURLs...)
}
if len(p.WebhookOnStatusUpdateURLs) != 0 {
newPlaybook.WebhookOnStatusUpdateURLs = append([]string(nil), p.WebhookOnStatusUpdateURLs...)
}
return newPlaybook
}
func (p Playbook) MarshalJSON() ([]byte, error) {
type Alias Playbook
old := Alias(p.Clone())
// replace nils with empty slices for the frontend
if old.Checklists == nil {
old.Checklists = []Checklist{}
}
for j, cl := range old.Checklists {
if cl.Items == nil {
old.Checklists[j].Items = []ChecklistItem{}
}
}
if old.Members == nil {
old.Members = []PlaybookMember{}
}
if old.Metrics == nil {
old.Metrics = []PlaybookMetricConfig{}
}
if old.InvitedUserIDs == nil {
old.InvitedUserIDs = []string{}
}
if old.InvitedGroupIDs == nil {
old.InvitedGroupIDs = []string{}
}
if old.SignalAnyKeywords == nil {
old.SignalAnyKeywords = []string{}
}
if old.BroadcastChannelIDs == nil {
old.BroadcastChannelIDs = []string{}
}
if old.WebhookOnCreationURLs == nil {
old.WebhookOnCreationURLs = []string{}
}
if old.WebhookOnStatusUpdateURLs == nil {
old.WebhookOnStatusUpdateURLs = []string{}
}
return json.Marshal(old)
}
func (p Playbook) GetRunChannelID() string {
if p.ChannelMode == PlaybookRunLinkExistingChannel {
return p.ChannelID
}
return ""
}
// ChecklistCommon allows access on common fields of Checklist and api.UpdateChecklist
type ChecklistCommon interface {
GetItems() []ChecklistItemCommon
}
// Checklist represents a checklist in a playbook.
type Checklist struct {
// ID is the identifier of the checklist.
ID string `json:"id" export:"-"`
// Title is the name of the checklist.
Title string `json:"title" export:"title"`
// Items is an array of all the items in the checklist.
Items []ChecklistItem `json:"items" export:"-"`
}
func (c Checklist) GetItems() []ChecklistItemCommon {
items := make([]ChecklistItemCommon, len(c.Items))
for i := range c.Items {
items[i] = &c.Items[i]
}
return items
}
func (c Checklist) Clone() Checklist {
newChecklist := c
newChecklist.Items = append([]ChecklistItem(nil), c.Items...)
return newChecklist
}
// ChecklistItemCommon allows access on common fields of ChecklistItem and api.UpdateChecklistItem
type ChecklistItemCommon interface {
GetAssigneeID() string
SetAssigneeModified(modified int64)
SetState(state string)
SetStateModified(modified int64)
SetCommandLastRun(lastRun int64)
}
// ChecklistItem represents an item in a checklist.
type ChecklistItem struct {
// ID is the identifier of the checklist item.
ID string `json:"id" export:"-"`
// Title is the content of the checklist item.
Title string `json:"title" export:"title"`
// State is the state of the checklist item: "closed" if it's checked, "skipped" if it has
// been skipped, the empty string otherwise.
State string `json:"state" export:"-"`
// StateModified is the timestamp, in milliseconds since epoch, of the last time the item's
// state was modified. 0 if it was never modified.
StateModified int64 `json:"state_modified" export:"-"`
// AssigneeID is the identifier of the user to whom this item is assigned.
AssigneeID string `json:"assignee_id" export:"-"`
// AssigneeModified is the timestamp, in milliseconds since epoch, of the last time the item's
// assignee was modified. 0 if it was never modified.
AssigneeModified int64 `json:"assignee_modified" export:"-"`
// Command, if not empty, is the slash command that can be run as part of this item.
Command string `json:"command" export:"command"`
// CommandLastRun is the timestamp, in milliseconds since epoch, of the last time the item's
// slash command was run. 0 if it was never run.
CommandLastRun int64 `json:"command_last_run" export:"-"`
// Description is a string with the markdown content of the long description of the item.
Description string `json:"description" export:"description"`
// LastSkipped is the timestamp, in milliseconds since epoch, of the last time the item
// was skipped. 0 if it was never skipped.
LastSkipped int64 `json:"delete_at" export:"-"`
// DueDate is the timestamp, in milliseconds since epoch. indicates relative or absolute due date
// of the checklist item. 0 if not set.
// Playbook can have only relative timstamp, run can have only absolute timestamp.
DueDate int64 `json:"due_date" export:"due_date"`
// TaskActions is an array of all the task actions associated with this task.
TaskActions []TaskAction `json:"task_actions" export:"-"`
}
func (ci *ChecklistItem) GetAssigneeID() string {
return ci.AssigneeID
}
func (ci *ChecklistItem) SetAssigneeModified(modified int64) {
ci.AssigneeModified = modified
}
func (ci *ChecklistItem) SetState(state string) {
ci.State = state
}
func (ci *ChecklistItem) SetStateModified(modified int64) {
ci.StateModified = modified
}
func (ci *ChecklistItem) SetCommandLastRun(lastRun int64) {
ci.CommandLastRun = lastRun
}
type GetPlaybooksResults struct {
TotalCount int `json:"total_count"`
PageCount int `json:"page_count"`
HasMore bool `json:"has_more"`
Items []Playbook `json:"items"`
}
// MarshalJSON customizes the JSON marshalling for GetPlaybooksResults by rendering a nil Items as
// an empty slice instead.
func (r GetPlaybooksResults) MarshalJSON() ([]byte, error) {
type Alias GetPlaybooksResults
if r.Items == nil {
r.Items = []Playbook{}
}
aux := &struct {
*Alias
}{
Alias: (*Alias)(&r),
}
return json.Marshal(aux)
}
// PlaybookService is the playbook service for managing playbooks
// userID is the user initiating the event.
type PlaybookService interface {
// Get retrieves a playbook. Returns ErrNotFound if not found.
Get(id string) (Playbook, error)
// Create creates a new playbook
Create(playbook Playbook, userID string) (string, error)
// Import imports a new playbook
Import(playbook Playbook, userID string) (string, error)
// GetPlaybooks retrieves all playbooks
GetPlaybooks() ([]Playbook, error)
// GetPlaybooksForTeam retrieves all playbooks on the specified team given the provided options
GetPlaybooksForTeam(requesterInfo RequesterInfo, teamID string, opts PlaybookFilterOptions) (GetPlaybooksResults, error)
// Update updates a playbook
Update(playbook Playbook, userID string) error
// Archive archives a playbook
Archive(playbook Playbook, userID string) error
// Restores an archived playbook
Restore(playbook Playbook, userID string) error
// AutoFollow method lets user auto-follow all runs of a specific playbook
AutoFollow(playbookID, userID string) error
// AutoUnfollow method lets user to not auto-follow the newly created playbook runs
AutoUnfollow(playbookID, userID string) error
// GetAutoFollows returns list of users who auto-follows a playbook
GetAutoFollows(playbookID string) ([]string, error)
// Duplicate duplicates a playbook
Duplicate(playbook Playbook, userID string) (string, error)
// Get top playbooks for teams
GetTopPlaybooksForTeam(teamID, userID string, opts *model.InsightsOpts) (*PlaybooksInsightsList, error)
// Get top playbooks for users
GetTopPlaybooksForUser(teamID, userID string, opts *model.InsightsOpts) (*PlaybooksInsightsList, error)
}
// PlaybookStore is an interface for storing playbooks
type PlaybookStore interface {
// Get retrieves a playbook
Get(id string) (Playbook, error)
// Create creates a new playbook
Create(playbook Playbook) (string, error)
// GetPlaybooks retrieves all playbooks
GetPlaybooks() ([]Playbook, error)
// GetPlaybooksForTeam retrieves all playbooks on the specified team
GetPlaybooksForTeam(requesterInfo RequesterInfo, teamID string, opts PlaybookFilterOptions) (GetPlaybooksResults, error)
// GetPlaybooksWithKeywords retrieves all playbooks with keywords enabled
GetPlaybooksWithKeywords(opts PlaybookFilterOptions) ([]Playbook, error)
// GetTimeLastUpdated retrieves time last playbook was updated at.
// Passed argument determines whether to include playbooks with
// SignalAnyKeywordsEnabled flag or not.
GetTimeLastUpdated(onlyPlaybooksWithKeywordsEnabled bool) (int64, error)
// GetPlaybookIDsForUser retrieves playbooks user can access
GetPlaybookIDsForUser(userID, teamID string) ([]string, error)
// Update updates a playbook
Update(playbook Playbook) error
// GraphqlUpdate taking a setmap for graphql
GraphqlUpdate(id string, setmap map[string]interface{}) error
// Archive archives a playbook
Archive(id string) error
// Restore restores a deleted playbook
Restore(id string) error
// AutoFollow method lets user auto-follow all runs of a specific playbook
AutoFollow(playbookID, userID string) error
// AutoUnfollow method lets user to not auto-follow the newly created playbook runs
AutoUnfollow(playbookID, userID string) error
// GetAutoFollows returns list of users who auto-follows a playbook
GetAutoFollows(playbookID string) ([]string, error)
// GetPlaybooksActiveTotal returns number of active playbooks
GetPlaybooksActiveTotal() (int64, error)
// GetMetric retrieves a metric by ID
GetMetric(id string) (*PlaybookMetricConfig, error)
// AddMetric adds a metric
AddMetric(playbookID string, config PlaybookMetricConfig) error
// UpdateMetric updates a metric
UpdateMetric(id string, setmap map[string]interface{}) error
// DeleteMetric deletes a metric
DeleteMetric(id string) error
// Get top playbooks for teams
GetTopPlaybooksForTeam(teamID, userID string, opts *model.InsightsOpts) (*PlaybooksInsightsList, error)
// Get top playbooks for users
GetTopPlaybooksForUser(teamID, userID string, opts *model.InsightsOpts) (*PlaybooksInsightsList, error)
// AddPlaybookMember adds a user as a member to a playbook
AddPlaybookMember(id string, memberID string) error
// RemovePlaybookMember removes a user from a playbook
RemovePlaybookMember(id string, memberID string) error
}
// PlaybookTelemetry defines the methods that the Playbook service needs from the RudderTelemetry.
// userID is the user initiating the event.
type PlaybookTelemetry interface {
// CreatePlaybook tracks the creation of a playbook.
CreatePlaybook(playbook Playbook, userID string)
// ImportPlaybook tracks the import of a playbook.
ImportPlaybook(playbook Playbook, userID string)
// UpdatePlaybook tracks the update of a playbook.
UpdatePlaybook(playbook Playbook, userID string)
// DeletePlaybook tracks the deletion of a playbook.
DeletePlaybook(playbook Playbook, userID string)
// RestorePlaybook tracks the restoration of a playbook.
RestorePlaybook(playbook Playbook, userID string)
// FrontendTelemetryForPlaybook tracks an event originating from the frontend
FrontendTelemetryForPlaybook(playbook Playbook, userID, action string)
// FrontendTelemetryForPlaybookTemplate tracks an event originating from the frontend
FrontendTelemetryForPlaybookTemplate(templateName string, userID, action string)
// AutoFollowPlaybook tracks the auto-follow of a playbook.
AutoFollowPlaybook(playbook Playbook, userID string)
// AutoUnfollowPlaybook tracks the auto-unfollow of a playbook.
AutoUnfollowPlaybook(playbook Playbook, userID string)
}
const (
ChecklistItemStateOpen = ""
ChecklistItemStateInProgress = "in_progress"
ChecklistItemStateClosed = "closed"
ChecklistItemStateSkipped = "skipped"
)
func IsValidChecklistItemState(state string) bool {
return state == ChecklistItemStateClosed ||
state == ChecklistItemStateInProgress ||
state == ChecklistItemStateOpen ||
state == ChecklistItemStateSkipped
}
func IsValidChecklistItemIndex(checklists []Checklist, checklistNum, itemNum int) bool {
return checklists != nil && checklistNum >= 0 && itemNum >= 0 && checklistNum < len(checklists) && itemNum < len(checklists[checklistNum].Items)
}
// PlaybookFilterOptions specifies the parameters when getting playbooks.
type PlaybookFilterOptions struct {
Sort SortField
Direction SortDirection
SearchTerm string
WithArchived bool
WithMembershipOnly bool //if true will return only playbooks you are a member of
PlaybookIDs []string
// Pagination options.
Page int
PerPage int
}
// Clone duplicates the given options.
func (o *PlaybookFilterOptions) Clone() PlaybookFilterOptions {
return *o
}
// Validate returns a new, validated filter options or returns an error if invalid.
func (o PlaybookFilterOptions) Validate() (PlaybookFilterOptions, error) {
options := o.Clone()
if options.PerPage <= 0 {
options.PerPage = PerPageDefault
}
options.Sort = SortField(strings.ToLower(string(options.Sort)))
switch options.Sort {
case SortByID:
case SortByTitle:
case SortByStages:
case SortBySteps:
case "": // default
options.Sort = SortByID
default:
return PlaybookFilterOptions{}, errors.Errorf("unsupported sort '%s'", options.Sort)
}
options.Direction = SortDirection(strings.ToUpper(string(options.Direction)))
switch options.Direction {
case DirectionAsc:
case DirectionDesc:
case "": //default
options.Direction = DirectionAsc
default:
return PlaybookFilterOptions{}, errors.Errorf("unsupported direction '%s'", options.Direction)
}
return options, nil
}
func ValidateWebhookURLs(urls []string) error {
if len(urls) > 64 {
return errors.New("too many registered urls, limit to less than 64")
}
for _, webhook := range urls {
reqURL, err := url.ParseRequestURI(webhook)
if err != nil {
return errors.Wrapf(err, "unable to parse webhook: %v", webhook)
}
if reqURL.Scheme != "http" && reqURL.Scheme != "https" {
return fmt.Errorf("protocol in webhook URL is %s; only HTTP and HTTPS are accepted", reqURL.Scheme)
}
}
return nil
}
func ValidateCategoryName(categoryName string) error {
categoryNameLength := len(categoryName)
if categoryNameLength > 22 {
msg := fmt.Sprintf("invalid category name: %s (maximum length is 22 characters)", categoryName)
return errors.Errorf(msg)
}
return nil
}
// CleanUpChecklists sets empty values for checklist fields that are not editable
func CleanUpChecklists[T ChecklistCommon](checklists []T) {
for listIndex := range checklists {
items := checklists[listIndex].GetItems()
for itemIndex := range items {
items[itemIndex].SetAssigneeModified(0)
items[itemIndex].SetState("")
items[itemIndex].SetStateModified(0)
items[itemIndex].SetCommandLastRun(0)
}
}
}
// ValidatePreAssignment checks if invitations are enabled and if all assignees are also invited
func ValidatePreAssignment(assignees []string, invitedUsers []string, inviteUsersEnabled bool) error {
if len(assignees) > 0 && !inviteUsersEnabled {
return errors.New("invitations are disabled")
}
if !assigneesAreInvited(assignees, invitedUsers) {
return errors.New("users missing in invite user list")
}
return nil
}
// GetDistinctAssignees returns a list of distinct user ids that are assignees in the given checklists
func GetDistinctAssignees[T ChecklistCommon](checklists []T) []string {
uMap := make(map[string]bool)
for _, cl := range checklists {
for _, ci := range cl.GetItems() {
if id := ci.GetAssigneeID(); id != "" && !uMap[id] {
uMap[id] = true
}
}
}
uIds := make([]string, 0, len(uMap))
for k := range uMap {
uIds = append(uIds, k)
}
return uIds
}
func assigneesAreInvited(assignees []string, invited []string) bool {
for _, assignee := range assignees {
found := false
for _, user := range invited {
if user == assignee {
found = true
}
}
if !found {
return false
}
}
return true
}
func removeDuplicates(a []string) []string {
items := make(map[string]bool)
for _, item := range a {
if item != "" {
items[item] = true
}
}
res := make([]string, 0, len(items))
for item := range items {
res = append(res, item)
}
return res
}
func ProcessSignalAnyKeywords(keywords []string) []string {
return removeDuplicates(keywords)
}
// models for playbooks-insights
// PlaybooksInsightsList is a response type with pagination support.
type PlaybooksInsightsList struct {
HasNext bool `json:"has_next"`
Items []*PlaybookInsight `json:"items"`
}
// PlaybookInsight gives insight into activities related to a playbook
type PlaybookInsight struct {
// ID of the playbook
// required: true
PlaybookID string `json:"playbook_id"`
// Run count of playbook
// required: true
NumRuns int `json:"num_runs"`
// Title of playbook
// required: true
Title string `json:"title"`
// Time the playbook was last run.
// required: false
LastRunAt int64 `json:"last_run_at"`
}
// ChannelPlaybookMode is a type alias to hold all possible
// modes for playbook > run > channel relation
type ChannelPlaybookMode int
const (
PlaybookRunCreateNewChannel ChannelPlaybookMode = iota
PlaybookRunLinkExistingChannel
)
var channelPlaybookTypes = [...]string{
PlaybookRunCreateNewChannel: "create_new_channel",
PlaybookRunLinkExistingChannel: "link_existing_channel",
}
// String creates the string version of the TelemetryTrack
func (cpm ChannelPlaybookMode) String() string {
return channelPlaybookTypes[cpm]
}
// MarshalText converts a ChannelPlaybookMode to a string for serializers (including JSON)
func (cpm ChannelPlaybookMode) MarshalText() ([]byte, error) {
return []byte(channelPlaybookTypes[cpm]), nil
}
// UnmarshalText parses a ChannelPlaybookMode from text. For deserializers (including JSON)
func (cpm *ChannelPlaybookMode) UnmarshalText(text []byte) error {
for i, st := range channelPlaybookTypes {
if st == string(text) {
*cpm = ChannelPlaybookMode(i)
return nil
}
}
return fmt.Errorf("unknown ChannelPlaybookMode: %s", string(text))
}
// Scan parses a ChannelPlaybookMode back from the DB
func (cpm *ChannelPlaybookMode) Scan(src interface{}) error {
txt, ok := src.([]byte) // mysql
if !ok {
txt, ok := src.(string) //postgres
if !ok {
return fmt.Errorf("could not cast to string: %v", src)
}
return cpm.UnmarshalText([]byte(txt))
}
return cpm.UnmarshalText(txt)
}
// Value represents a ChannelPlaybookMode as a type writable into the DB
func (cpm ChannelPlaybookMode) Value() (driver.Value, error) {
return cpm.MarshalText()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,219 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func TestPlaybookRun_MarshalJSON(t *testing.T) {
t.Run("marshal pointer", func(t *testing.T) {
testPlaybookRun := &PlaybookRun{}
result, err := json.Marshal(testPlaybookRun)
require.NoError(t, err)
require.NotContains(t, string(result), "null", "update MarshalJSON to initialize nil slices")
})
t.Run("marshal value", func(t *testing.T) {
testPlaybookRun := PlaybookRun{}
result, err := json.Marshal(testPlaybookRun)
require.NoError(t, err)
require.NotContains(t, string(result), "null", "update MarshalJSON to initialize nil slices")
})
}
func TestPlaybookRunFilterOptions_Clone(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: "team_id",
Page: 1,
PerPage: 10,
Sort: SortByID,
Direction: DirectionAsc,
Statuses: []string{"InProgress", "Finished"},
OwnerID: "owner_id",
ParticipantID: "participant_id",
SearchTerm: "search_term",
PlaybookID: "playbook_id",
}
marshalledOptions, err := json.Marshal(options)
require.NoError(t, err)
clone := options.Clone()
clone.TeamID = "team_id_clone"
clone.Page = 2
clone.PerPage = 20
clone.Sort = SortByName
clone.Direction = DirectionDesc
clone.Statuses[0] = "Finished"
clone.OwnerID = "owner_id_clone"
clone.ParticipantID = "participant_id_clone"
clone.SearchTerm = "search_term_clone"
clone.PlaybookID = "playbook_id_clone"
var unmarshalledOptions PlaybookRunFilterOptions
err = json.Unmarshal(marshalledOptions, &unmarshalledOptions)
require.NoError(t, err)
require.Equal(t, options, unmarshalledOptions)
require.NotEqual(t, clone, unmarshalledOptions)
}
func TestPlaybookRunFilterOptions_Validate(t *testing.T) {
t.Run("non-positive PerPage", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
PerPage: -1,
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, options.TeamID, validOptions.TeamID)
require.Equal(t, PerPageDefault, validOptions.PerPage)
})
t.Run("invalid sort option", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
Sort: SortField("invalid"),
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("valid, but wrong case sort option", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
Sort: SortField("END_at"),
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, options.TeamID, validOptions.TeamID)
require.Equal(t, SortByEndAt, validOptions.Sort)
})
t.Run("valid, no explicit sort option", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, options.TeamID, validOptions.TeamID)
require.Equal(t, SortByCreateAt, validOptions.Sort)
})
t.Run("invalid sort direction", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
Direction: SortDirection("invalid"),
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("valid, but wrong case direction option", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
Direction: SortDirection("DEsC"),
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, options.TeamID, validOptions.TeamID)
require.Equal(t, DirectionDesc, validOptions.Direction)
})
t.Run("valid, no explicit direction", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, options.TeamID, validOptions.TeamID)
require.Equal(t, DirectionAsc, validOptions.Direction)
})
t.Run("invalid team id", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: "invalid",
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("invalid owner id", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
OwnerID: "invalid",
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("invalid participant id", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
ParticipantID: "invalid",
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("invalid playbook id", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
PlaybookID: "invalid",
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("invalid statuses", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
Page: 1,
PerPage: 10,
Sort: SortByID,
Direction: DirectionAsc,
Statuses: []string{"active", "Finished"},
OwnerID: model.NewId(),
ParticipantID: model.NewId(),
SearchTerm: "search_term",
PlaybookID: model.NewId(),
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("valid status", func(t *testing.T) {
options := PlaybookRunFilterOptions{
TeamID: model.NewId(),
Page: 1,
PerPage: 10,
Sort: SortByID,
Direction: DirectionAsc,
Statuses: []string{"InProgress", "Finished"},
OwnerID: model.NewId(),
ParticipantID: model.NewId(),
SearchTerm: "search_term",
PlaybookID: model.NewId(),
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, options, validOptions)
})
}

View File

@ -1,258 +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/public/model"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/mattermost/mattermost/server/v8/playbooks/server/bot"
"github.com/mattermost/mattermost/server/v8/playbooks/server/metrics"
"github.com/mattermost/mattermost/server/v8/playbooks/server/playbooks"
)
const (
playbookCreatedWSEvent = "playbook_created"
playbookArchivedWSEvent = "playbook_archived"
playbookRestoredWSEvent = "playbook_restored"
)
type playbookService struct {
store PlaybookStore
poster bot.Poster
telemetry PlaybookTelemetry
api playbooks.ServicesAPI
metricsService *metrics.Metrics
}
// NewPlaybookService returns a new playbook service
func NewPlaybookService(store PlaybookStore, poster bot.Poster, telemetry PlaybookTelemetry, api playbooks.ServicesAPI, metricsService *metrics.Metrics) PlaybookService {
return &playbookService{
store: store,
poster: poster,
telemetry: telemetry,
api: api,
metricsService: metricsService,
}
}
func (s *playbookService) Create(playbook Playbook, userID string) (string, error) {
playbook.CreateAt = model.GetMillis()
playbook.UpdateAt = playbook.CreateAt
newID, err := s.store.Create(playbook)
if err != nil {
return "", err
}
playbook.ID = newID
s.telemetry.CreatePlaybook(playbook, userID)
s.poster.PublishWebsocketEventToTeam(playbookCreatedWSEvent, map[string]interface{}{
"teamID": playbook.TeamID,
}, playbook.TeamID)
s.metricsService.IncrementPlaybookCreatedCount(1)
return newID, nil
}
func (s *playbookService) Import(playbook Playbook, userID string) (string, error) {
newID, err := s.Create(playbook, userID)
if err != nil {
return "", err
}
playbook.ID = newID
s.telemetry.ImportPlaybook(playbook, userID)
return newID, nil
}
func (s *playbookService) Get(id string) (Playbook, error) {
return s.store.Get(id)
}
func (s *playbookService) GetPlaybooks() ([]Playbook, error) {
return s.store.GetPlaybooks()
}
func (s *playbookService) GetPlaybooksForTeam(requesterInfo RequesterInfo, teamID string, opts PlaybookFilterOptions) (GetPlaybooksResults, error) {
return s.store.GetPlaybooksForTeam(requesterInfo, teamID, opts)
}
func (s *playbookService) Update(playbook Playbook, userID string) error {
if playbook.DeleteAt != 0 {
return errors.New("cannot update a playbook that is archived")
}
playbook.UpdateAt = model.GetMillis()
if err := s.store.Update(playbook); err != nil {
return err
}
s.telemetry.UpdatePlaybook(playbook, userID)
return nil
}
func (s *playbookService) Archive(playbook Playbook, userID string) error {
if playbook.ID == "" {
return errors.New("can't archive a playbook without an ID")
}
if err := s.store.Archive(playbook.ID); err != nil {
return err
}
s.telemetry.DeletePlaybook(playbook, userID)
s.metricsService.IncrementPlaybookArchivedCount(1)
s.poster.PublishWebsocketEventToTeam(playbookArchivedWSEvent, map[string]interface{}{
"teamID": playbook.TeamID,
}, playbook.TeamID)
return nil
}
func (s *playbookService) Restore(playbook Playbook, userID string) error {
if playbook.ID == "" {
return errors.New("can't restore a playbook without an ID")
}
if playbook.DeleteAt == 0 {
return nil
}
if err := s.store.Restore(playbook.ID); err != nil {
return err
}
s.telemetry.RestorePlaybook(playbook, userID)
s.metricsService.IncrementPlaybookRestoredCount(1)
s.poster.PublishWebsocketEventToTeam(playbookRestoredWSEvent, map[string]interface{}{
"teamID": playbook.TeamID,
}, playbook.TeamID)
return nil
}
// AutoFollow method lets user to auto-follow all runs of a specific playbook
func (s *playbookService) AutoFollow(playbookID, userID string) error {
if err := s.store.AutoFollow(playbookID, userID); err != nil {
return errors.Wrapf(err, "user `%s` failed to auto-follow the playbook `%s`", userID, playbookID)
}
playbook, err := s.store.Get(playbookID)
if err != nil {
return errors.Wrap(err, "failed to retrieve playbook run")
}
s.telemetry.AutoFollowPlaybook(playbook, userID)
return nil
}
// AutoUnfollow method lets user to not auto-follow the newly created playbook runs
func (s *playbookService) AutoUnfollow(playbookID, userID string) error {
if err := s.store.AutoUnfollow(playbookID, userID); err != nil {
return errors.Wrapf(err, "user `%s` failed to auto-unfollow the playbook `%s`", userID, playbookID)
}
playbook, err := s.store.Get(playbookID)
if err != nil {
return errors.Wrap(err, "failed to retrieve playbook run")
}
s.telemetry.AutoUnfollowPlaybook(playbook, userID)
return nil
}
// GetAutoFollows returns list of users who auto-follow a playbook
func (s *playbookService) GetAutoFollows(playbookID string) ([]string, error) {
autoFollows, err := s.store.GetAutoFollows(playbookID)
if err != nil {
return nil, errors.Wrapf(err, "failed to get auto-follows for the playbook `%s`", playbookID)
}
return autoFollows, nil
}
// Duplicate duplicates a playbook
func (s *playbookService) Duplicate(playbook Playbook, userID string) (string, error) {
logger := logrus.WithFields(logrus.Fields{
"original_playbook_id": playbook.ID,
"user_id": userID,
})
newPlaybook := playbook.Clone()
newPlaybook.ID = ""
// Empty metric IDs if there are such. Otherwise, metrics will not be saved in the database.
for i := range newPlaybook.Metrics {
newPlaybook.Metrics[i].ID = ""
}
newPlaybook.Title = "Copy of " + playbook.Title
// On duplicating, make the current user the administrator.
newPlaybook.Members = []PlaybookMember{{
UserID: userID,
Roles: []string{PlaybookRoleMember, PlaybookRoleAdmin},
}}
playbookID, err := s.Create(newPlaybook, userID)
if err != nil {
return "", err
}
logger.WithField("playbook_id", playbookID).Debug("Duplicated playbook")
return playbookID, nil
}
// get top playbooks for teams
func (s *playbookService) GetTopPlaybooksForTeam(teamID, userID string, opts *model.InsightsOpts) (*PlaybooksInsightsList, error) {
permissionFlag, err := licenseAndGuestCheck(s, userID, false)
if err != nil {
return nil, err
}
if !permissionFlag {
return nil, errors.New("User cannot access playbooks insights")
}
return s.store.GetTopPlaybooksForTeam(teamID, userID, opts)
}
// get top playbooks for users
func (s *playbookService) GetTopPlaybooksForUser(teamID, userID string, opts *model.InsightsOpts) (*PlaybooksInsightsList, error) {
permissionFlag, err := licenseAndGuestCheck(s, userID, true)
if err != nil {
return nil, err
}
if !permissionFlag {
return nil, errors.New("User cannot access playbooks insights")
}
return s.store.GetTopPlaybooksForUser(teamID, userID, opts)
}
func licenseAndGuestCheck(s *playbookService, 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 := s.api.GetLicense()
user, err := s.api.GetUserByID(userID)
if err != nil {
return false, err
}
if user.IsGuest() {
return false, guestError
}
if lic == nil && !isMyInsights {
return false, licenseError
}
if !isMyInsights && (lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise) {
return false, licenseError
}
return true, nil
}

View File

@ -1,186 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestPlaybook_MarshalJSON(t *testing.T) {
tests := []struct {
name string
original Playbook
expected []byte
wantErr bool
}{
{
name: "marshals a struct with nil slices into empty arrays",
original: Playbook{
ID: "playbookid",
Title: "the playbook title",
Description: "the playbook's description",
TeamID: "theteamid",
CreatePublicPlaybookRun: true,
CreateAt: 4503134,
DeleteAt: 0,
NumStages: 0,
NumSteps: 0,
Checklists: nil,
Members: nil,
BroadcastChannelIDs: []string{"channelid"},
ReminderMessageTemplate: "This is a message",
ReminderTimerDefaultSeconds: 0,
InvitedUserIDs: nil,
InvitedGroupIDs: nil,
},
expected: []byte(`"checklists":[]`),
wantErr: false,
},
{
name: "marshals a struct with nil []checklistItems into an empty array",
original: Playbook{
ID: "playbookid",
Title: "the playbook title",
Description: "the playbook's description",
TeamID: "theteamid",
CreatePublicPlaybookRun: true,
CreateAt: 4503134,
DeleteAt: 0,
NumStages: 0,
NumSteps: 0,
Checklists: []Checklist{
{
ID: "checklist1",
Title: "checklist 1",
Items: nil,
},
},
BroadcastChannelIDs: []string{},
ReminderMessageTemplate: "This is a message",
ReminderTimerDefaultSeconds: 0,
InvitedUserIDs: nil,
InvitedGroupIDs: nil,
WebhookOnStatusUpdateURLs: []string{"testurl"},
WebhookOnStatusUpdateEnabled: true,
},
expected: []byte(`"checklists":[{"id":"checklist1","title":"checklist 1","items":[]}]`),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := json.Marshal(tt.original)
if (err != nil) != tt.wantErr {
t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
require.Contains(t, string(got), string(tt.expected))
})
}
}
func TestPlaybookFilterOptions_Clone(t *testing.T) {
options := PlaybookFilterOptions{
Page: 1,
PerPage: 10,
Sort: SortByID,
Direction: DirectionAsc,
}
marshalledOptions, err := json.Marshal(options)
require.NoError(t, err)
clone := options.Clone()
clone.Page = 2
clone.PerPage = 20
clone.Sort = SortByName
clone.Direction = DirectionDesc
var unmarshalledOptions PlaybookFilterOptions
err = json.Unmarshal(marshalledOptions, &unmarshalledOptions)
require.NoError(t, err)
require.Equal(t, options, unmarshalledOptions)
require.NotEqual(t, clone, unmarshalledOptions)
}
func TestPlaybookFilterOptions_Validate(t *testing.T) {
t.Run("non-positive PerPage", func(t *testing.T) {
options := PlaybookFilterOptions{
PerPage: -1,
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, PerPageDefault, validOptions.PerPage)
})
t.Run("invalid sort option", func(t *testing.T) {
options := PlaybookFilterOptions{
Sort: SortField("invalid"),
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("valid, but wrong case sort option", func(t *testing.T) {
options := PlaybookFilterOptions{
Sort: SortField("STAges"),
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, SortByStages, validOptions.Sort)
})
t.Run("valid, no explicit sort option", func(t *testing.T) {
options := PlaybookFilterOptions{}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, SortByID, validOptions.Sort)
})
t.Run("invalid sort direction", func(t *testing.T) {
options := PlaybookFilterOptions{
Direction: SortDirection("invalid"),
}
_, err := options.Validate()
require.Error(t, err)
})
t.Run("valid, but wrong case direction option", func(t *testing.T) {
options := PlaybookFilterOptions{
Direction: SortDirection("DEsC"),
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, DirectionDesc, validOptions.Direction)
})
t.Run("valid, no explicit direction", func(t *testing.T) {
options := PlaybookFilterOptions{}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, DirectionAsc, validOptions.Direction)
})
t.Run("valid", func(t *testing.T) {
options := PlaybookFilterOptions{
Page: 1,
PerPage: 10,
Sort: SortByTitle,
Direction: DirectionAsc,
}
validOptions, err := options.Validate()
require.NoError(t, err)
require.Equal(t, options, validOptions)
})
}

View File

@ -1,37 +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/playbooks/server/playbooks"
"github.com/pkg/errors"
)
var (
ErrChannelNotFound = errors.Errorf("channel not found")
ErrChannelDeleted = errors.Errorf("channel deleted")
ErrChannelNotInExpectedTeam = errors.Errorf("channel in different team")
)
func IsChannelActiveInTeam(channelID string, expectedTeamID string, api playbooks.ServicesAPI) error {
channel, err := api.GetChannelByID(channelID)
if err != nil {
return errors.Wrapf(ErrChannelNotFound, "channel with ID %s does not exist", channelID)
}
if channel.DeleteAt != 0 {
return errors.Wrapf(ErrChannelDeleted, "channel with ID %s is archived", channelID)
}
if channel.TeamId != expectedTeamID {
return errors.Wrapf(ErrChannelNotInExpectedTeam,
"channel with ID %s is on team with ID %s; expected team ID is %s",
channelID,
channel.TeamId,
expectedTeamID,
)
}
return nil
}

View File

@ -1,39 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"time"
)
func ShouldSendWeeklyDigestMessage(userInfo UserInfo, timezone *time.Location, currentTime time.Time) bool {
if userInfo.DigestNotificationSettings.DisableWeeklyDigest {
return false
}
lastSentTime := time.UnixMilli(userInfo.LastDailyTodoDMAt).In(timezone)
currentYear, currentWeek := currentTime.ISOWeek()
lastSentYear, lastSentWeek := lastSentTime.ISOWeek()
isFirstLoginOfTheWeek := currentYear != lastSentYear || currentWeek != lastSentWeek
return isFirstLoginOfTheWeek
}
func ShouldSendDailyDigestMessage(userInfo UserInfo, timezone *time.Location, currentTime time.Time) bool {
if userInfo.DigestNotificationSettings.DisableDailyDigest {
return false
}
// DM message if it's the next day and been more than an hour since the last post
// Hat tip to Github plugin for the logic.
lastSentTime := time.UnixMilli(userInfo.LastDailyTodoDMAt).In(timezone)
isMoreThanOneHourPassed := currentTime.Sub(lastSentTime).Hours() >= 1
isDifferentDay := currentTime.Day() != lastSentTime.Day() ||
currentTime.Month() != lastSentTime.Month() ||
currentTime.Year() != lastSentTime.Year()
return isMoreThanOneHourPassed && isDifferentDay
}

View File

@ -1,168 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"time"
)
func TestShouldSendWeeklyDigestMessage(t *testing.T) {
now, ok := time.Parse("2006-01-02", "2022-10-08")
if ok != nil {
t.Error("Could not parse current time")
}
type args struct {
userInfo UserInfo
timezone *time.Location
currentTime time.Time
}
tests := []struct {
name string
args args
want bool
}{
{
name: "Should not send a weekly digest if the user has configured it so",
args: args{
userInfo: UserInfo{
ID: "testUser",
LastDailyTodoDMAt: now.AddDate(0, 0, -6).UnixMilli(),
DigestNotificationSettings: DigestNotificationSettings{
DisableWeeklyDigest: true,
},
},
timezone: time.FixedZone("local", 0),
currentTime: now,
},
want: false,
},
{
name: "Should not send a weekly digest if we have already sent a digest this week",
args: args{
userInfo: UserInfo{
ID: "testUser",
LastDailyTodoDMAt: now.AddDate(0, 0, -1).UnixMilli(),
DigestNotificationSettings: DigestNotificationSettings{
DisableDailyDigest: false,
},
},
timezone: time.FixedZone("local", 0),
currentTime: now,
},
want: false,
},
{
name: "Should send a weekly digest if we have not sent a digest this week",
args: args{
userInfo: UserInfo{
ID: "testUser",
LastDailyTodoDMAt: now.AddDate(0, 0, -6).UnixMilli(),
DigestNotificationSettings: DigestNotificationSettings{
DisableDailyDigest: false,
},
},
timezone: time.FixedZone("local", 0),
currentTime: now,
},
want: true,
},
{
name: "Should send a weekly digest if we have not sent a digest ever",
args: args{
userInfo: UserInfo{
ID: "testUser",
LastDailyTodoDMAt: 0,
DigestNotificationSettings: DigestNotificationSettings{
DisableDailyDigest: false,
DisableWeeklyDigest: false,
},
},
timezone: time.FixedZone("local", 0),
currentTime: now,
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ShouldSendWeeklyDigestMessage(tt.args.userInfo, tt.args.timezone, tt.args.currentTime); got != tt.want {
t.Errorf("ShouldSendWeeklyDigestMessage() = %v, want %v", got, tt.want)
}
})
}
}
func TestShouldSendDailyDigestMessage(t *testing.T) {
now, ok := time.Parse("Jan 2, 2006 at 3:04pm", "Oct 8, 2022 at 3:04pm")
lateNow, lateOk := time.Parse("Jan 2, 2006 at 3:04pm", "Oct 8, 2022 at 12:10am")
if ok != nil || lateOk != nil {
t.Error("Could not parse current time")
}
type args struct {
userInfo UserInfo
timezone *time.Location
currentTime time.Time
}
tests := []struct {
name string
args args
want bool
}{
{
name: "Should not send a daily digest if we have already sent a digest today",
args: args{
userInfo: UserInfo{
ID: "testUser",
LastDailyTodoDMAt: now.Add(-((time.Hour * 1) + (time.Minute * 2))).UnixMilli(),
DigestNotificationSettings: DigestNotificationSettings{
DisableDailyDigest: false,
},
},
timezone: time.FixedZone("local", 0),
currentTime: now,
},
want: false,
},
{
name: "Should send a daily digest if we have not sent a digest today",
args: args{
userInfo: UserInfo{
ID: "testUser",
LastDailyTodoDMAt: now.Add(-(time.Hour * 25)).UnixMilli(),
DigestNotificationSettings: DigestNotificationSettings{
DisableDailyDigest: false,
},
},
timezone: time.FixedZone("local", 0),
currentTime: now,
},
want: true,
},
{
name: "Should not send a daily digest if we have sent one within the last hour",
args: args{
userInfo: UserInfo{
ID: "testUser",
LastDailyTodoDMAt: lateNow.Add(-(time.Minute * 40)).UnixMilli(),
DigestNotificationSettings: DigestNotificationSettings{
DisableDailyDigest: false,
},
},
timezone: time.FixedZone("local", 0),
currentTime: lateNow,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ShouldSendDailyDigestMessage(tt.args.userInfo, tt.args.timezone, tt.args.currentTime); got != tt.want {
t.Errorf("ShouldSendDailyDigestMessage() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -1,253 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const RetrospectivePrefix = "retro_"
// HandleReminder is the handler for all reminder events.
func (s *PlaybookRunServiceImpl) HandleReminder(key string) {
if strings.HasPrefix(key, RetrospectivePrefix) {
s.handleReminderToFillRetro(strings.TrimPrefix(key, RetrospectivePrefix))
} else {
s.handleStatusUpdateReminder(key)
}
}
func (s *PlaybookRunServiceImpl) handleReminderToFillRetro(playbookRunID string) {
logger := logrus.WithField("playbook_run_id", playbookRunID)
playbookRunToRemind, err := s.GetPlaybookRun(playbookRunID)
if err != nil {
logger.WithError(err).Errorf("handleReminderToFillRetro failed to get playbook run")
return
}
// In the meantime we did publish a retrospective, so no reminder.
if playbookRunToRemind.RetrospectivePublishedAt != 0 {
return
}
// If we are not in the finished state then don't remind
if playbookRunToRemind.CurrentStatus != StatusFinished {
return
}
if err = s.postRetrospectiveReminder(playbookRunToRemind, false); err != nil {
logger.WithError(err).Errorf("couldn't post reminder")
return
}
// Jobs can't be rescheduled within themselves with the same key. As a temporary workaround do it in a delayed goroutine
go func() {
time.Sleep(time.Second * 2)
if err = s.SetReminder(RetrospectivePrefix+playbookRunID, time.Duration(playbookRunToRemind.RetrospectiveReminderIntervalSeconds)*time.Second); err != nil {
logger.WithError(err).Errorf("failed to reocurr retrospective reminder")
return
}
}()
}
func (s *PlaybookRunServiceImpl) handleStatusUpdateReminder(playbookRunID string) {
logger := logrus.WithField("playbook_run_id", playbookRunID)
playbookRunToModify, err := s.GetPlaybookRun(playbookRunID)
if err != nil {
logger.WithError(err).Error("HandleReminder failed to get playbook run")
return
}
owner, err := s.api.GetUserByID(playbookRunToModify.OwnerUserID)
if err != nil {
logger.WithError(err).WithField("user_id", playbookRunToModify.OwnerUserID).Error("HandleReminder failed to get owner")
return
}
attachments := []*model.SlackAttachment{
{
Actions: []*model.PostAction{
{
Type: "button",
Name: "Update status",
Integration: &model.PostActionIntegration{
URL: fmt.Sprintf("/plugins/%s/api/v0/runs/%s/reminder/button-update",
"playbooks",
playbookRunToModify.ID),
},
},
},
},
}
post := &model.Post{
Message: fmt.Sprintf("@%s, please provide a status update for [%s](%s).", owner.Username, playbookRunToModify.Name, GetRunDetailsRelativeURL(playbookRunID)),
ChannelId: playbookRunToModify.ChannelID,
Type: "custom_update_status",
Props: map[string]any{
"targetUsername": owner.Username,
"playbookRunId": playbookRunToModify.ID,
},
}
model.ParseSlackAttachment(post, attachments)
if err = s.poster.PostMessageToThread("", post); err != nil {
logger.WithError(err).Errorf("HandleReminder error posting reminder message")
return
}
// broadcast to followers
message, err := s.buildOverdueStatusUpdateMessage(playbookRunToModify, owner.Username)
if err != nil {
logger.WithError(err).Error("failed to build overdue status update message")
} else {
err = s.dmPostToRunFollowers(&model.Post{Message: message}, overdueStatusUpdateMessage, playbookRunToModify.ID, "")
if err != nil {
logger.WithError(err).Error("failed to dm post to run followers")
}
}
playbookRunToModify.ReminderPostID = post.Id
if _, err = s.store.UpdatePlaybookRun(playbookRunToModify); err != nil {
logger.WithError(err).Error("error updating with reminder post id")
}
}
func (s *PlaybookRunServiceImpl) buildOverdueStatusUpdateMessage(playbookRun *PlaybookRun, ownerUserName string) (string, error) {
channel, err := s.api.GetChannelByID(playbookRun.ChannelID)
if err != nil {
return "", errors.Wrapf(err, "can't get channel - %s", playbookRun.ChannelID)
}
team, err := s.api.GetTeam(channel.TeamId)
if err != nil {
return "", errors.Wrapf(err, "can't get team - %s", channel.TeamId)
}
message := fmt.Sprintf("Status update is overdue for [%s](/%s/channels/%s?telem_action=todo_overduestatus_clicked&telem_run_id=%s&forceRHSOpen) (Owner: @%s)\n",
channel.DisplayName, team.Name, channel.Name, playbookRun.ID, ownerUserName)
return message, nil
}
// SetReminder sets a reminder. After timeInMinutes in the future, the owner will be
// reminded to update the playbook run's status.
func (s *PlaybookRunServiceImpl) SetReminder(playbookRunID string, fromNow time.Duration) error {
if _, err := s.scheduler.ScheduleOnce(playbookRunID, time.Now().Add(fromNow)); err != nil {
return errors.Wrap(err, "unable to schedule reminder")
}
return nil
}
// RemoveReminder removes the pending reminder for the given playbook run, if any.
func (s *PlaybookRunServiceImpl) RemoveReminder(playbookRunID string) {
s.scheduler.Cancel(playbookRunID)
}
// resetReminderTimer sets the previous reminder timer to 0.
func (s *PlaybookRunServiceImpl) resetReminderTimer(playbookRunID string) error {
playbookRunToModify, err := s.store.GetPlaybookRun(playbookRunID)
if err != nil {
return errors.Wrapf(err, "failed to retrieve playbook run")
}
playbookRunToModify.PreviousReminder = 0
playbookRunToModify, err = s.store.UpdatePlaybookRun(playbookRunToModify)
if err != nil {
return errors.Wrapf(err, "failed to update playbook run after resetting reminder timer")
}
s.poster.PublishWebsocketEventToChannel(playbookRunUpdatedWSEvent, playbookRunToModify, playbookRunToModify.ChannelID)
return nil
}
// ResetReminder creates a timeline event for a reminder being reset and then creates a new reminder
func (s *PlaybookRunServiceImpl) ResetReminder(playbookRunID string, newReminder time.Duration) error {
playbookRunToModify, err := s.store.GetPlaybookRun(playbookRunID)
if err != nil {
return errors.Wrapf(err, "failed to retrieve playbook run")
}
eventTime := model.GetMillis()
event := &TimelineEvent{
PlaybookRunID: playbookRunToModify.ID,
CreateAt: eventTime,
EventAt: eventTime,
EventType: StatusUpdateSnoozed,
SubjectUserID: playbookRunToModify.ReporterUserID,
}
if _, err := s.store.CreateTimelineEvent(event); err != nil {
return errors.Wrapf(err, "failed to create timeline event after resetting reminder timer")
}
return s.SetNewReminder(playbookRunID, newReminder)
}
// SetNewReminder sets a new reminder for playbookRunID, removes any pending reminder, removes the
// reminder post in the playbookRun's channel, and resets the PreviousReminder and
// LastStatusUpdateAt (so the countdown timer to "update due" shows the correct time)
func (s *PlaybookRunServiceImpl) SetNewReminder(playbookRunID string, newReminder time.Duration) error {
playbookRunToModify, err := s.store.GetPlaybookRun(playbookRunID)
if err != nil {
return errors.Wrapf(err, "failed to retrieve playbook run")
}
// Remove pending reminder (if any)
s.RemoveReminder(playbookRunID)
// Remove reminder post (if any)
if playbookRunToModify.ReminderPostID != "" {
if err = s.removePost(playbookRunToModify.ReminderPostID); err != nil {
return err
}
playbookRunToModify.ReminderPostID = ""
}
playbookRunToModify.PreviousReminder = newReminder
playbookRunToModify.LastStatusUpdateAt = model.GetMillis()
playbookRunToModify, err = s.store.UpdatePlaybookRun(playbookRunToModify)
if err != nil {
return errors.Wrapf(err, "failed to update playbook run after resetting reminder timer")
}
if newReminder != 0 {
if err = s.SetReminder(playbookRunID, newReminder); err != nil {
return errors.Wrap(err, "failed to set the reminder for playbook run")
}
}
s.poster.PublishWebsocketEventToChannel(playbookRunUpdatedWSEvent, playbookRunToModify, playbookRunToModify.ChannelID)
return nil
}
func (s *PlaybookRunServiceImpl) removePost(postID string) error {
post, err := s.api.GetPost(postID)
if err != nil {
return errors.Wrapf(err, "failed to retrieve reminder post %s", postID)
}
if post.DeleteAt != 0 {
return nil
}
if _, err = s.api.DeletePost(postID); err != nil {
return errors.Wrapf(err, "failed to delete reminder post %s", postID)
}
return nil
}

View File

@ -1,72 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
// SortField enumerates the available fields we can sort on.
type SortField string
const (
// SortByTitle sorts by the title field of a playbook.
SortByTitle SortField = "title"
// SortByStages sorts by the number of checklists in a playbook.
SortByStages SortField = "stages"
// SortBySteps sorts by the number of steps in a playbook.
SortBySteps SortField = "steps"
// SortByRuns sorts by the number of times a playbook has been run.
SortByRuns SortField = "runs"
// SortByCreateAt sorts by the created time of a playbook or playbook run.
SortByCreateAt SortField = "create_at"
// SortByID sorts by the primary key of a playbook or playbook run.
SortByID SortField = "id"
// SortByName sorts by the name of a playbook run.
SortByName SortField = "name"
// SortByOwnerUserID sorts by the user id of the owner of a playbook run.
SortByOwnerUserID SortField = "owner_user_id"
// SortByTeamID sorts by the team id of a playbook or playbook run.
SortByTeamID SortField = "team_id"
// SortByEndAt sorts by the end time of a playbook run.
SortByEndAt SortField = "end_at"
// SortByStatus sorts by the status of a playbook run.
SortByStatus SortField = "status"
// SortByLastStatusUpdateAt sorts by when the playbook run was last updated.
SortByLastStatusUpdateAt SortField = "last_status_update_at"
// SortByLastStatusUpdateAt sorts by when the playbook was last run.
SortByLastRunAt SortField = "last_run_at"
// SortByActiveRuns sorts by number of active runs in the playbook.
SortByActiveRuns SortField = "active_runs"
// SortByMetric0 ..3 sorts by the playbook's metric index
SortByMetric0 SortField = "metric0"
SortByMetric1 SortField = "metric1"
SortByMetric2 SortField = "metric2"
SortByMetric3 SortField = "metric3"
)
// SortDirection is the type used to specify the ascending or descending order of returned results.
type SortDirection string
const (
// DirectionDesc is descending order.
DirectionDesc SortDirection = "DESC"
// DirectionAsc is ascending order.
DirectionAsc SortDirection = "ASC"
)
func IsValidDirection(direction SortDirection) bool {
return direction == DirectionAsc || direction == DirectionDesc
}

View File

@ -1,153 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type TaskAction struct {
Trigger Trigger `json:"trigger"`
Actions []Action `json:"actions"`
}
type TaskActionType string
type TaskTriggerType string
type Trigger struct {
Type TaskTriggerType `json:"type"`
// Payload is the json payload that stores trigger specific settings or config.
// This should be unmarshalled into a concrete type during usage
Payload string `json:"payload"`
}
type Action struct {
Type TaskActionType `json:"type"`
// Payload is the json payload that stores action specific settings or config.
// This should be unmarshalled into a concrete type during usage
Payload string `json:"payload"`
}
// Known Types
const (
KeywordsByUsersTriggerType TaskTriggerType = "keywords_by_users"
MarkItemAsDoneActionType TaskActionType = "mark_item_as_done"
)
var (
ValidTaskActionTypes = []TaskActionType{
MarkItemAsDoneActionType,
}
)
// Triggers
type KeywordsByUsersTrigger struct {
typ TaskTriggerType
Payload KeywordsByUsersTriggerPayload
}
type KeywordsByUsersTriggerPayload struct {
Keywords []string `json:"keywords" mapstructure:"keywords"`
UserIDs []string `json:"user_ids" mapstructure:"user_ids"`
}
func NewKeywordsByUsersTrigger(trigger Trigger) (*KeywordsByUsersTrigger, error) {
if trigger.Type != KeywordsByUsersTriggerType {
return nil, errors.Errorf("Unexpected trigger type: %s, expected: %s", trigger.Type, KeywordsByUsersTriggerType)
}
var t KeywordsByUsersTrigger
t.typ = KeywordsByUsersTriggerType
if err := json.Unmarshal([]byte(trigger.Payload), &t.Payload); err != nil {
return nil, errors.New("unable to decode payload from trigger")
}
return &t, nil
}
func (t *KeywordsByUsersTrigger) IsValid() error {
return nil
}
func (t *KeywordsByUsersTrigger) IsTriggered(post *model.Post) bool {
foundUser := false
if len(t.Payload.UserIDs) > 0 {
for _, userID := range t.Payload.UserIDs {
if post.UserId == userID {
foundUser = true
break
}
}
} else {
foundUser = true
}
if foundUser {
for _, keyword := range t.Payload.Keywords {
if strings.Contains(post.Message, keyword) {
logrus.WithField("keyword", keyword)
return true
}
}
}
return false
}
// Actions
type MarkItemAsDoneAction struct {
typ TaskActionType
Payload MarkItemAsDoneActionPayload
}
type MarkItemAsDoneActionPayload struct {
Enabled bool `json:"enabled"`
}
func NewMarkItemAsDoneAction(action Action) (*MarkItemAsDoneAction, error) {
if action.Type != MarkItemAsDoneActionType {
return nil, errors.Errorf("Unexpected trigger type: %s, expected: %s", action.Type, MarkItemAsDoneActionType)
}
var a MarkItemAsDoneAction
a.typ = MarkItemAsDoneActionType
if err := json.Unmarshal([]byte(action.Payload), &a.Payload); err != nil {
return nil, errors.New("unable to decode payload from trigger")
}
return &a, nil
}
func (a *MarkItemAsDoneAction) IsValid() error {
return nil
}
// Validators
func ValidateTrigger(t Trigger) error {
switch t.Type {
case KeywordsByUsersTriggerType:
trigger, err := NewKeywordsByUsersTrigger(t)
if err != nil {
return err
}
return trigger.IsValid()
default:
return errors.Errorf("Unknown task trigger type: %s", t.Type)
}
}
func ValidateAction(a Action) error {
switch a.Type {
case MarkItemAsDoneActionType:
action, err := NewMarkItemAsDoneAction(a)
if err != nil {
return err
}
return action.IsValid()
default:
return errors.Errorf("Unknown task action type: %s", a.Type)
}
}

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