diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md
index 883426323e0..8cbcd250f41 100644
--- a/docs/content/doc/usage/errors.md
+++ b/docs/content/doc/usage/errors.md
@@ -1,11 +1,10 @@
---
date: "2019-02-12:00:00+02:00"
title: "Errors"
-draft: false
-type: "doc"
+draft: false type: "doc"
menu:
- sidebar:
- parent: "usage"
+sidebar:
+parent: "usage"
---
# Errors
@@ -142,3 +141,10 @@ This document describes the different errors Vikunja can return.
|-----------|------------------|-------------|
| 11001 | 404 | The saved filter does not exist. |
| 11002 | 412 | Saved filters are not available for link shares. |
+
+## Subscriptions
+
+| ErrorCode | HTTP Status Code | Description |
+|-----------|------------------|-------------|
+| 12001 | 412 | The subscription entity type is invalid. |
+| 12002 | 412 | The user is already subscribed to the entity itself or a parent entity. |
diff --git a/magefile.go b/magefile.go
index a4733b4315c..1aac4d31f0b 100644
--- a/magefile.go
+++ b/magefile.go
@@ -827,7 +827,7 @@ func (s *` + name + `) Name() string {
return "` + listenerName + `"
}
-// Hanlde is executed when the event ` + name + ` listens on is fired
+// Handle is executed when the event ` + name + ` listens on is fired
func (s *` + name + `) Handle(payload message.Payload) (err error) {
event := &` + event + `{}
err = json.Unmarshal(payload, event)
diff --git a/pkg/db/fixtures/subscriptions.yml b/pkg/db/fixtures/subscriptions.yml
new file mode 100644
index 00000000000..aa4c42f4f9d
--- /dev/null
+++ b/pkg/db/fixtures/subscriptions.yml
@@ -0,0 +1,35 @@
+- id: 1
+ entity_type: 3 # Task
+ entity_id: 2
+ user_id: 1
+ created: 2021-02-01 15:13:12
+- id: 2
+ entity_type: 1 # Namespace
+ entity_id: 6
+ user_id: 6
+ created: 2021-02-01 15:13:12
+- id: 3
+ entity_type: 2 # List
+ entity_id: 12 # belongs to namespace 7
+ user_id: 6
+ created: 2021-02-01 15:13:12
+- id: 4
+ entity_type: 3 # Task
+ entity_id: 22 # belongs to list 13 which belongs to namespace 8
+ user_id: 6
+ created: 2021-02-01 15:13:12
+- id: 5
+ entity_type: 1 # Namespace
+ entity_id: 8
+ user_id: 6
+ created: 2021-02-01 15:13:12
+- id: 6
+ entity_type: 2 # List
+ entity_id: 13
+ user_id: 6
+ created: 2021-02-01 15:13:12
+- id: 7
+ entity_type: 3 # Task
+ entity_id: 26
+ user_id: 6
+ created: 2021-02-01 15:13:12
diff --git a/pkg/migration/20210209204715.go b/pkg/migration/20210209204715.go
new file mode 100644
index 00000000000..1bca9e50857
--- /dev/null
+++ b/pkg/migration/20210209204715.go
@@ -0,0 +1,49 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
+//
+// You should have received a copy of the GNU Affero General Public Licensee
+// along with this program. If not, see .
+
+package migration
+
+import (
+ "time"
+
+ "src.techknowlogick.com/xormigrate"
+ "xorm.io/xorm"
+)
+
+type subscriptions20210209204715 struct {
+ ID int64 `xorm:"autoincr not null unique pk" json:"id"`
+ EntityType int `xorm:"index not null" json:"-"`
+ EntityID int64 `xorm:"bigint index not null" json:"entity_id"`
+ UserID int64 `xorm:"bigint index not null" json:"-"`
+ Created time.Time `xorm:"created not null" json:"created"`
+}
+
+func (subscriptions20210209204715) TableName() string {
+ return "subscriptions"
+}
+
+func init() {
+ migrations = append(migrations, &xormigrate.Migration{
+ ID: "20210209204715",
+ Description: "",
+ Migrate: func(tx *xorm.Engine) error {
+ return tx.Sync2(subscriptions20210209204715{})
+ },
+ Rollback: func(tx *xorm.Engine) error {
+ return nil
+ },
+ })
+}
diff --git a/pkg/models/error.go b/pkg/models/error.go
index 041a1150b12..d71f5bb7854 100644
--- a/pkg/models/error.go
+++ b/pkg/models/error.go
@@ -1423,3 +1423,63 @@ func (err ErrSavedFilterNotAvailableForLinkShare) HTTPError() web.HTTPError {
Message: "Saved filters are not available for link shares.",
}
}
+
+// =============
+// Subscriptions
+// =============
+
+// ErrUnknownSubscriptionEntityType represents an error where a subscription entity type is unknown
+type ErrUnknownSubscriptionEntityType struct {
+ EntityType SubscriptionEntityType
+}
+
+// IsErrUnknownSubscriptionEntityType checks if an error is ErrUnknownSubscriptionEntityType.
+func IsErrUnknownSubscriptionEntityType(err error) bool {
+ _, ok := err.(*ErrUnknownSubscriptionEntityType)
+ return ok
+}
+
+func (err *ErrUnknownSubscriptionEntityType) Error() string {
+ return fmt.Sprintf("Subscription entity type is unkowns [EntityType: %d]", err.EntityType)
+}
+
+// ErrCodeUnknownSubscriptionEntityType holds the unique world-error code of this error
+const ErrCodeUnknownSubscriptionEntityType = 12001
+
+// HTTPError holds the http error description
+func (err ErrUnknownSubscriptionEntityType) HTTPError() web.HTTPError {
+ return web.HTTPError{
+ HTTPCode: http.StatusPreconditionFailed,
+ Code: ErrCodeUnknownSubscriptionEntityType,
+ Message: "The subscription entity type is invalid.",
+ }
+}
+
+// ErrSubscriptionAlreadyExists represents an error where a subscription entity already exists
+type ErrSubscriptionAlreadyExists struct {
+ EntityID int64
+ EntityType SubscriptionEntityType
+ UserID int64
+}
+
+// IsErrSubscriptionAlreadyExists checks if an error is ErrSubscriptionAlreadyExists.
+func IsErrSubscriptionAlreadyExists(err error) bool {
+ _, ok := err.(*ErrSubscriptionAlreadyExists)
+ return ok
+}
+
+func (err *ErrSubscriptionAlreadyExists) Error() string {
+ return fmt.Sprintf("Subscription for this (entity_id, entity_type, user_id) already exists [EntityType: %d, EntityID: %d, UserID: %d]", err.EntityType, err.EntityID, err.UserID)
+}
+
+// ErrCodeSubscriptionAlreadyExists holds the unique world-error code of this error
+const ErrCodeSubscriptionAlreadyExists = 12002
+
+// HTTPError holds the http error description
+func (err ErrSubscriptionAlreadyExists) HTTPError() web.HTTPError {
+ return web.HTTPError{
+ HTTPCode: http.StatusPreconditionFailed,
+ Code: ErrCodeSubscriptionAlreadyExists,
+ Message: "You're already subscribed.",
+ }
+}
diff --git a/pkg/models/events.go b/pkg/models/events.go
index 8b37fa0de83..40dc1bcb75b 100644
--- a/pkg/models/events.go
+++ b/pkg/models/events.go
@@ -28,7 +28,7 @@ import (
// TaskCreatedEvent represents an event where a task has been created
type TaskCreatedEvent struct {
Task *Task
- Doer web.Auth
+ Doer *user.User
}
// Name defines the name for TaskCreatedEvent
@@ -39,7 +39,7 @@ func (t *TaskCreatedEvent) Name() string {
// TaskUpdatedEvent represents an event where a task has been updated
type TaskUpdatedEvent struct {
Task *Task
- Doer web.Auth
+ Doer *user.User
}
// Name defines the name for TaskUpdatedEvent
@@ -50,7 +50,7 @@ func (t *TaskUpdatedEvent) Name() string {
// TaskDeletedEvent represents a TaskDeletedEvent event
type TaskDeletedEvent struct {
Task *Task
- Doer web.Auth
+ Doer *user.User
}
// Name defines the name for TaskDeletedEvent
@@ -62,7 +62,7 @@ func (t *TaskDeletedEvent) Name() string {
type TaskAssigneeCreatedEvent struct {
Task *Task
Assignee *user.User
- Doer web.Auth
+ Doer *user.User
}
// Name defines the name for TaskAssigneeCreatedEvent
@@ -74,7 +74,7 @@ func (t *TaskAssigneeCreatedEvent) Name() string {
type TaskCommentCreatedEvent struct {
Task *Task
Comment *TaskComment
- Doer web.Auth
+ Doer *user.User
}
// Name defines the name for TaskCommentCreatedEvent
@@ -126,7 +126,7 @@ func (t *NamespaceDeletedEvent) Name() string {
// ListCreatedEvent represents an event where a list has been created
type ListCreatedEvent struct {
List *List
- Doer web.Auth
+ Doer *user.User
}
// Name defines the name for ListCreatedEvent
diff --git a/pkg/models/list.go b/pkg/models/list.go
index a50a993b0be..97cf5d90378 100644
--- a/pkg/models/list.go
+++ b/pkg/models/list.go
@@ -68,6 +68,10 @@ type List struct {
// True if a list is a favorite. Favorite lists show up in a separate namespace.
IsFavorite bool `xorm:"default false" json:"is_favorite"`
+ // The subscription status for the user reading this list. You can only read this property, use the subscription endpoints to modify it.
+ // Will only returned when retreiving one list.
+ Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
+
// A timestamp when this list was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this list was last updated. You cannot change this value.
@@ -236,7 +240,8 @@ func (l *List) ReadOne(s *xorm.Session, a web.Auth) (err error) {
}
}
- return nil
+ l.Subscription, err = GetSubscription(s, SubscriptionEntityList, l.ID, a)
+ return
}
// GetListSimpleByID gets a list with only the basic items, aka no tasks or user objects. Returns an error if the list does not exist.
@@ -622,7 +627,7 @@ func (l *List) Create(s *xorm.Session, a web.Auth) (err error) {
return events.Dispatch(&ListCreatedEvent{
List: l,
- Doer: a,
+ Doer: doer,
})
}
diff --git a/pkg/models/list_test.go b/pkg/models/list_test.go
index 933484a7d13..a3d64044d09 100644
--- a/pkg/models/list_test.go
+++ b/pkg/models/list_test.go
@@ -215,3 +215,34 @@ func TestList_ReadAll(t *testing.T) {
_ = s.Close()
})
}
+
+func TestList_ReadOne(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ u := &user.User{ID: 1}
+ l := &List{ID: 1}
+ can, _, err := l.CanRead(s, u)
+ assert.NoError(t, err)
+ assert.True(t, can)
+ err = l.ReadOne(s, u)
+ assert.NoError(t, err)
+ assert.Equal(t, "Test1", l.Title)
+ })
+ t.Run("with subscription", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ u := &user.User{ID: 6}
+ l := &List{ID: 12}
+ can, _, err := l.CanRead(s, u)
+ assert.NoError(t, err)
+ assert.True(t, can)
+ err = l.ReadOne(s, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, l.Subscription)
+ })
+}
diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go
index 74826f76a03..416ea4d49fe 100644
--- a/pkg/models/listeners.go
+++ b/pkg/models/listeners.go
@@ -17,9 +17,14 @@
package models
import (
+ "encoding/json"
+
+ "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/modules/keyvalue"
+ "code.vikunja.io/api/pkg/notifications"
"github.com/ThreeDotsLabs/watermill/message"
)
@@ -33,6 +38,10 @@ func RegisterListeners() {
events.RegisterListener((&TaskDeletedEvent{}).Name(), &DecreaseTaskCounter{})
events.RegisterListener((&TeamDeletedEvent{}).Name(), &DecreaseTeamCounter{})
events.RegisterListener((&TeamCreatedEvent{}).Name(), &IncreaseTeamCounter{})
+ events.RegisterListener((&TaskCommentCreatedEvent{}).Name(), &SendTaskCommentNotification{})
+ events.RegisterListener((&TaskAssigneeCreatedEvent{}).Name(), &SendTaskAssignedNotification{})
+ events.RegisterListener((&TaskDeletedEvent{}).Name(), &SendTaskDeletedNotification{})
+ events.RegisterListener((&ListCreatedEvent{}).Name(), &SendListCreatedNotification{})
}
//////
@@ -66,6 +75,143 @@ func (s *DecreaseTaskCounter) Handle(payload message.Payload) (err error) {
return keyvalue.DecrBy(metrics.TaskCountKey, 1)
}
+// SendTaskCommentNotification represents a listener
+type SendTaskCommentNotification struct {
+}
+
+// Name defines the name for the SendTaskCommentNotification listener
+func (s *SendTaskCommentNotification) Name() string {
+ return "send.task.comment.notification"
+}
+
+// Handle is executed when the event SendTaskCommentNotification listens on is fired
+func (s *SendTaskCommentNotification) Handle(payload message.Payload) (err error) {
+ event := &TaskCommentCreatedEvent{}
+ err = json.Unmarshal(payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ subscribers, err := getSubscribersForEntity(sess, SubscriptionEntityTask, event.Task.ID)
+ if err != nil {
+ return err
+ }
+
+ log.Debugf("Sending task comment notifications to %d subscribers for task %d", len(subscribers), event.Task.ID)
+
+ for _, subscriber := range subscribers {
+ if subscriber.UserID == event.Doer.ID {
+ continue
+ }
+
+ n := &TaskCommentNotification{
+ Doer: event.Doer,
+ Task: event.Task,
+ Comment: event.Comment,
+ }
+ err = notifications.Notify(subscriber.User, n)
+ if err != nil {
+ return
+ }
+ }
+
+ return
+}
+
+// SendTaskAssignedNotification represents a listener
+type SendTaskAssignedNotification struct {
+}
+
+// Name defines the name for the SendTaskAssignedNotification listener
+func (s *SendTaskAssignedNotification) Name() string {
+ return "send.task.assigned.notification"
+}
+
+// Handle is executed when the event SendTaskAssignedNotification listens on is fired
+func (s *SendTaskAssignedNotification) Handle(payload message.Payload) (err error) {
+ event := &TaskAssigneeCreatedEvent{}
+ err = json.Unmarshal(payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ subscribers, err := getSubscribersForEntity(sess, SubscriptionEntityTask, event.Task.ID)
+ if err != nil {
+ return err
+ }
+
+ log.Debugf("Sending task assigned notifications to %d subscribers for task %d", len(subscribers), event.Task.ID)
+
+ for _, subscriber := range subscribers {
+ if subscriber.UserID == event.Doer.ID {
+ continue
+ }
+
+ n := &TaskAssignedNotification{
+ Doer: event.Doer,
+ Task: event.Task,
+ Assignee: event.Assignee,
+ }
+ err = notifications.Notify(subscriber.User, n)
+ if err != nil {
+ return
+ }
+ }
+
+ return nil
+}
+
+// SendTaskDeletedNotification represents a listener
+type SendTaskDeletedNotification struct {
+}
+
+// Name defines the name for the SendTaskDeletedNotification listener
+func (s *SendTaskDeletedNotification) Name() string {
+ return "send.task.deleted.notification"
+}
+
+// Handle is executed when the event SendTaskDeletedNotification listens on is fired
+func (s *SendTaskDeletedNotification) Handle(payload message.Payload) (err error) {
+ event := &TaskDeletedEvent{}
+ err = json.Unmarshal(payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ subscribers, err := getSubscribersForEntity(sess, SubscriptionEntityTask, event.Task.ID)
+ if err != nil {
+ return err
+ }
+
+ log.Debugf("Sending task deleted notifications to %d subscribers for task %d", len(subscribers), event.Task.ID)
+
+ for _, subscriber := range subscribers {
+ if subscriber.UserID == event.Doer.ID {
+ continue
+ }
+
+ n := &TaskDeletedNotification{
+ Doer: event.Doer,
+ Task: event.Task,
+ }
+ err = notifications.Notify(subscriber.User, n)
+ if err != nil {
+ return
+ }
+ }
+
+ return nil
+}
+
///////
// List Event Listeners
@@ -91,6 +237,51 @@ func (s *DecreaseListCounter) Handle(payload message.Payload) (err error) {
return keyvalue.DecrBy(metrics.ListCountKey, 1)
}
+// SendListCreatedNotification represents a listener
+type SendListCreatedNotification struct {
+}
+
+// Name defines the name for the SendListCreatedNotification listener
+func (s *SendListCreatedNotification) Name() string {
+ return "send.list.created.notification"
+}
+
+// Handle is executed when the event SendListCreatedNotification listens on is fired
+func (s *SendListCreatedNotification) Handle(payload message.Payload) (err error) {
+ event := &ListCreatedEvent{}
+ err = json.Unmarshal(payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ subscribers, err := getSubscribersForEntity(sess, SubscriptionEntityList, event.List.ID)
+ if err != nil {
+ return err
+ }
+
+ log.Debugf("Sending list created notifications to %d subscribers for list %d", len(subscribers), event.List.ID)
+
+ for _, subscriber := range subscribers {
+ if subscriber.UserID == event.Doer.ID {
+ continue
+ }
+
+ n := &ListCreatedNotification{
+ Doer: event.Doer,
+ List: event.List,
+ }
+ err = notifications.Notify(subscriber.User, n)
+ if err != nil {
+ return
+ }
+ }
+
+ return nil
+}
+
//////
// Namespace events
diff --git a/pkg/models/models.go b/pkg/models/models.go
index b8d3919a237..869744629a4 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -59,6 +59,7 @@ func GetTables() []interface{} {
&Bucket{},
&UnsplashPhoto{},
&SavedFilter{},
+ &Subscription{},
}
}
diff --git a/pkg/models/namespace.go b/pkg/models/namespace.go
index 7f4f00c3696..ad3a70d392f 100644
--- a/pkg/models/namespace.go
+++ b/pkg/models/namespace.go
@@ -50,6 +50,10 @@ type Namespace struct {
// The user who owns this namespace
Owner *user.User `xorm:"-" json:"owner" valid:"-"`
+ // The subscription status for the user reading this namespace. You can only read this property, use the subscription endpoints to modify it.
+ // Will only returned when retreiving one namespace.
+ Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
+
// A timestamp when this namespace was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this namespace was last updated. You cannot change this value.
@@ -166,6 +170,8 @@ func (n *Namespace) ReadOne(s *xorm.Session, a web.Auth) (err error) {
return err
}
*n = *nn
+
+ n.Subscription, err = GetSubscription(s, SubscriptionEntityNamespace, n.ID, a)
return
}
@@ -175,10 +181,11 @@ type NamespaceWithLists struct {
Lists []*List `xorm:"-" json:"lists"`
}
-func makeNamespaceSliceFromMap(namespaces map[int64]*NamespaceWithLists, userMap map[int64]*user.User) []*NamespaceWithLists {
+func makeNamespaceSliceFromMap(namespaces map[int64]*NamespaceWithLists, userMap map[int64]*user.User, subscriptions map[int64]*Subscription) []*NamespaceWithLists {
all := make([]*NamespaceWithLists, 0, len(namespaces))
for _, n := range namespaces {
n.Owner = userMap[n.OwnerID]
+ n.Subscription = subscriptions[n.ID]
all = append(all, n)
}
sort.Slice(all, func(i, j int) bool {
@@ -289,6 +296,21 @@ func (n *Namespace) ReadAll(s *xorm.Session, a web.Auth, search string, page int
userIDs = append(userIDs, nsp.OwnerID)
}
+ // Get all subscriptions
+ subscriptions := []*Subscription{}
+ err = s.
+ Where("entity_type = ? AND user_id = ?", SubscriptionEntityNamespace, a.GetID()).
+ In("entity_id", namespaceids).
+ Find(&subscriptions)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ subscriptionsMap := make(map[int64]*Subscription)
+ for _, sub := range subscriptions {
+ sub.Entity = sub.EntityType.String()
+ subscriptionsMap[sub.EntityID] = sub
+ }
+
// Get all owners
userMap := make(map[int64]*user.User)
err = s.In("id", userIDs).Find(&userMap)
@@ -297,7 +319,7 @@ func (n *Namespace) ReadAll(s *xorm.Session, a web.Auth, search string, page int
}
if n.NamespacesOnly {
- all := makeNamespaceSliceFromMap(namespaces, userMap)
+ all := makeNamespaceSliceFromMap(namespaces, userMap, subscriptionsMap)
return all, len(all), numberOfTotalItems, nil
}
@@ -443,7 +465,7 @@ func (n *Namespace) ReadAll(s *xorm.Session, a web.Auth, search string, page int
//////////////////////
// Put it all together (and sort it)
- all := makeNamespaceSliceFromMap(namespaces, userMap)
+ all := makeNamespaceSliceFromMap(namespaces, userMap, subscriptionsMap)
return all, len(all), numberOfTotalItems, nil
}
diff --git a/pkg/models/namespace_test.go b/pkg/models/namespace_test.go
index f989c7ce7ce..3c6c82cd69e 100644
--- a/pkg/models/namespace_test.go
+++ b/pkg/models/namespace_test.go
@@ -75,19 +75,31 @@ func TestNamespace_ReadOne(t *testing.T) {
n := &Namespace{ID: 1}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
+ defer s.Close()
+
err := n.ReadOne(s, u)
assert.NoError(t, err)
assert.Equal(t, n.Title, "testnamespace")
- _ = s.Close()
})
t.Run("nonexistant", func(t *testing.T) {
n := &Namespace{ID: 99999}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
+ defer s.Close()
+
err := n.ReadOne(s, u)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
- _ = s.Close()
+ })
+ t.Run("with subscription", func(t *testing.T) {
+ n := &Namespace{ID: 8}
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ err := n.ReadOne(s, &user.User{ID: 6})
+ assert.NoError(t, err)
+ assert.NotNil(t, n.Subscription)
})
}
diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go
index af655aaaeb4..a84db9b0c8c 100644
--- a/pkg/models/notifications.go
+++ b/pkg/models/notifications.go
@@ -17,7 +17,9 @@
package models
import (
+ "bufio"
"strconv"
+ "strings"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
@@ -45,3 +47,88 @@ func (n *ReminderDueNotification) ToMail() *notifications.Mail {
func (n *ReminderDueNotification) ToDB() interface{} {
return nil
}
+
+// TaskCommentNotification represents a TaskCommentNotification notification
+type TaskCommentNotification struct {
+ Doer *user.User
+ Task *Task
+ Comment *TaskComment
+}
+
+// ToMail returns the mail notification for TaskCommentNotification
+func (n *TaskCommentNotification) ToMail() *notifications.Mail {
+
+ mail := notifications.NewMail().
+ From(n.Doer.GetName() + " via Vikunja <" + config.MailerFromEmail.GetString() + ">").
+ Subject("Re: " + n.Task.Title)
+
+ lines := bufio.NewScanner(strings.NewReader(n.Comment.Comment))
+ for lines.Scan() {
+ mail.Line(lines.Text())
+ }
+
+ return mail.
+ Action("View Task", n.Task.GetFrontendURL())
+}
+
+// ToDB returns the TaskCommentNotification notification in a format which can be saved in the db
+func (n *TaskCommentNotification) ToDB() interface{} {
+ return n
+}
+
+// TaskAssignedNotification represents a TaskAssignedNotification notification
+type TaskAssignedNotification struct {
+ Doer *user.User
+ Task *Task
+ Assignee *user.User
+}
+
+// ToMail returns the mail notification for TaskAssignedNotification
+func (n *TaskAssignedNotification) ToMail() *notifications.Mail {
+ return notifications.NewMail().
+ Subject(n.Task.Title+"("+n.Task.GetFullIdentifier()+")"+" has been assigned to "+n.Assignee.GetName()).
+ Line(n.Doer.GetName()+" has assigned this task to "+n.Assignee.GetName()).
+ Action("View Task", n.Task.GetFrontendURL())
+}
+
+// ToDB returns the TaskAssignedNotification notification in a format which can be saved in the db
+func (n *TaskAssignedNotification) ToDB() interface{} {
+ return n
+}
+
+// TaskDeletedNotification represents a TaskDeletedNotification notification
+type TaskDeletedNotification struct {
+ Doer *user.User
+ Task *Task
+}
+
+// ToMail returns the mail notification for TaskDeletedNotification
+func (n *TaskDeletedNotification) ToMail() *notifications.Mail {
+ return notifications.NewMail().
+ Subject(n.Task.Title + "(" + n.Task.GetFullIdentifier() + ")" + " has been delete").
+ Line(n.Doer.GetName() + " has deleted the task " + n.Task.Title + "(" + n.Task.GetFullIdentifier() + ")")
+}
+
+// ToDB returns the TaskDeletedNotification notification in a format which can be saved in the db
+func (n *TaskDeletedNotification) ToDB() interface{} {
+ return n
+}
+
+// ListCreatedNotification represents a ListCreatedNotification notification
+type ListCreatedNotification struct {
+ Doer *user.User
+ List *List
+}
+
+// ToMail returns the mail notification for ListCreatedNotification
+func (n *ListCreatedNotification) ToMail() *notifications.Mail {
+ return notifications.NewMail().
+ Subject(n.Doer.GetName()+` created the list "`+n.List.Title+`"`).
+ Line(n.Doer.GetName()+` created the list "`+n.List.Title+`"`).
+ Action("View List", config.ServiceFrontendurl.GetString()+"lists/")
+}
+
+// ToDB returns the ListCreatedNotification notification in a format which can be saved in the db
+func (n *ListCreatedNotification) ToDB() interface{} {
+ return nil
+}
diff --git a/pkg/models/subscription.go b/pkg/models/subscription.go
new file mode 100644
index 00000000000..26b01d25322
--- /dev/null
+++ b/pkg/models/subscription.go
@@ -0,0 +1,282 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
+//
+// You should have received a copy of the GNU Affero General Public Licensee
+// along with this program. If not, see .
+
+package models
+
+import (
+ "time"
+
+ "xorm.io/builder"
+
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/web"
+ "xorm.io/xorm"
+)
+
+// SubscriptionEntityType represents all entities which can be subscribed to
+type SubscriptionEntityType int
+
+const (
+ SubscriptionEntityUnknown = iota
+ SubscriptionEntityNamespace
+ SubscriptionEntityList
+ SubscriptionEntityTask
+)
+
+const (
+ entityNamespace = `namespace`
+ entityList = `list`
+ entityTask = `task`
+)
+
+// Subscription represents a subscription for an entity
+type Subscription struct {
+ // The numeric ID of the subscription
+ ID int64 `xorm:"autoincr not null unique pk" json:"id"`
+
+ EntityType SubscriptionEntityType `xorm:"index not null" json:"-"`
+ Entity string `xorm:"-" json:"entity" param:"entity"`
+ // The id of the entity to subscribe to.
+ EntityID int64 `xorm:"bigint index not null" json:"entity_id" param:"entityID"`
+
+ // The user who made this subscription
+ User *user.User `xorm:"-" json:"user"`
+ UserID int64 `xorm:"bigint index not null" json:"-"`
+
+ // A timestamp when this subscription was created. You cannot change this value.
+ Created time.Time `xorm:"created not null" json:"created"`
+
+ web.CRUDable `xorm:"-" json:"-"`
+ web.Rights `xorm:"-" json:"-"`
+}
+
+// TableName gives us a better tabel name for the subscriptions table
+func (sb *Subscription) TableName() string {
+ return "subscriptions"
+}
+
+func getEntityTypeFromString(entityType string) SubscriptionEntityType {
+ switch entityType {
+ case entityNamespace:
+ return SubscriptionEntityNamespace
+ case entityList:
+ return SubscriptionEntityList
+ case entityTask:
+ return SubscriptionEntityTask
+ }
+
+ return SubscriptionEntityUnknown
+}
+
+// String returns a human-readable string of an entity
+func (et SubscriptionEntityType) String() string {
+ switch et {
+ case SubscriptionEntityNamespace:
+ return entityNamespace
+ case SubscriptionEntityList:
+ return entityList
+ case SubscriptionEntityTask:
+ return entityTask
+ }
+
+ return ""
+}
+
+func (et SubscriptionEntityType) validate() error {
+ if et == SubscriptionEntityNamespace ||
+ et == SubscriptionEntityList ||
+ et == SubscriptionEntityTask {
+ return nil
+ }
+
+ return &ErrUnknownSubscriptionEntityType{EntityType: et}
+}
+
+// Create subscribes the current user to an entity
+// @Summary Subscribes the current user to an entity.
+// @Description Subscribes the current user to an entity.
+// @tags subscriptions
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param entity path string true "The entity the user subscribes to. Can be either `namespace`, `list` or `task`."
+// @Param entityID path string true "The numeric id of the entity to subscribe to."
+// @Success 200 {object} models.Subscription "The subscription"
+// @Failure 403 {object} web.HTTPError "The user does not have access to subscribe to this entity."
+// @Failure 412 {object} web.HTTPError "The subscription already exists."
+// @Failure 412 {object} web.HTTPError "The subscription entity is invalid."
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /subscriptions/{entity}/{entityID} [put]
+func (sb *Subscription) Create(s *xorm.Session, auth web.Auth) (err error) {
+ // Rights method alread does the validation of the entity type so we don't need to do that here
+
+ sb.UserID = auth.GetID()
+
+ sub, err := GetSubscription(s, sb.EntityType, sb.EntityID, auth)
+ if err != nil {
+ return err
+ }
+ if sub != nil {
+ return &ErrSubscriptionAlreadyExists{
+ EntityID: sb.EntityID,
+ EntityType: sb.EntityType,
+ UserID: sb.UserID,
+ }
+ }
+
+ _, err = s.Insert(sb)
+ if err != nil {
+ return
+ }
+
+ sb.User, err = user.GetFromAuth(auth)
+ return
+}
+
+// Delete unsubscribes the current user to an entity
+// @Summary Unsubscribe the current user from an entity.
+// @Description Unsubscribes the current user to an entity.
+// @tags subscriptions
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param entity path string true "The entity the user subscribed to. Can be either `namespace`, `list` or `task`."
+// @Param entityID path string true "The numeric id of the subscribed entity to."
+// @Success 200 {object} models.Subscription "The subscription"
+// @Failure 403 {object} web.HTTPError "The user does not have access to subscribe to this entity."
+// @Failure 404 {object} web.HTTPError "The subscription does not exist."
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /subscriptions/{entity}/{entityID} [delete]
+func (sb *Subscription) Delete(s *xorm.Session, auth web.Auth) (err error) {
+ sb.UserID = auth.GetID()
+
+ _, err = s.
+ Where("entity_id = ? AND entity_type = ? AND user_id = ?", sb.EntityID, sb.EntityType, sb.UserID).
+ Delete(&Subscription{})
+ return
+}
+
+func getSubscriberCondForEntity(entityType SubscriptionEntityType, entityID int64) (cond builder.Cond) {
+ if entityType == SubscriptionEntityNamespace {
+ cond = builder.And(
+ builder.Eq{"entity_id": entityID},
+ builder.Eq{"entity_type": SubscriptionEntityNamespace},
+ )
+ }
+
+ if entityType == SubscriptionEntityList {
+ cond = builder.Or(
+ builder.And(
+ builder.Eq{"entity_id": entityID},
+ builder.Eq{"entity_type": SubscriptionEntityList},
+ ),
+ builder.And(
+ builder.Eq{"entity_id": builder.
+ Select("namespace_id").
+ From("list").
+ Where(builder.Eq{"id": entityID}),
+ },
+ builder.Eq{"entity_type": SubscriptionEntityNamespace},
+ ),
+ )
+ }
+
+ if entityType == SubscriptionEntityTask {
+ cond = builder.Or(
+ builder.And(
+ builder.Eq{"entity_id": entityID},
+ builder.Eq{"entity_type": SubscriptionEntityTask},
+ ),
+ builder.And(
+ builder.Eq{"entity_id": builder.
+ Select("namespace_id").
+ From("list").
+ Join("INNER", "tasks", "list.id = tasks.list_id").
+ Where(builder.Eq{"tasks.id": entityID}),
+ },
+ builder.Eq{"entity_type": SubscriptionEntityNamespace},
+ ),
+ builder.And(
+ builder.Eq{"entity_id": builder.
+ Select("list_id").
+ From("tasks").
+ Where(builder.Eq{"id": entityID}),
+ },
+ builder.Eq{"entity_type": SubscriptionEntityList},
+ ),
+ )
+ }
+
+ return
+}
+
+// GetSubscription returns a matching subscription for an entity and user.
+// It will return the next parent of a subscription. That means for tasks, it will first look for a subscription for
+// that task, if there is none it will look for a subscription on the list the task belongs to and if that also
+// doesn't exist it will check for a subscription for the namespace the list is belonging to.
+func GetSubscription(s *xorm.Session, entityType SubscriptionEntityType, entityID int64, a web.Auth) (subscription *Subscription, err error) {
+ u, is := a.(*user.User)
+ if !is {
+ return
+ }
+
+ if err := entityType.validate(); err != nil {
+ return nil, err
+ }
+
+ subscription = &Subscription{}
+ cond := getSubscriberCondForEntity(entityType, entityID)
+ exists, err := s.
+ Where("user_id = ?", u.ID).
+ And(cond).
+ Get(subscription)
+ if !exists {
+ return nil, err
+ }
+
+ subscription.Entity = subscription.EntityType.String()
+
+ return subscription, err
+}
+
+func getSubscribersForEntity(s *xorm.Session, entityType SubscriptionEntityType, entityID int64) (subscriptions []*Subscription, err error) {
+ if err := entityType.validate(); err != nil {
+ return nil, err
+ }
+
+ cond := getSubscriberCondForEntity(entityType, entityID)
+ err = s.
+ Where(cond).
+ Find(&subscriptions)
+ if err != nil {
+ return
+ }
+
+ userIDs := []int64{}
+ for _, subscription := range subscriptions {
+ userIDs = append(userIDs, subscription.UserID)
+ }
+
+ users, err := user.GetUsersByIDs(s, userIDs)
+ if err != nil {
+ return
+ }
+
+ for _, subscription := range subscriptions {
+ subscription.User = users[subscription.UserID]
+ }
+ return
+}
diff --git a/pkg/models/subscription_rights.go b/pkg/models/subscription_rights.go
new file mode 100644
index 00000000000..8b8fedc1941
--- /dev/null
+++ b/pkg/models/subscription_rights.go
@@ -0,0 +1,66 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
+//
+// You should have received a copy of the GNU Affero General Public Licensee
+// along with this program. If not, see .
+
+package models
+
+import (
+ "code.vikunja.io/web"
+ "xorm.io/xorm"
+)
+
+// CanCreate checks if a user can subscribe to an entity
+func (sb *Subscription) CanCreate(s *xorm.Session, a web.Auth) (can bool, err error) {
+ if _, is := a.(*LinkSharing); is {
+ return false, &ErrGenericForbidden{}
+ }
+
+ sb.EntityType = getEntityTypeFromString(sb.Entity)
+
+ switch sb.EntityType {
+ case SubscriptionEntityNamespace:
+ n := &Namespace{ID: sb.EntityID}
+ can, _, err = n.CanRead(s, a)
+ case SubscriptionEntityList:
+ l := &List{ID: sb.EntityID}
+ can, _, err = l.CanRead(s, a)
+ case SubscriptionEntityTask:
+ t := &Task{ID: sb.EntityID}
+ can, _, err = t.CanRead(s, a)
+ default:
+ return false, &ErrUnknownSubscriptionEntityType{EntityType: sb.EntityType}
+ }
+
+ return
+}
+
+// CanDelete checks if a user can delete a subscription
+func (sb *Subscription) CanDelete(s *xorm.Session, a web.Auth) (can bool, err error) {
+ if _, is := a.(*LinkSharing); is {
+ return false, &ErrGenericForbidden{}
+ }
+
+ sb.EntityType = getEntityTypeFromString(sb.Entity)
+
+ realSb := &Subscription{}
+ exists, err := s.
+ Where("entity_id = ? AND entity_type = ? AND user_id = ?", sb.EntityID, sb.EntityType, a.GetID()).
+ Get(realSb)
+ if err != nil {
+ return false, err
+ }
+
+ return exists, nil
+}
diff --git a/pkg/models/subscription_test.go b/pkg/models/subscription_test.go
new file mode 100644
index 00000000000..3485c993fb8
--- /dev/null
+++ b/pkg/models/subscription_test.go
@@ -0,0 +1,346 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
+//
+// You should have received a copy of the GNU Affero General Public Licensee
+// along with this program. If not, see .
+
+package models
+
+import (
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/user"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSubscriptionGetTypeFromString(t *testing.T) {
+ t.Run("namespace", func(t *testing.T) {
+ entityType := getEntityTypeFromString("namespace")
+ assert.Equal(t, SubscriptionEntityType(SubscriptionEntityNamespace), entityType)
+ })
+ t.Run("list", func(t *testing.T) {
+ entityType := getEntityTypeFromString("list")
+ assert.Equal(t, SubscriptionEntityType(SubscriptionEntityList), entityType)
+ })
+ t.Run("task", func(t *testing.T) {
+ entityType := getEntityTypeFromString("task")
+ assert.Equal(t, SubscriptionEntityType(SubscriptionEntityTask), entityType)
+ })
+ t.Run("invalid", func(t *testing.T) {
+ entityType := getEntityTypeFromString("someomejghsd")
+ assert.Equal(t, SubscriptionEntityType(SubscriptionEntityUnknown), entityType)
+ })
+}
+
+func TestSubscription_Create(t *testing.T) {
+ u := &user.User{ID: 1}
+
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 1,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.NoError(t, err)
+ assert.True(t, can)
+
+ err = sb.Create(s, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, sb.User)
+
+ db.AssertExists(t, "subscriptions", map[string]interface{}{
+ "entity_type": 3,
+ "entity_id": 1,
+ "user_id": u.ID,
+ }, false)
+ })
+ t.Run("forbidden for link shares", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ linkShare := &LinkSharing{}
+
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 1,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, linkShare)
+ assert.Error(t, err)
+ assert.False(t, can)
+ })
+ t.Run("noneixsting namespace", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "namespace",
+ EntityID: 99999999,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.Error(t, err)
+ assert.True(t, IsErrNamespaceDoesNotExist(err))
+ assert.False(t, can)
+ })
+ t.Run("noneixsting list", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "list",
+ EntityID: 99999999,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.Error(t, err)
+ assert.True(t, IsErrListDoesNotExist(err))
+ assert.False(t, can)
+ })
+ t.Run("noneixsting task", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 99999999,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.Error(t, err)
+ assert.True(t, IsErrTaskDoesNotExist(err))
+ assert.False(t, can)
+ })
+ t.Run("no rights to see namespace", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "namespace",
+ EntityID: 6,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.NoError(t, err)
+ assert.False(t, can)
+ })
+ t.Run("no rights to see list", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "list",
+ EntityID: 20,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.NoError(t, err)
+ assert.False(t, can)
+ })
+ t.Run("no rights to see task", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 14,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.NoError(t, err)
+ assert.False(t, can)
+ })
+ t.Run("existing subscription for (entity_id, entity_type, user_id) ", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 2,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanCreate(s, u)
+ assert.NoError(t, err)
+ assert.True(t, can)
+
+ err = sb.Create(s, u)
+ assert.Error(t, err)
+ assert.True(t, IsErrSubscriptionAlreadyExists(err))
+ })
+
+ // TODO: Add tests to test triggering of notifications for subscribed things
+}
+
+func TestSubscription_Delete(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ u := &user.User{ID: 1}
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 2,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanDelete(s, u)
+ assert.NoError(t, err)
+ assert.True(t, can)
+
+ err = sb.Delete(s, u)
+ assert.NoError(t, err)
+ db.AssertMissing(t, "subscriptions", map[string]interface{}{
+ "entity_type": 3,
+ "entity_id": 2,
+ "user_id": u.ID,
+ })
+ })
+ t.Run("forbidden for link shares", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ linkShare := &LinkSharing{}
+
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 1,
+ UserID: 1,
+ }
+
+ can, err := sb.CanDelete(s, linkShare)
+ assert.Error(t, err)
+ assert.False(t, can)
+ })
+ t.Run("not owner of the subscription", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ u := &user.User{ID: 2}
+ sb := &Subscription{
+ Entity: "task",
+ EntityID: 2,
+ UserID: u.ID,
+ }
+
+ can, err := sb.CanDelete(s, u)
+ assert.NoError(t, err)
+ assert.False(t, can)
+ })
+}
+
+func TestSubscriptionGet(t *testing.T) {
+ u := &user.User{ID: 6}
+
+ t.Run("test each individually", func(t *testing.T) {
+ t.Run("namespace", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sub, err := GetSubscription(s, SubscriptionEntityNamespace, 6, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, sub)
+ assert.Equal(t, int64(2), sub.ID)
+ })
+ t.Run("list", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sub, err := GetSubscription(s, SubscriptionEntityList, 12, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, sub)
+ assert.Equal(t, int64(3), sub.ID)
+ })
+ t.Run("task", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ sub, err := GetSubscription(s, SubscriptionEntityTask, 22, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, sub)
+ assert.Equal(t, int64(4), sub.ID)
+ })
+ })
+ t.Run("inherited", func(t *testing.T) {
+ t.Run("list from namespace", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // List 6 belongs to namespace 6 where user 6 has subscribed to
+ sub, err := GetSubscription(s, SubscriptionEntityList, 6, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, sub)
+ assert.Equal(t, int64(2), sub.ID)
+ })
+ t.Run("task from namespace", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // Task 20 belongs to list 11 which belongs to namespace 6 where the user has subscribed
+ sub, err := GetSubscription(s, SubscriptionEntityTask, 20, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, sub)
+ assert.Equal(t, int64(2), sub.ID)
+ })
+ t.Run("task from list", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // Task 21 belongs to list 12 which the user has subscribed to
+ sub, err := GetSubscription(s, SubscriptionEntityTask, 21, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, sub)
+ assert.Equal(t, int64(3), sub.ID)
+ })
+ })
+ t.Run("invalid type", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ _, err := GetSubscription(s, 2342, 21, u)
+ assert.Error(t, err)
+ assert.True(t, IsErrUnknownSubscriptionEntityType(err))
+ })
+}
diff --git a/pkg/models/task_assignees.go b/pkg/models/task_assignees.go
index d156483226c..5ef8363f0b2 100644
--- a/pkg/models/task_assignees.go
+++ b/pkg/models/task_assignees.go
@@ -225,10 +225,11 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *Li
return err
}
+ doer, _ := user.GetFromAuth(auth)
err = events.Dispatch(&TaskAssigneeCreatedEvent{
Task: t,
Assignee: newAssignee,
- Doer: auth,
+ Doer: doer,
})
if err != nil {
return err
diff --git a/pkg/models/task_comments.go b/pkg/models/task_comments.go
index 9bad08c7c77..1924712c49f 100644
--- a/pkg/models/task_comments.go
+++ b/pkg/models/task_comments.go
@@ -73,10 +73,11 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
return
}
+ doer, _ := user.GetFromAuth(a)
err = events.Dispatch(&TaskCommentCreatedEvent{
Task: &task,
Comment: tc,
- Doer: a,
+ Doer: doer,
})
if err != nil {
return err
diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go
index 3ee2edc65bd..8732dcebc11 100644
--- a/pkg/models/tasks.go
+++ b/pkg/models/tasks.go
@@ -91,6 +91,10 @@ type Task struct {
// True if a task is a favorite task. Favorite tasks show up in a separate "Important" list
IsFavorite bool `xorm:"default false" json:"is_favorite"`
+ // The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.
+ // Will only returned when retreiving one task.
+ Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
+
// A timestamp when this task was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this task was last updated. You cannot change this value.
@@ -119,6 +123,19 @@ func (Task) TableName() string {
return "tasks"
}
+// GetFullIdentifier returns the task identifier if the task has one and the index prefixed with # otherwise.
+func (t *Task) GetFullIdentifier() string {
+ if t.Identifier != "" {
+ return t.Identifier
+ }
+
+ return "#" + strconv.FormatInt(t.Index, 10)
+}
+
+func (t *Task) GetFrontendURL() string {
+ return config.ServiceFrontendurl.GetString() + "tasks/" + strconv.FormatInt(t.ID, 10)
+}
+
type taskFilterConcatinator string
const (
@@ -832,9 +849,10 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
t.setIdentifier(l)
+ doer, _ := user.GetFromAuth(a)
err = events.Dispatch(&TaskCreatedEvent{
Task: t,
- Doer: a,
+ Doer: doer,
})
if err != nil {
return err
@@ -1040,9 +1058,10 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
}
t.Updated = nt.Updated
+ doer, _ := user.GetFromAuth(a)
err = events.Dispatch(&TaskUpdatedEvent{
Task: t,
- Doer: a,
+ Doer: doer,
})
if err != nil {
return err
@@ -1197,9 +1216,10 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
return err
}
+ doer, _ := user.GetFromAuth(a)
err = events.Dispatch(&TaskDeletedEvent{
Task: t,
- Doer: a,
+ Doer: doer,
})
if err != nil {
return
@@ -1241,5 +1261,6 @@ func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) {
*t = *taskMap[t.ID]
+ t.Subscription, err = GetSubscription(s, SubscriptionEntityTask, t.ID, a)
return
}
diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go
index 6c8ae2dfed7..705df45418e 100644
--- a/pkg/models/tasks_test.go
+++ b/pkg/models/tasks_test.go
@@ -467,4 +467,16 @@ func TestTask_ReadOne(t *testing.T) {
assert.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(err))
})
+ t.Run("with subscription", func(t *testing.T) {
+ u = &user.User{ID: 6}
+
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ task := &Task{ID: 22}
+ err := task.ReadOne(s, u)
+ assert.NoError(t, err)
+ assert.NotNil(t, task.Subscription)
+ })
}
diff --git a/pkg/models/unit_tests.go b/pkg/models/unit_tests.go
index 9ab084d064d..9e6ba4b2cc8 100644
--- a/pkg/models/unit_tests.go
+++ b/pkg/models/unit_tests.go
@@ -59,6 +59,7 @@ func SetupTests() {
"users_namespace",
"buckets",
"saved_filters",
+ "subscriptions",
)
if err != nil {
log.Fatal(err)
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 4267dd2d0a9..3a76132a3b7 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -512,6 +512,15 @@ func registerAPIRoutes(a *echo.Group) {
a.DELETE("/teams/:team/members/:user", teamMemberHandler.DeleteWeb)
a.POST("/teams/:team/members/:user/admin", teamMemberHandler.UpdateWeb)
+ // Subscriptions
+ subscriptionHandler := &handler.WebHandler{
+ EmptyStruct: func() handler.CObject {
+ return &models.Subscription{}
+ },
+ }
+ a.PUT("/subscriptions/:entity/:entityID", subscriptionHandler.CreateWeb)
+ a.DELETE("/subscriptions/:entity/:entityID", subscriptionHandler.DeleteWeb)
+
// Migrations
m := a.Group("/migration")
diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go
index cd2c47f471b..193215a0608 100644
--- a/pkg/swagger/docs.go
+++ b/pkg/swagger/docs.go
@@ -3991,6 +3991,128 @@ var doc = `{
}
}
},
+ "/subscriptions/{entity}/{entityID}": {
+ "put": {
+ "security": [
+ {
+ "JWTKeyAuth": []
+ }
+ ],
+ "description": "Subscribes the current user to an entity.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "subscriptions"
+ ],
+ "summary": "Subscribes the current user to an entity.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The entity the user subscribes to. Can be either ` + "`" + `namespace` + "`" + `, ` + "`" + `list` + "`" + ` or ` + "`" + `task` + "`" + `.",
+ "name": "entity",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The numeric id of the entity to subscribe to.",
+ "name": "entityID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The subscription",
+ "schema": {
+ "$ref": "#/definitions/models.Subscription"
+ }
+ },
+ "403": {
+ "description": "The user does not have access to subscribe to this entity.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "412": {
+ "description": "The subscription entity is invalid.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "500": {
+ "description": "Internal error",
+ "schema": {
+ "$ref": "#/definitions/models.Message"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "JWTKeyAuth": []
+ }
+ ],
+ "description": "Unsubscribes the current user to an entity.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "subscriptions"
+ ],
+ "summary": "Unsubscribe the current user from an entity.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The entity the user subscribed to. Can be either ` + "`" + `namespace` + "`" + `, ` + "`" + `list` + "`" + ` or ` + "`" + `task` + "`" + `.",
+ "name": "entity",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The numeric id of the subscribed entity to.",
+ "name": "entityID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The subscription",
+ "schema": {
+ "$ref": "#/definitions/models.Subscription"
+ }
+ },
+ "403": {
+ "description": "The user does not have access to subscribe to this entity.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "404": {
+ "description": "The subscription does not exist.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "500": {
+ "description": "Internal error",
+ "schema": {
+ "$ref": "#/definitions/models.Message"
+ }
+ }
+ }
+ }
+ },
"/tasks/all": {
"get": {
"security": [
@@ -7039,6 +7161,10 @@ var doc = `{
"description": "When this task starts.",
"type": "string"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one task.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"task_ids": {
"description": "A list of task ids to update",
"type": "array",
@@ -7201,6 +7327,10 @@ var doc = `{
"description": "The user who created this list.",
"$ref": "#/definitions/user.User"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this list. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one list.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The title of the list. You'll see this in the namespace overview.",
"type": "string",
@@ -7290,6 +7420,10 @@ var doc = `{
"description": "The user who owns this namespace",
"$ref": "#/definitions/user.User"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this namespace. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one namespace.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The name of this namespace.",
"type": "string",
@@ -7363,6 +7497,10 @@ var doc = `{
"description": "The user who owns this namespace",
"$ref": "#/definitions/user.User"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this namespace. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one namespace.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The name of this namespace.",
"type": "string",
@@ -7419,6 +7557,30 @@ var doc = `{
}
}
},
+ "models.Subscription": {
+ "type": "object",
+ "properties": {
+ "created": {
+ "description": "A timestamp when this subscription was created. You cannot change this value.",
+ "type": "string"
+ },
+ "entity": {
+ "type": "string"
+ },
+ "entity_id": {
+ "description": "The id of the entity to subscribe to.",
+ "type": "integer"
+ },
+ "id": {
+ "description": "The numeric ID of the subscription",
+ "type": "integer"
+ },
+ "user": {
+ "description": "The user who made this subscription",
+ "$ref": "#/definitions/user.User"
+ }
+ }
+ },
"models.Task": {
"type": "object",
"properties": {
@@ -7535,6 +7697,10 @@ var doc = `{
"description": "When this task starts.",
"type": "string"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one task.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The task text. This is what you'll see in the list.",
"type": "string",
diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json
index 895d95a1aea..5aa77402811 100644
--- a/pkg/swagger/swagger.json
+++ b/pkg/swagger/swagger.json
@@ -3974,6 +3974,128 @@
}
}
},
+ "/subscriptions/{entity}/{entityID}": {
+ "put": {
+ "security": [
+ {
+ "JWTKeyAuth": []
+ }
+ ],
+ "description": "Subscribes the current user to an entity.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "subscriptions"
+ ],
+ "summary": "Subscribes the current user to an entity.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The entity the user subscribes to. Can be either `namespace`, `list` or `task`.",
+ "name": "entity",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The numeric id of the entity to subscribe to.",
+ "name": "entityID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The subscription",
+ "schema": {
+ "$ref": "#/definitions/models.Subscription"
+ }
+ },
+ "403": {
+ "description": "The user does not have access to subscribe to this entity.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "412": {
+ "description": "The subscription entity is invalid.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "500": {
+ "description": "Internal error",
+ "schema": {
+ "$ref": "#/definitions/models.Message"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "JWTKeyAuth": []
+ }
+ ],
+ "description": "Unsubscribes the current user to an entity.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "subscriptions"
+ ],
+ "summary": "Unsubscribe the current user from an entity.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The entity the user subscribed to. Can be either `namespace`, `list` or `task`.",
+ "name": "entity",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The numeric id of the subscribed entity to.",
+ "name": "entityID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The subscription",
+ "schema": {
+ "$ref": "#/definitions/models.Subscription"
+ }
+ },
+ "403": {
+ "description": "The user does not have access to subscribe to this entity.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "404": {
+ "description": "The subscription does not exist.",
+ "schema": {
+ "$ref": "#/definitions/web.HTTPError"
+ }
+ },
+ "500": {
+ "description": "Internal error",
+ "schema": {
+ "$ref": "#/definitions/models.Message"
+ }
+ }
+ }
+ }
+ },
"/tasks/all": {
"get": {
"security": [
@@ -7022,6 +7144,10 @@
"description": "When this task starts.",
"type": "string"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one task.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"task_ids": {
"description": "A list of task ids to update",
"type": "array",
@@ -7184,6 +7310,10 @@
"description": "The user who created this list.",
"$ref": "#/definitions/user.User"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this list. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one list.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The title of the list. You'll see this in the namespace overview.",
"type": "string",
@@ -7273,6 +7403,10 @@
"description": "The user who owns this namespace",
"$ref": "#/definitions/user.User"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this namespace. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one namespace.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The name of this namespace.",
"type": "string",
@@ -7346,6 +7480,10 @@
"description": "The user who owns this namespace",
"$ref": "#/definitions/user.User"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this namespace. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one namespace.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The name of this namespace.",
"type": "string",
@@ -7402,6 +7540,30 @@
}
}
},
+ "models.Subscription": {
+ "type": "object",
+ "properties": {
+ "created": {
+ "description": "A timestamp when this subscription was created. You cannot change this value.",
+ "type": "string"
+ },
+ "entity": {
+ "type": "string"
+ },
+ "entity_id": {
+ "description": "The id of the entity to subscribe to.",
+ "type": "integer"
+ },
+ "id": {
+ "description": "The numeric ID of the subscription",
+ "type": "integer"
+ },
+ "user": {
+ "description": "The user who made this subscription",
+ "$ref": "#/definitions/user.User"
+ }
+ }
+ },
"models.Task": {
"type": "object",
"properties": {
@@ -7518,6 +7680,10 @@
"description": "When this task starts.",
"type": "string"
},
+ "subscription": {
+ "description": "The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.\nWill only returned when retreiving one task.",
+ "$ref": "#/definitions/models.Subscription"
+ },
"title": {
"description": "The task text. This is what you'll see in the list.",
"type": "string",
diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml
index b4ad40dc701..959c2a61ab9 100644
--- a/pkg/swagger/swagger.yaml
+++ b/pkg/swagger/swagger.yaml
@@ -210,6 +210,11 @@ definitions:
start_date:
description: When this task starts.
type: string
+ subscription:
+ $ref: '#/definitions/models.Subscription'
+ description: |-
+ The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.
+ Will only returned when retreiving one task.
task_ids:
description: A list of task ids to update
items:
@@ -330,6 +335,11 @@ definitions:
owner:
$ref: '#/definitions/user.User'
description: The user who created this list.
+ subscription:
+ $ref: '#/definitions/models.Subscription'
+ description: |-
+ The subscription status for the user reading this list. You can only read this property, use the subscription endpoints to modify it.
+ Will only returned when retreiving one list.
title:
description: The title of the list. You'll see this in the namespace overview.
maxLength: 250
@@ -395,6 +405,11 @@ definitions:
owner:
$ref: '#/definitions/user.User'
description: The user who owns this namespace
+ subscription:
+ $ref: '#/definitions/models.Subscription'
+ description: |-
+ The subscription status for the user reading this namespace. You can only read this property, use the subscription endpoints to modify it.
+ Will only returned when retreiving one namespace.
title:
description: The name of this namespace.
maxLength: 250
@@ -449,6 +464,11 @@ definitions:
owner:
$ref: '#/definitions/user.User'
description: The user who owns this namespace
+ subscription:
+ $ref: '#/definitions/models.Subscription'
+ description: |-
+ The subscription status for the user reading this namespace. You can only read this property, use the subscription endpoints to modify it.
+ Will only returned when retreiving one namespace.
title:
description: The name of this namespace.
maxLength: 250
@@ -490,6 +510,23 @@ definitions:
description: A timestamp when this filter was last updated. You cannot change this value.
type: string
type: object
+ models.Subscription:
+ properties:
+ created:
+ description: A timestamp when this subscription was created. You cannot change this value.
+ type: string
+ entity:
+ type: string
+ entity_id:
+ description: The id of the entity to subscribe to.
+ type: integer
+ id:
+ description: The numeric ID of the subscription
+ type: integer
+ user:
+ $ref: '#/definitions/user.User'
+ description: The user who made this subscription
+ type: object
models.Task:
properties:
assignees:
@@ -582,6 +619,11 @@ definitions:
start_date:
description: When this task starts.
type: string
+ subscription:
+ $ref: '#/definitions/models.Subscription'
+ description: |-
+ The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.
+ Will only returned when retreiving one task.
title:
description: The task text. This is what you'll see in the list.
maxLength: 250
@@ -3660,6 +3702,85 @@ paths:
summary: Get an auth token for a share
tags:
- sharing
+ /subscriptions/{entity}/{entityID}:
+ delete:
+ consumes:
+ - application/json
+ description: Unsubscribes the current user to an entity.
+ parameters:
+ - description: The entity the user subscribed to. Can be either `namespace`, `list` or `task`.
+ in: path
+ name: entity
+ required: true
+ type: string
+ - description: The numeric id of the subscribed entity to.
+ in: path
+ name: entityID
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: The subscription
+ schema:
+ $ref: '#/definitions/models.Subscription'
+ "403":
+ description: The user does not have access to subscribe to this entity.
+ schema:
+ $ref: '#/definitions/web.HTTPError'
+ "404":
+ description: The subscription does not exist.
+ schema:
+ $ref: '#/definitions/web.HTTPError'
+ "500":
+ description: Internal error
+ schema:
+ $ref: '#/definitions/models.Message'
+ security:
+ - JWTKeyAuth: []
+ summary: Unsubscribe the current user from an entity.
+ tags:
+ - subscriptions
+ put:
+ consumes:
+ - application/json
+ description: Subscribes the current user to an entity.
+ parameters:
+ - description: The entity the user subscribes to. Can be either `namespace`, `list` or `task`.
+ in: path
+ name: entity
+ required: true
+ type: string
+ - description: The numeric id of the entity to subscribe to.
+ in: path
+ name: entityID
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: The subscription
+ schema:
+ $ref: '#/definitions/models.Subscription'
+ "403":
+ description: The user does not have access to subscribe to this entity.
+ schema:
+ $ref: '#/definitions/web.HTTPError'
+ "412":
+ description: The subscription entity is invalid.
+ schema:
+ $ref: '#/definitions/web.HTTPError'
+ "500":
+ description: Internal error
+ schema:
+ $ref: '#/definitions/models.Message'
+ security:
+ - JWTKeyAuth: []
+ summary: Subscribes the current user to an entity.
+ tags:
+ - subscriptions
/tasks/{ID}:
get:
consumes: