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:
parent
618b464ca3
commit
e7875ecb3b
25 changed files with 1714 additions and 23 deletions
|
@ -1,8 +1,7 @@
|
||||||
---
|
---
|
||||||
date: "2019-02-12:00:00+02:00"
|
date: "2019-02-12:00:00+02:00"
|
||||||
title: "Errors"
|
title: "Errors"
|
||||||
draft: false
|
draft: false type: "doc"
|
||||||
type: "doc"
|
|
||||||
menu:
|
menu:
|
||||||
sidebar:
|
sidebar:
|
||||||
parent: "usage"
|
parent: "usage"
|
||||||
|
@ -142,3 +141,10 @@ This document describes the different errors Vikunja can return.
|
||||||
|-----------|------------------|-------------|
|
|-----------|------------------|-------------|
|
||||||
| 11001 | 404 | The saved filter does not exist. |
|
| 11001 | 404 | The saved filter does not exist. |
|
||||||
| 11002 | 412 | Saved filters are not available for link shares. |
|
| 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. |
|
||||||
|
|
|
@ -827,7 +827,7 @@ func (s *` + name + `) Name() string {
|
||||||
return "` + listenerName + `"
|
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) {
|
func (s *` + name + `) Handle(payload message.Payload) (err error) {
|
||||||
event := &` + event + `{}
|
event := &` + event + `{}
|
||||||
err = json.Unmarshal(payload, event)
|
err = json.Unmarshal(payload, event)
|
||||||
|
|
35
pkg/db/fixtures/subscriptions.yml
Normal file
35
pkg/db/fixtures/subscriptions.yml
Normal file
|
@ -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
|
49
pkg/migration/20210209204715.go
Normal file
49
pkg/migration/20210209204715.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -1423,3 +1423,63 @@ func (err ErrSavedFilterNotAvailableForLinkShare) HTTPError() web.HTTPError {
|
||||||
Message: "Saved filters are not available for link shares.",
|
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.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ import (
|
||||||
// TaskCreatedEvent represents an event where a task has been created
|
// TaskCreatedEvent represents an event where a task has been created
|
||||||
type TaskCreatedEvent struct {
|
type TaskCreatedEvent struct {
|
||||||
Task *Task
|
Task *Task
|
||||||
Doer web.Auth
|
Doer *user.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name defines the name for TaskCreatedEvent
|
// 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
|
// TaskUpdatedEvent represents an event where a task has been updated
|
||||||
type TaskUpdatedEvent struct {
|
type TaskUpdatedEvent struct {
|
||||||
Task *Task
|
Task *Task
|
||||||
Doer web.Auth
|
Doer *user.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name defines the name for TaskUpdatedEvent
|
// Name defines the name for TaskUpdatedEvent
|
||||||
|
@ -50,7 +50,7 @@ func (t *TaskUpdatedEvent) Name() string {
|
||||||
// TaskDeletedEvent represents a TaskDeletedEvent event
|
// TaskDeletedEvent represents a TaskDeletedEvent event
|
||||||
type TaskDeletedEvent struct {
|
type TaskDeletedEvent struct {
|
||||||
Task *Task
|
Task *Task
|
||||||
Doer web.Auth
|
Doer *user.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name defines the name for TaskDeletedEvent
|
// Name defines the name for TaskDeletedEvent
|
||||||
|
@ -62,7 +62,7 @@ func (t *TaskDeletedEvent) Name() string {
|
||||||
type TaskAssigneeCreatedEvent struct {
|
type TaskAssigneeCreatedEvent struct {
|
||||||
Task *Task
|
Task *Task
|
||||||
Assignee *user.User
|
Assignee *user.User
|
||||||
Doer web.Auth
|
Doer *user.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name defines the name for TaskAssigneeCreatedEvent
|
// Name defines the name for TaskAssigneeCreatedEvent
|
||||||
|
@ -74,7 +74,7 @@ func (t *TaskAssigneeCreatedEvent) Name() string {
|
||||||
type TaskCommentCreatedEvent struct {
|
type TaskCommentCreatedEvent struct {
|
||||||
Task *Task
|
Task *Task
|
||||||
Comment *TaskComment
|
Comment *TaskComment
|
||||||
Doer web.Auth
|
Doer *user.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name defines the name for TaskCommentCreatedEvent
|
// 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
|
// ListCreatedEvent represents an event where a list has been created
|
||||||
type ListCreatedEvent struct {
|
type ListCreatedEvent struct {
|
||||||
List *List
|
List *List
|
||||||
Doer web.Auth
|
Doer *user.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name defines the name for ListCreatedEvent
|
// Name defines the name for ListCreatedEvent
|
||||||
|
|
|
@ -68,6 +68,10 @@ type List struct {
|
||||||
// True if a list is a favorite. Favorite lists show up in a separate namespace.
|
// True if a list is a favorite. Favorite lists show up in a separate namespace.
|
||||||
IsFavorite bool `xorm:"default false" json:"is_favorite"`
|
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.
|
// A timestamp when this list was created. You cannot change this value.
|
||||||
Created time.Time `xorm:"created not null" json:"created"`
|
Created time.Time `xorm:"created not null" json:"created"`
|
||||||
// A timestamp when this list was last updated. You cannot change this value.
|
// 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.
|
// 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{
|
return events.Dispatch(&ListCreatedEvent{
|
||||||
List: l,
|
List: l,
|
||||||
Doer: a,
|
Doer: doer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -215,3 +215,34 @@ func TestList_ReadAll(t *testing.T) {
|
||||||
_ = s.Close()
|
_ = 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -17,9 +17,14 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/db"
|
||||||
"code.vikunja.io/api/pkg/events"
|
"code.vikunja.io/api/pkg/events"
|
||||||
|
"code.vikunja.io/api/pkg/log"
|
||||||
"code.vikunja.io/api/pkg/metrics"
|
"code.vikunja.io/api/pkg/metrics"
|
||||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||||
|
"code.vikunja.io/api/pkg/notifications"
|
||||||
"github.com/ThreeDotsLabs/watermill/message"
|
"github.com/ThreeDotsLabs/watermill/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -33,6 +38,10 @@ func RegisterListeners() {
|
||||||
events.RegisterListener((&TaskDeletedEvent{}).Name(), &DecreaseTaskCounter{})
|
events.RegisterListener((&TaskDeletedEvent{}).Name(), &DecreaseTaskCounter{})
|
||||||
events.RegisterListener((&TeamDeletedEvent{}).Name(), &DecreaseTeamCounter{})
|
events.RegisterListener((&TeamDeletedEvent{}).Name(), &DecreaseTeamCounter{})
|
||||||
events.RegisterListener((&TeamCreatedEvent{}).Name(), &IncreaseTeamCounter{})
|
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)
|
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
|
// List Event Listeners
|
||||||
|
|
||||||
|
@ -91,6 +237,51 @@ func (s *DecreaseListCounter) Handle(payload message.Payload) (err error) {
|
||||||
return keyvalue.DecrBy(metrics.ListCountKey, 1)
|
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
|
// Namespace events
|
||||||
|
|
||||||
|
|
|
@ -59,6 +59,7 @@ func GetTables() []interface{} {
|
||||||
&Bucket{},
|
&Bucket{},
|
||||||
&UnsplashPhoto{},
|
&UnsplashPhoto{},
|
||||||
&SavedFilter{},
|
&SavedFilter{},
|
||||||
|
&Subscription{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,10 @@ type Namespace struct {
|
||||||
// The user who owns this namespace
|
// The user who owns this namespace
|
||||||
Owner *user.User `xorm:"-" json:"owner" valid:"-"`
|
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.
|
// A timestamp when this namespace was created. You cannot change this value.
|
||||||
Created time.Time `xorm:"created not null" json:"created"`
|
Created time.Time `xorm:"created not null" json:"created"`
|
||||||
// A timestamp when this namespace was last updated. You cannot change this value.
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
*n = *nn
|
*n = *nn
|
||||||
|
|
||||||
|
n.Subscription, err = GetSubscription(s, SubscriptionEntityNamespace, n.ID, a)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -175,10 +181,11 @@ type NamespaceWithLists struct {
|
||||||
Lists []*List `xorm:"-" json:"lists"`
|
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))
|
all := make([]*NamespaceWithLists, 0, len(namespaces))
|
||||||
for _, n := range namespaces {
|
for _, n := range namespaces {
|
||||||
n.Owner = userMap[n.OwnerID]
|
n.Owner = userMap[n.OwnerID]
|
||||||
|
n.Subscription = subscriptions[n.ID]
|
||||||
all = append(all, n)
|
all = append(all, n)
|
||||||
}
|
}
|
||||||
sort.Slice(all, func(i, j int) bool {
|
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)
|
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
|
// Get all owners
|
||||||
userMap := make(map[int64]*user.User)
|
userMap := make(map[int64]*user.User)
|
||||||
err = s.In("id", userIDs).Find(&userMap)
|
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 {
|
if n.NamespacesOnly {
|
||||||
all := makeNamespaceSliceFromMap(namespaces, userMap)
|
all := makeNamespaceSliceFromMap(namespaces, userMap, subscriptionsMap)
|
||||||
return all, len(all), numberOfTotalItems, nil
|
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)
|
// Put it all together (and sort it)
|
||||||
all := makeNamespaceSliceFromMap(namespaces, userMap)
|
all := makeNamespaceSliceFromMap(namespaces, userMap, subscriptionsMap)
|
||||||
return all, len(all), numberOfTotalItems, nil
|
return all, len(all), numberOfTotalItems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,19 +75,31 @@ func TestNamespace_ReadOne(t *testing.T) {
|
||||||
n := &Namespace{ID: 1}
|
n := &Namespace{ID: 1}
|
||||||
db.LoadAndAssertFixtures(t)
|
db.LoadAndAssertFixtures(t)
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
err := n.ReadOne(s, u)
|
err := n.ReadOne(s, u)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, n.Title, "testnamespace")
|
assert.Equal(t, n.Title, "testnamespace")
|
||||||
_ = s.Close()
|
|
||||||
})
|
})
|
||||||
t.Run("nonexistant", func(t *testing.T) {
|
t.Run("nonexistant", func(t *testing.T) {
|
||||||
n := &Namespace{ID: 99999}
|
n := &Namespace{ID: 99999}
|
||||||
db.LoadAndAssertFixtures(t)
|
db.LoadAndAssertFixtures(t)
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
err := n.ReadOne(s, u)
|
err := n.ReadOne(s, u)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.True(t, IsErrNamespaceDoesNotExist(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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"code.vikunja.io/api/pkg/config"
|
"code.vikunja.io/api/pkg/config"
|
||||||
"code.vikunja.io/api/pkg/notifications"
|
"code.vikunja.io/api/pkg/notifications"
|
||||||
|
@ -45,3 +47,88 @@ func (n *ReminderDueNotification) ToMail() *notifications.Mail {
|
||||||
func (n *ReminderDueNotification) ToDB() interface{} {
|
func (n *ReminderDueNotification) ToDB() interface{} {
|
||||||
return nil
|
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
282
pkg/models/subscription.go
Normal 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
|
||||||
|
}
|
66
pkg/models/subscription_rights.go
Normal file
66
pkg/models/subscription_rights.go
Normal 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
|
||||||
|
}
|
346
pkg/models/subscription_test.go
Normal file
346
pkg/models/subscription_test.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
|
@ -225,10 +225,11 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *Li
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doer, _ := user.GetFromAuth(auth)
|
||||||
err = events.Dispatch(&TaskAssigneeCreatedEvent{
|
err = events.Dispatch(&TaskAssigneeCreatedEvent{
|
||||||
Task: t,
|
Task: t,
|
||||||
Assignee: newAssignee,
|
Assignee: newAssignee,
|
||||||
Doer: auth,
|
Doer: doer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -73,10 +73,11 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doer, _ := user.GetFromAuth(a)
|
||||||
err = events.Dispatch(&TaskCommentCreatedEvent{
|
err = events.Dispatch(&TaskCommentCreatedEvent{
|
||||||
Task: &task,
|
Task: &task,
|
||||||
Comment: tc,
|
Comment: tc,
|
||||||
Doer: a,
|
Doer: doer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -91,6 +91,10 @@ type Task struct {
|
||||||
// True if a task is a favorite task. Favorite tasks show up in a separate "Important" list
|
// 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"`
|
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.
|
// A timestamp when this task was created. You cannot change this value.
|
||||||
Created time.Time `xorm:"created not null" json:"created"`
|
Created time.Time `xorm:"created not null" json:"created"`
|
||||||
// A timestamp when this task was last updated. You cannot change this value.
|
// A timestamp when this task was last updated. You cannot change this value.
|
||||||
|
@ -119,6 +123,19 @@ func (Task) TableName() string {
|
||||||
return "tasks"
|
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
|
type taskFilterConcatinator string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -832,9 +849,10 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
|
||||||
|
|
||||||
t.setIdentifier(l)
|
t.setIdentifier(l)
|
||||||
|
|
||||||
|
doer, _ := user.GetFromAuth(a)
|
||||||
err = events.Dispatch(&TaskCreatedEvent{
|
err = events.Dispatch(&TaskCreatedEvent{
|
||||||
Task: t,
|
Task: t,
|
||||||
Doer: a,
|
Doer: doer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -1040,9 +1058,10 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
|
||||||
}
|
}
|
||||||
t.Updated = nt.Updated
|
t.Updated = nt.Updated
|
||||||
|
|
||||||
|
doer, _ := user.GetFromAuth(a)
|
||||||
err = events.Dispatch(&TaskUpdatedEvent{
|
err = events.Dispatch(&TaskUpdatedEvent{
|
||||||
Task: t,
|
Task: t,
|
||||||
Doer: a,
|
Doer: doer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -1197,9 +1216,10 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doer, _ := user.GetFromAuth(a)
|
||||||
err = events.Dispatch(&TaskDeletedEvent{
|
err = events.Dispatch(&TaskDeletedEvent{
|
||||||
Task: t,
|
Task: t,
|
||||||
Doer: a,
|
Doer: doer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
@ -1241,5 +1261,6 @@ func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) {
|
||||||
|
|
||||||
*t = *taskMap[t.ID]
|
*t = *taskMap[t.ID]
|
||||||
|
|
||||||
|
t.Subscription, err = GetSubscription(s, SubscriptionEntityTask, t.ID, a)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -467,4 +467,16 @@ func TestTask_ReadOne(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.True(t, IsErrTaskDoesNotExist(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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,7 @@ func SetupTests() {
|
||||||
"users_namespace",
|
"users_namespace",
|
||||||
"buckets",
|
"buckets",
|
||||||
"saved_filters",
|
"saved_filters",
|
||||||
|
"subscriptions",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
|
@ -512,6 +512,15 @@ func registerAPIRoutes(a *echo.Group) {
|
||||||
a.DELETE("/teams/:team/members/:user", teamMemberHandler.DeleteWeb)
|
a.DELETE("/teams/:team/members/:user", teamMemberHandler.DeleteWeb)
|
||||||
a.POST("/teams/:team/members/:user/admin", teamMemberHandler.UpdateWeb)
|
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
|
// Migrations
|
||||||
m := a.Group("/migration")
|
m := a.Group("/migration")
|
||||||
|
|
||||||
|
|
|
@ -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": {
|
"/tasks/all": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
@ -7039,6 +7161,10 @@ var doc = `{
|
||||||
"description": "When this task starts.",
|
"description": "When this task starts.",
|
||||||
"type": "string"
|
"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": {
|
"task_ids": {
|
||||||
"description": "A list of task ids to update",
|
"description": "A list of task ids to update",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
@ -7201,6 +7327,10 @@ var doc = `{
|
||||||
"description": "The user who created this list.",
|
"description": "The user who created this list.",
|
||||||
"$ref": "#/definitions/user.User"
|
"$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": {
|
"title": {
|
||||||
"description": "The title of the list. You'll see this in the namespace overview.",
|
"description": "The title of the list. You'll see this in the namespace overview.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -7290,6 +7420,10 @@ var doc = `{
|
||||||
"description": "The user who owns this namespace",
|
"description": "The user who owns this namespace",
|
||||||
"$ref": "#/definitions/user.User"
|
"$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": {
|
"title": {
|
||||||
"description": "The name of this namespace.",
|
"description": "The name of this namespace.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -7363,6 +7497,10 @@ var doc = `{
|
||||||
"description": "The user who owns this namespace",
|
"description": "The user who owns this namespace",
|
||||||
"$ref": "#/definitions/user.User"
|
"$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": {
|
"title": {
|
||||||
"description": "The name of this namespace.",
|
"description": "The name of this namespace.",
|
||||||
"type": "string",
|
"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": {
|
"models.Task": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -7535,6 +7697,10 @@ var doc = `{
|
||||||
"description": "When this task starts.",
|
"description": "When this task starts.",
|
||||||
"type": "string"
|
"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": {
|
"title": {
|
||||||
"description": "The task text. This is what you'll see in the list.",
|
"description": "The task text. This is what you'll see in the list.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
@ -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": {
|
"/tasks/all": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
@ -7022,6 +7144,10 @@
|
||||||
"description": "When this task starts.",
|
"description": "When this task starts.",
|
||||||
"type": "string"
|
"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": {
|
"task_ids": {
|
||||||
"description": "A list of task ids to update",
|
"description": "A list of task ids to update",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
@ -7184,6 +7310,10 @@
|
||||||
"description": "The user who created this list.",
|
"description": "The user who created this list.",
|
||||||
"$ref": "#/definitions/user.User"
|
"$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": {
|
"title": {
|
||||||
"description": "The title of the list. You'll see this in the namespace overview.",
|
"description": "The title of the list. You'll see this in the namespace overview.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -7273,6 +7403,10 @@
|
||||||
"description": "The user who owns this namespace",
|
"description": "The user who owns this namespace",
|
||||||
"$ref": "#/definitions/user.User"
|
"$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": {
|
"title": {
|
||||||
"description": "The name of this namespace.",
|
"description": "The name of this namespace.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -7346,6 +7480,10 @@
|
||||||
"description": "The user who owns this namespace",
|
"description": "The user who owns this namespace",
|
||||||
"$ref": "#/definitions/user.User"
|
"$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": {
|
"title": {
|
||||||
"description": "The name of this namespace.",
|
"description": "The name of this namespace.",
|
||||||
"type": "string",
|
"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": {
|
"models.Task": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -7518,6 +7680,10 @@
|
||||||
"description": "When this task starts.",
|
"description": "When this task starts.",
|
||||||
"type": "string"
|
"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": {
|
"title": {
|
||||||
"description": "The task text. This is what you'll see in the list.",
|
"description": "The task text. This is what you'll see in the list.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
@ -210,6 +210,11 @@ definitions:
|
||||||
start_date:
|
start_date:
|
||||||
description: When this task starts.
|
description: When this task starts.
|
||||||
type: string
|
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:
|
task_ids:
|
||||||
description: A list of task ids to update
|
description: A list of task ids to update
|
||||||
items:
|
items:
|
||||||
|
@ -330,6 +335,11 @@ definitions:
|
||||||
owner:
|
owner:
|
||||||
$ref: '#/definitions/user.User'
|
$ref: '#/definitions/user.User'
|
||||||
description: The user who created this list.
|
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:
|
title:
|
||||||
description: The title of the list. You'll see this in the namespace overview.
|
description: The title of the list. You'll see this in the namespace overview.
|
||||||
maxLength: 250
|
maxLength: 250
|
||||||
|
@ -395,6 +405,11 @@ definitions:
|
||||||
owner:
|
owner:
|
||||||
$ref: '#/definitions/user.User'
|
$ref: '#/definitions/user.User'
|
||||||
description: The user who owns this namespace
|
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:
|
title:
|
||||||
description: The name of this namespace.
|
description: The name of this namespace.
|
||||||
maxLength: 250
|
maxLength: 250
|
||||||
|
@ -449,6 +464,11 @@ definitions:
|
||||||
owner:
|
owner:
|
||||||
$ref: '#/definitions/user.User'
|
$ref: '#/definitions/user.User'
|
||||||
description: The user who owns this namespace
|
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:
|
title:
|
||||||
description: The name of this namespace.
|
description: The name of this namespace.
|
||||||
maxLength: 250
|
maxLength: 250
|
||||||
|
@ -490,6 +510,23 @@ definitions:
|
||||||
description: A timestamp when this filter was last updated. You cannot change this value.
|
description: A timestamp when this filter was last updated. You cannot change this value.
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
models.Task:
|
||||||
properties:
|
properties:
|
||||||
assignees:
|
assignees:
|
||||||
|
@ -582,6 +619,11 @@ definitions:
|
||||||
start_date:
|
start_date:
|
||||||
description: When this task starts.
|
description: When this task starts.
|
||||||
type: string
|
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:
|
title:
|
||||||
description: The task text. This is what you'll see in the list.
|
description: The task text. This is what you'll see in the list.
|
||||||
maxLength: 250
|
maxLength: 250
|
||||||
|
@ -3660,6 +3702,85 @@ paths:
|
||||||
summary: Get an auth token for a share
|
summary: Get an auth token for a share
|
||||||
tags:
|
tags:
|
||||||
- sharing
|
- 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}:
|
/tasks/{ID}:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
Loading…
Reference in a new issue