From e7875ecb3b3fd474f36c930041d0e51e4570e3bd Mon Sep 17 00:00:00 2001 From: konrad Date: Sun, 14 Feb 2021 19:18:14 +0000 Subject: [PATCH] Subscriptions and notifications for namespaces, tasks and lists (#786) Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/786 Co-authored-by: konrad Co-committed-by: konrad --- docs/content/doc/usage/errors.md | 14 +- magefile.go | 2 +- pkg/db/fixtures/subscriptions.yml | 35 +++ pkg/migration/20210209204715.go | 49 +++++ pkg/models/error.go | 60 ++++++ pkg/models/events.go | 12 +- pkg/models/list.go | 9 +- pkg/models/list_test.go | 31 +++ pkg/models/listeners.go | 191 +++++++++++++++++ pkg/models/models.go | 1 + pkg/models/namespace.go | 28 ++- pkg/models/namespace_test.go | 16 +- pkg/models/notifications.go | 87 ++++++++ pkg/models/subscription.go | 282 ++++++++++++++++++++++++ pkg/models/subscription_rights.go | 66 ++++++ pkg/models/subscription_test.go | 346 ++++++++++++++++++++++++++++++ pkg/models/task_assignees.go | 3 +- pkg/models/task_comments.go | 3 +- pkg/models/tasks.go | 27 ++- pkg/models/tasks_test.go | 12 ++ pkg/models/unit_tests.go | 1 + pkg/routes/routes.go | 9 + pkg/swagger/docs.go | 166 ++++++++++++++ pkg/swagger/swagger.json | 166 ++++++++++++++ pkg/swagger/swagger.yaml | 121 +++++++++++ 25 files changed, 1714 insertions(+), 23 deletions(-) create mode 100644 pkg/db/fixtures/subscriptions.yml create mode 100644 pkg/migration/20210209204715.go create mode 100644 pkg/models/subscription.go create mode 100644 pkg/models/subscription_rights.go create mode 100644 pkg/models/subscription_test.go diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 88342632..8cbcd250 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 a4733b43..1aac4d31 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 00000000..aa4c42f4 --- /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 00000000..1bca9e50 --- /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 041a1150..d71f5bb7 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 8b37fa0d..40dc1bcb 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 a50a993b..97cf5d90 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 933484a7..a3d64044 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 74826f76..416ea4d4 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 b8d3919a..86974462 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 7f4f00c3..ad3a70d3 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 f989c7ce..3c6c82cd 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 af655aaa..a84db9b0 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 00000000..26b01d25 --- /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 00000000..8b8fedc1 --- /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 00000000..3485c993 --- /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 d1564832..5ef8363f 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 9bad08c7..1924712c 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 3ee2edc6..8732dceb 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 6c8ae2df..705df454 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 9ab084d0..9e6ba4b2 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 4267dd2d..3a76132a 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 cd2c47f4..193215a0 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 895d95a1..5aa77402 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 b4ad40dc..959c2a61 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: