diff --git a/config.yml.sample b/config.yml.sample
index 85b80ec1..1b44369d 100644
--- a/config.yml.sample
+++ b/config.yml.sample
@@ -28,6 +28,8 @@ service:
enabletaskattachments: true
# The time zone all timestamps are in
timezone: GMT
+ # Whether task comments should be enabled or not
+ enabletaskcomments: true
database:
# Database type to use. Supported types are mysql, postgres and sqlite.
diff --git a/docs/content/doc/setup/config.md b/docs/content/doc/setup/config.md
index dd4ebc44..bfff15fa 100644
--- a/docs/content/doc/setup/config.md
+++ b/docs/content/doc/setup/config.md
@@ -71,6 +71,8 @@ service:
enabletaskattachments: true
# The time zone all timestamps are in
timezone: GMT
+ # Whether task comments should be enabled or not
+ enabletaskcomments: true
database:
# Database type to use. Supported types are mysql, postgres and sqlite.
diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md
index a312284f..7010deeb 100644
--- a/docs/content/doc/usage/errors.md
+++ b/docs/content/doc/usage/errors.md
@@ -48,6 +48,7 @@ This document describes the different errors Vikunja can return.
| 4012 | 400 | The task attachment is too large. |
| 4013 | 400 | The task sort param is invalid. |
| 4014 | 400 | The task sort order is invalid. |
+| 4015 | 404 | The task comment does not exist. |
| 5001 | 404 | The namspace does not exist. |
| 5003 | 403 | The user does not have access to the specified namespace. |
| 5006 | 400 | The namespace name cannot be empty. |
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 92604e8b..a2a9cd06 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -45,6 +45,7 @@ const (
ServiceEnableRegistration Key = `service.enableregistration`
ServiceEnableTaskAttachments Key = `service.enabletaskattachments`
ServiceTimeZone Key = `service.timezone`
+ ServiceEnableTaskComments Key = `service.enabletaskcomments`
DatabaseType Key = `database.type`
DatabaseHost Key = `database.host`
@@ -171,6 +172,7 @@ func InitDefaultConfig() {
ServiceEnableRegistration.setDefault(true)
ServiceEnableTaskAttachments.setDefault(true)
ServiceTimeZone.setDefault("GMT")
+ ServiceEnableTaskComments.setDefault(true)
// Database
DatabaseType.setDefault("sqlite")
diff --git a/pkg/db/fixtures/task_comments.yml b/pkg/db/fixtures/task_comments.yml
new file mode 100644
index 00000000..76f599fe
--- /dev/null
+++ b/pkg/db/fixtures/task_comments.yml
@@ -0,0 +1,84 @@
+- id: 1
+ comment: Lorem Ipsum Dolor Sit Amet
+ author_id: 1
+ task_id: 1
+ created: 1582135626
+ updated: 1582135626
+- id: 2
+ comment: comment 2
+ author_id: 5
+ task_id: 14
+ created: 1582135626
+ updated: 1582135626
+- id: 3
+ comment: comment 3
+ author_id: 5
+ task_id: 15
+ created: 1582135626
+ updated: 1582135626
+- id: 4
+ comment: comment 4
+ author_id: 6
+ task_id: 16
+ created: 1582135626
+ updated: 1582135626
+- id: 5
+ comment: comment 5
+ author_id: 6
+ task_id: 17
+ created: 1582135626
+ updated: 1582135626
+- id: 6
+ comment: comment 6
+ author_id: 6
+ task_id: 18
+ created: 1582135626
+ updated: 1582135626
+- id: 7
+ comment: comment 7
+ author_id: 6
+ task_id: 19
+ created: 1582135626
+ updated: 1582135626
+- id: 8
+ comment: comment 8
+ author_id: 6
+ task_id: 20
+ created: 1582135626
+ updated: 1582135626
+- id: 9
+ comment: comment 9
+ author_id: 6
+ task_id: 21
+ created: 1582135626
+ updated: 1582135626
+- id: 10
+ comment: comment 10
+ author_id: 6
+ task_id: 22
+ created: 1582135626
+ updated: 1582135626
+- id: 11
+ comment: comment 11
+ author_id: 6
+ task_id: 23
+ created: 1582135626
+ updated: 1582135626
+- id: 12
+ comment: comment 12
+ author_id: 6
+ task_id: 24
+ created: 1582135626
+ updated: 1582135626
+- id: 13
+ comment: comment 13
+ author_id: 6
+ task_id: 25
+ created: 1582135626
+ updated: 1582135626
+- id: 14
+ comment: comment 14
+ author_id: 6
+ task_id: 26
+ created: 1582135626
+ updated: 1582135626
diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml
index 38818119..d7f7fae8 100644
--- a/pkg/db/fixtures/tasks.yml
+++ b/pkg/db/fixtures/tasks.yml
@@ -243,4 +243,13 @@
percent_done: 0.5
created: 1543626724
updated: 1543626724
+ # This task is forbidden for user1
+- id: 34
+ text: 'task #34'
+ created_by_id: 13
+ list_id: 20
+ index: 20
+ created: 1543626724
+ updated: 1543626724
+
diff --git a/pkg/integrations/task_comment_test.go b/pkg/integrations/task_comment_test.go
new file mode 100644
index 00000000..e1a8bc5a
--- /dev/null
+++ b/pkg/integrations/task_comment_test.go
@@ -0,0 +1,284 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package integrations
+
+import (
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/web/handler"
+ "github.com/labstack/echo/v4"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestTaskComments(t *testing.T) {
+ testHandler := webHandlerTest{
+ user: &testuser1,
+ strFunc: func() handler.CObject {
+ return &models.TaskComment{}
+ },
+ t: t,
+ }
+ // Only run specific nested tests:
+ // ^TestTaskComments$/^Update$/^Update_task_items$/^Removing_Assignees_null$
+ t.Run("Update", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "1", "commentid": "1"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "99999", "commentid": "9999"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
+ })
+ t.Run("Rights check", func(t *testing.T) {
+ t.Run("Forbidden", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "14", "commentid": "2"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via Team readonly", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "15", "commentid": "3"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via Team write", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "16", "commentid": "4"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via Team admin", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "17", "commentid": "5"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+
+ t.Run("Shared Via User readonly", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "18", "commentid": "6"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via User write", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "19", "commentid": "7"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via User admin", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "20", "commentid": "8"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+
+ t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "21", "commentid": "9"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "22", "commentid": "10"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "23", "commentid": "11"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+
+ t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "24", "commentid": "12"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "25", "commentid": "13"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "26", "commentid": "14"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ })
+ })
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "1", "commentid": "1"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "99999", "commentid": "9999"})
+ assert.Error(t, err)
+ assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
+ })
+ t.Run("Rights check", func(t *testing.T) {
+ t.Run("Forbidden", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "14", "commentid": "2"})
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via Team readonly", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "15", "commentid": "3"})
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via Team write", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "16", "commentid": "4"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+ t.Run("Shared Via Team admin", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "17", "commentid": "5"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+
+ t.Run("Shared Via User readonly", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "18", "commentid": "6"})
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via User write", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "19", "commentid": "7"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+ t.Run("Shared Via User admin", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "20", "commentid": "8"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+
+ t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "21", "commentid": "9"})
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "22", "commentid": "10"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+ t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "23", "commentid": "11"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+
+ t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "24", "commentid": "12"})
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "25", "commentid": "13"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+ t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "26", "commentid": "14"})
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Successfully deleted.`)
+ })
+ })
+ })
+ t.Run("Create", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "1"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "9999"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
+ })
+ t.Run("Rights check", func(t *testing.T) {
+ t.Run("Forbidden", func(t *testing.T) {
+ // Owned by user13
+ _, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "34"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via Team readonly", func(t *testing.T) {
+ _, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "15"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via Team write", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "16"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via Team admin", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "17"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+
+ t.Run("Shared Via User readonly", func(t *testing.T) {
+ _, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "18"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via User write", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "19"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via User admin", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "20"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+
+ t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
+ _, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "21"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "22"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "23"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+
+ t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
+ _, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "24"}, `{"comment":"Lorem Ipsum"}`)
+ assert.Error(t, err)
+ assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
+ })
+ t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "25"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, map[string]string{"task": "26"}, `{"comment":"Lorem Ipsum"}`)
+ assert.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`)
+ })
+ })
+ })
+}
diff --git a/pkg/migration/20200219183248.go b/pkg/migration/20200219183248.go
new file mode 100644
index 00000000..c2e2ac42
--- /dev/null
+++ b/pkg/migration/20200219183248.go
@@ -0,0 +1,51 @@
+// Vikunja is a todo-list application to facilitate your life.
+// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package migration
+
+import (
+ "code.vikunja.io/api/pkg/timeutil"
+ "src.techknowlogick.com/xormigrate"
+ "xorm.io/xorm"
+)
+
+type taskComments20200219183248 struct {
+ ID int64 `xorm:"autoincr pk unique not null" json:"id" param:"comment"`
+ Comment string `xorm:"text not null" json:"comment"`
+ AuthorID int64 `xorm:"not null" json:"-"`
+ TaskID int64 `xorm:"not null" json:"-" param:"task"`
+
+ Created timeutil.TimeStamp `xorm:"created"`
+ Updated timeutil.TimeStamp `xorm:"updated"`
+}
+
+func (s taskComments20200219183248) TableName() string {
+ return "task_comments"
+}
+
+func init() {
+ migrations = append(migrations, &xormigrate.Migration{
+ ID: "20200219183248",
+ Description: "Add task comments table",
+ Migrate: func(tx *xorm.Engine) error {
+ return tx.Sync2(taskComments20200219183248{})
+ },
+ Rollback: func(tx *xorm.Engine) error {
+ return tx.DropTables(taskComments20200219183248{})
+ },
+ })
+
+}
diff --git a/pkg/models/error.go b/pkg/models/error.go
index e464a137..77b70ba6 100644
--- a/pkg/models/error.go
+++ b/pkg/models/error.go
@@ -276,7 +276,7 @@ const ErrCodeTaskDoesNotExist = 4002
// HTTPError holds the http error description
func (err ErrTaskDoesNotExist) HTTPError() web.HTTPError {
- return web.HTTPError{HTTPCode: http.StatusNotFound, Code: ErrCodeTaskDoesNotExist, Message: "This list task does not exist"}
+ return web.HTTPError{HTTPCode: http.StatusNotFound, Code: ErrCodeTaskDoesNotExist, Message: "This task does not exist"}
}
// ErrBulkTasksMustBeInSameList represents a "ErrBulkTasksMustBeInSameList" kind of error.
@@ -602,6 +602,34 @@ func (err ErrInvalidSortOrder) HTTPError() web.HTTPError {
}
}
+// ErrTaskCommentDoesNotExist represents an error where a task comment does not exist
+type ErrTaskCommentDoesNotExist struct {
+ ID int64
+ TaskID int64
+}
+
+// IsErrTaskCommentDoesNotExist checks if an error is ErrTaskCommentDoesNotExist.
+func IsErrTaskCommentDoesNotExist(err error) bool {
+ _, ok := err.(ErrTaskCommentDoesNotExist)
+ return ok
+}
+
+func (err ErrTaskCommentDoesNotExist) Error() string {
+ return fmt.Sprintf("Task comment does not exist [ID: %d, TaskID: %d]", err.ID, err.TaskID)
+}
+
+// ErrCodeTaskCommentDoesNotExist holds the unique world-error code of this error
+const ErrCodeTaskCommentDoesNotExist = 4015
+
+// HTTPError holds the http error description
+func (err ErrTaskCommentDoesNotExist) HTTPError() web.HTTPError {
+ return web.HTTPError{
+ HTTPCode: http.StatusNotFound,
+ Code: ErrCodeTaskCommentDoesNotExist,
+ Message: "This task comment does not exist",
+ }
+}
+
// =================
// Namespace errors
// =================
diff --git a/pkg/models/models.go b/pkg/models/models.go
index bfa3f133..7a0e22ef 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -50,6 +50,7 @@ func GetTables() []interface{} {
&LinkSharing{},
&TaskRelation{},
&TaskAttachment{},
+ &TaskComment{},
}
}
diff --git a/pkg/models/task_comment_rights.go b/pkg/models/task_comment_rights.go
new file mode 100644
index 00000000..b1fec2d5
--- /dev/null
+++ b/pkg/models/task_comment_rights.go
@@ -0,0 +1,44 @@
+// Copyright 2020 Vikunja and contriubtors. All rights reserved.
+//
+// This file is part of Vikunja.
+//
+// Vikunja is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Vikunja is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Vikunja. If not, see .
+
+package models
+
+import "code.vikunja.io/web"
+
+// CanRead checks if a user can read a comment
+func (tc *TaskComment) CanRead(a web.Auth) (bool, error) {
+ t := Task{ID: tc.TaskID}
+ return t.CanRead(a)
+}
+
+// CanDelete checks if a user can delete a comment
+func (tc *TaskComment) CanDelete(a web.Auth) (bool, error) {
+ t := Task{ID: tc.TaskID}
+ return t.CanWrite(a)
+}
+
+// CanUpdate checks if a user can update a comment
+func (tc *TaskComment) CanUpdate(a web.Auth) (bool, error) {
+ t := Task{ID: tc.TaskID}
+ return t.CanWrite(a)
+}
+
+// CanCreate checks if a user can create a new comment
+func (tc *TaskComment) CanCreate(a web.Auth) (bool, error) {
+ t := Task{ID: tc.TaskID}
+ return t.CanWrite(a)
+}
diff --git a/pkg/models/task_comments.go b/pkg/models/task_comments.go
new file mode 100644
index 00000000..41ae22eb
--- /dev/null
+++ b/pkg/models/task_comments.go
@@ -0,0 +1,209 @@
+// Copyright 2020 Vikunja and contriubtors. All rights reserved.
+//
+// This file is part of Vikunja.
+//
+// Vikunja is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Vikunja is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Vikunja. If not, see .
+
+package models
+
+import (
+ "code.vikunja.io/api/pkg/timeutil"
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/web"
+)
+
+// TaskComment represents a task comment
+type TaskComment struct {
+ ID int64 `xorm:"autoincr pk unique not null" json:"id" param:"commentid"`
+ Comment string `xorm:"text not null" json:"comment"`
+ AuthorID int64 `xorm:"not null" json:"-"`
+ Author *user.User `xorm:"-" json:"author"`
+ TaskID int64 `xorm:"not null" json:"-" param:"task"`
+
+ Created timeutil.TimeStamp `xorm:"created" json:"created"`
+ Updated timeutil.TimeStamp `xorm:"updated" json:"updated"`
+
+ web.CRUDable `xorm:"-" json:"-"`
+ web.Rights `xorm:"-" json:"-"`
+}
+
+// TableName holds the table name for the task comments table
+func (tc *TaskComment) TableName() string {
+ return "task_comments"
+}
+
+// Create creates a new task comment
+// @Summary Create a new task comment
+// @Description Create a new task comment. The user doing this need to have at least write access to the task this comment should belong to.
+// @tags task
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param relation body models.TaskComment true "The task comment object"
+// @Param taskID path int true "Task ID"
+// @Success 200 {object} models.TaskComment "The created task comment object."
+// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid task comment object provided."
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /tasks/{taskID}/comments [put]
+func (tc *TaskComment) Create(a web.Auth) (err error) {
+ // Check if the task exists
+ _, err = GetTaskSimple(&Task{ID: tc.TaskID})
+ if err != nil {
+ return err
+ }
+
+ tc.AuthorID = a.GetID()
+ _, err = x.Insert(tc)
+ return
+}
+
+// Delete removes a task comment
+// @Summary Remove a task comment
+// @Description Remove a task comment. The user doing this need to have at least write access to the task this comment belongs to.
+// @tags task
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param taskID path int true "Task ID"
+// @Param commentID path int true "Comment ID"
+// @Success 200 {object} models.Message "The task comment was successfully deleted."
+// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid task comment object provided."
+// @Failure 404 {object} code.vikunja.io/web.HTTPError "The task comment was not found."
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /tasks/{taskID}/comments/{commentID} [delete]
+func (tc *TaskComment) Delete() error {
+ deleted, err := x.ID(tc.ID).NoAutoCondition().Delete(tc)
+ if deleted == 0 {
+ return ErrTaskCommentDoesNotExist{ID: tc.ID}
+ }
+ return err
+}
+
+// Update updates a task text by its ID
+// @Summary Update an existing task comment
+// @Description Update an existing task comment. The user doing this need to have at least write access to the task this comment belongs to.
+// @tags task
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param taskID path int true "Task ID"
+// @Param commentID path int true "Comment ID"
+// @Success 200 {object} models.TaskComment "The updated task comment object."
+// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid task comment object provided."
+// @Failure 404 {object} code.vikunja.io/web.HTTPError "The task comment was not found."
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /tasks/{taskID}/comments/{commentID} [post]
+func (tc *TaskComment) Update() error {
+ updated, err := x.ID(tc.ID).Cols("comment").Update(tc)
+ if updated == 0 {
+ return ErrTaskCommentDoesNotExist{ID: tc.ID}
+ }
+ return err
+}
+
+// ReadOne handles getting a single comment
+// @Summary Remove a task comment
+// @Description Remove a task comment. The user doing this need to have at least read access to the task this comment belongs to.
+// @tags task
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param taskID path int true "Task ID"
+// @Param commentID path int true "Comment ID"
+// @Success 200 {object} models.TaskComment "The task comment object."
+// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid task comment object provided."
+// @Failure 404 {object} code.vikunja.io/web.HTTPError "The task comment was not found."
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /tasks/{taskID}/comments/{commentID} [get]
+func (tc *TaskComment) ReadOne() (err error) {
+ exists, err := x.Get(tc)
+ if err != nil {
+ return
+ }
+ if !exists {
+ return ErrTaskCommentDoesNotExist{
+ ID: tc.ID,
+ TaskID: tc.TaskID,
+ }
+ }
+
+ // Get the author
+ author := &user.User{}
+ _, err = x.
+ Where("id = ?", tc.AuthorID).
+ Get(author)
+ tc.Author = author
+ return
+}
+
+// ReadAll returns all comments for a task
+// @Summary Get all task comments
+// @Description Get all task comments. The user doing this need to have at least read access to the task.
+// @tags task
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param taskID path int true "Task ID"
+// @Success 200 {array} models.TaskComment "The array with all task comments"
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /tasks/{taskID}/comments [get]
+func (tc *TaskComment) ReadAll(auth web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
+
+ // Check if the user has access to the task
+ canRead, err := tc.CanRead(auth)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ if !canRead {
+ return nil, 0, 0, ErrGenericForbidden{}
+ }
+
+ // Because we can't extend the type in general, we need to do this here.
+ // Not a good solution, but saves performance.
+ type TaskCommentWithAuthor struct {
+ TaskComment
+ AuthorFromDB *user.User `xorm:"extends" json:"-"`
+ }
+
+ comments := []*TaskComment{}
+ err = x.
+ Where("task_id = ? AND comment like ?", tc.TaskID, "%"+search+"%").
+ Join("LEFT", "users", "users.id = task_comments.author_id").
+ Limit(getLimitFromPageIndex(page, perPage)).
+ Find(&comments)
+ if err != nil {
+ return
+ }
+
+ // Get all authors
+ authors := make(map[int64]*user.User)
+ err = x.
+ Select("users.*").
+ Table("task_comments").
+ Where("task_id = ? AND comment like ?", tc.TaskID, "%"+search+"%").
+ Join("INNER", "users", "users.id = task_comments.author_id").
+ Find(&authors)
+ if err != nil {
+ return
+ }
+
+ for _, comment := range comments {
+ comment.Author = authors[comment.AuthorID]
+ }
+
+ numberOfTotalItems, err = x.
+ Where("task_id = ? AND comment like ?", tc.TaskID, "%"+search+"%").
+ Count(&TaskCommentWithAuthor{})
+ return comments, len(comments), numberOfTotalItems, err
+}
diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go
new file mode 100644
index 00000000..6127d2d0
--- /dev/null
+++ b/pkg/models/task_comments_test.go
@@ -0,0 +1,126 @@
+// Vikunja is a todo-list application to facilitate your life.
+// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package models
+
+import (
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/user"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestTaskComment_Create(t *testing.T) {
+ u := &user.User{ID: 1}
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{
+ Comment: "test",
+ TaskID: 1,
+ }
+ err := tc.Create(u)
+ assert.NoError(t, err)
+ })
+ t.Run("nonexisting task", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{
+ Comment: "test",
+ TaskID: 99999,
+ }
+ err := tc.Create(u)
+ assert.Error(t, err)
+ assert.True(t, IsErrTaskDoesNotExist(err))
+ })
+}
+
+func TestTaskComment_Delete(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{ID: 1}
+ err := tc.Delete()
+ assert.NoError(t, err)
+ })
+ t.Run("nonexisting comment", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{ID: 9999}
+ err := tc.Delete()
+ assert.Error(t, err)
+ assert.True(t, IsErrTaskCommentDoesNotExist(err))
+ })
+}
+
+func TestTaskComment_Update(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{
+ ID: 1,
+ Comment: "testing",
+ }
+ err := tc.Update()
+ assert.NoError(t, err)
+ })
+ t.Run("nonexisting comment", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{
+ ID: 9999,
+ }
+ err := tc.Update()
+ assert.Error(t, err)
+ assert.True(t, IsErrTaskCommentDoesNotExist(err))
+ })
+}
+
+func TestTaskComment_ReadOne(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{ID: 1}
+ err := tc.ReadOne()
+ assert.NoError(t, err)
+ assert.Equal(t, "Lorem Ipsum Dolor Sit Amet", tc.Comment)
+ assert.NotEmpty(t, tc.Author.ID)
+ })
+ t.Run("nonexisting", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{ID: 9999}
+ err := tc.ReadOne()
+ assert.Error(t, err)
+ assert.True(t, IsErrTaskCommentDoesNotExist(err))
+ })
+}
+
+func TestTaskComment_ReadAll(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{TaskID: 1}
+ u := &user.User{ID: 1}
+ result, resultCount, total, err := tc.ReadAll(u, "", 0, -1)
+ resultComment := result.([]*TaskComment)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, resultCount)
+ assert.Equal(t, int64(1), total)
+ assert.Equal(t, int64(1), resultComment[0].ID)
+ assert.Equal(t, "Lorem Ipsum Dolor Sit Amet", resultComment[0].Comment)
+ assert.NotEmpty(t, resultComment[0].Author.ID)
+ })
+ t.Run("no access to task", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ tc := &TaskComment{TaskID: 14}
+ u := &user.User{ID: 1}
+ _, _, _, err := tc.ReadAll(u, "", 0, -1)
+ assert.Error(t, err)
+ assert.True(t, IsErrGenericForbidden(err))
+ })
+}
diff --git a/pkg/models/tasks_rights.go b/pkg/models/tasks_rights.go
index 7e92a082..4771a995 100644
--- a/pkg/models/tasks_rights.go
+++ b/pkg/models/tasks_rights.go
@@ -64,7 +64,7 @@ func (t *Task) canDoTask(a web.Auth) (bool, error) {
return false, err
}
- // A user can do a task if he has write acces to its list
+ // A user can do a task if it has write acces to its list
l := &List{ID: lI.ListID}
return l.CanWrite(a)
}
diff --git a/pkg/models/unit_tests.go b/pkg/models/unit_tests.go
index c3a35ace..442a6be0 100644
--- a/pkg/models/unit_tests.go
+++ b/pkg/models/unit_tests.go
@@ -46,6 +46,7 @@ func SetupTests() {
"namespaces",
"task_assignees",
"task_attachments",
+ "task_comments",
"task_relations",
"task_reminders",
"tasks",
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index e5814b32..142a479e 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -305,6 +305,19 @@ func registerAPIRoutes(a *echo.Group) {
a.GET("/tasks/:task/attachments/:attachment", apiv1.GetTaskAttachment)
}
+ if config.ServiceEnableTaskComments.GetBool() {
+ taskCommentHandler := &handler.WebHandler{
+ EmptyStruct: func() handler.CObject {
+ return &models.TaskComment{}
+ },
+ }
+ a.GET("/tasks/:task/comments", taskCommentHandler.ReadAllWeb)
+ a.PUT("/tasks/:task/comments", taskCommentHandler.CreateWeb)
+ a.DELETE("/tasks/:task/comments/:commentid", taskCommentHandler.DeleteWeb)
+ a.POST("/tasks/:task/comments/:commentid", taskCommentHandler.UpdateWeb)
+ a.GET("/tasks/:task/comments/:commentid", taskCommentHandler.ReadOneWeb)
+ }
+
labelHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.Label{}