1
0

Subscriptions and notifications for namespaces, tasks and lists (#786)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/786
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad
2021-02-14 19:18:14 +00:00
parent 618b464ca3
commit e7875ecb3b
25 changed files with 1714 additions and 23 deletions

View File

@ -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.",
}
}

View File

@ -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

View File

@ -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,
})
}

View File

@ -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)
})
}

View File

@ -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

View File

@ -59,6 +59,7 @@ func GetTables() []interface{} {
&Bucket{},
&UnsplashPhoto{},
&SavedFilter{},
&Subscription{},
}
}

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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
}

282
pkg/models/subscription.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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))
})
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -59,6 +59,7 @@ func SetupTests() {
"users_namespace",
"buckets",
"saved_filters",
"subscriptions",
)
if err != nil {
log.Fatal(err)