mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-08-30 09:07:40 -05:00
feat(notifications): notify subscribers when a task is created
Subscribing to a project promised notifications "for changes", but no listener was registered for TaskCreatedEvent beyond mention handling, so new tasks never reached subscribers. Adds a TaskCreatedNotification and a listener that notifies task and project subscribers, skipping the creator and users already notified by the mention listener for the same event. Fixes #3611
This commit is contained in:
@@ -209,6 +209,7 @@ function getNotificationRoute(n: INotification): RouteLocationRaw | null {
|
||||
case names.TASK_ASSIGNED:
|
||||
case names.TASK_REMINDER:
|
||||
case names.TASK_MENTIONED:
|
||||
case names.TASK_CREATED:
|
||||
return {name: 'task.detail', params: {id: (n.notification as {task: {id: number}}).task.id}}
|
||||
case names.PROJECT_CREATED:
|
||||
return {name: 'task.index', params: {projectId: (n.notification as {project: {id: number}}).project.id}}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NOTIFICATION_NAMES = {
|
||||
'TASK_COMMENT': 'task.comment',
|
||||
'TASK_ASSIGNED': 'task.assigned',
|
||||
'TASK_DELETED': 'task.deleted',
|
||||
'TASK_CREATED': 'task.created',
|
||||
'TASK_REMINDER': 'task.reminder',
|
||||
'PROJECT_CREATED': 'project.created',
|
||||
'TEAM_MEMBER_ADDED': 'team.member.added',
|
||||
|
||||
@@ -43,6 +43,13 @@ export default class NotificationModel extends AbstractModel<INotification> impl
|
||||
task: new TaskModel(this.notification.task),
|
||||
}
|
||||
break
|
||||
case NOTIFICATION_NAMES.TASK_CREATED:
|
||||
this.notification = {
|
||||
doer: new UserModel(this.notification.doer),
|
||||
task: new TaskModel(this.notification.task),
|
||||
project: new ProjectModel(this.notification.project),
|
||||
}
|
||||
break
|
||||
case NOTIFICATION_NAMES.PROJECT_CREATED:
|
||||
this.notification = {
|
||||
doer: new UserModel(this.notification.doer),
|
||||
@@ -90,6 +97,8 @@ export default class NotificationModel extends AbstractModel<INotification> impl
|
||||
return `assigned ${who} to ${this.notification.task.getTextIdentifier()}`
|
||||
case NOTIFICATION_NAMES.TASK_DELETED:
|
||||
return `deleted ${this.notification.task.getTextIdentifier()}`
|
||||
case NOTIFICATION_NAMES.TASK_CREATED:
|
||||
return `created ${this.notification.task.getTextIdentifier()}`
|
||||
case NOTIFICATION_NAMES.PROJECT_CREATED:
|
||||
return `created ${this.notification.project.title}`
|
||||
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
"subject": "\"%[1]s\" (%[2]s) has been deleted",
|
||||
"message": "%[1]s has deleted the task \"%[2]s\" (%[3]s)"
|
||||
},
|
||||
"created": {
|
||||
"subject": "\"%[1]s\" (%[2]s) has been created",
|
||||
"message": "%[1]s created the task \"%[2]s\" (%[3]s)"
|
||||
},
|
||||
"mentioned": {
|
||||
"subject_new": "%[1]s mentioned you in a new task \"%[2]s\" (%[3]s)",
|
||||
"subject": "%[1]s mentioned you in a task \"%[2]s\" (%[3]s)"
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
func RegisterListeners() {
|
||||
events.RegisterListener((&TaskCommentCreatedEvent{}).Name(), &SendTaskCommentNotification{})
|
||||
events.RegisterListener((&TaskAssigneeCreatedEvent{}).Name(), &SendTaskAssignedNotification{})
|
||||
events.RegisterListener((&TaskCreatedEvent{}).Name(), &SendTaskCreatedNotification{})
|
||||
events.RegisterListener((&TaskDeletedEvent{}).Name(), &SendTaskDeletedNotification{})
|
||||
events.RegisterListener((&ProjectCreatedEvent{}).Name(), &SendProjectCreatedNotification{})
|
||||
events.RegisterListener((&TeamMemberAddedEvent{}).Name(), &SendTeamMemberAddedNotification{})
|
||||
@@ -711,6 +712,86 @@ func (s *SendTaskAssignedNotification) Handle(msg *message.Message) (err error)
|
||||
return sess.Commit()
|
||||
}
|
||||
|
||||
// SendTaskCreatedNotification represents a listener
|
||||
type SendTaskCreatedNotification struct {
|
||||
}
|
||||
|
||||
// Name defines the name for the SendTaskCreatedNotification listener
|
||||
func (s *SendTaskCreatedNotification) Name() string {
|
||||
return "task.created.notification.send"
|
||||
}
|
||||
|
||||
// Handle is executed when the event SendTaskCreatedNotification listens on is fired
|
||||
func (s *SendTaskCreatedNotification) Handle(msg *message.Message) (err error) {
|
||||
event := &TaskCreatedEvent{}
|
||||
err = json.Unmarshal(msg.Payload, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event.Task == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sess := db.NewSession()
|
||||
defer sess.Close()
|
||||
|
||||
project, err := GetProjectSimpleByID(sess, event.Task.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event.Task.setIdentifier(project)
|
||||
|
||||
// A task can only be subscribed to through its project at this point, but going
|
||||
// through the task resolves the whole project hierarchy for us.
|
||||
subscribers, err := GetSubscriptionsForEntity(sess, SubscriptionEntityTask, event.Task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Sending task created notifications to %d subscribers for task %d", len(subscribers), event.Task.ID)
|
||||
|
||||
// HandleTaskCreateMentions notifies these separately for the same event
|
||||
mentioned, err := FindMentionedUsersInText(sess, event.Task.Description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifiedUsers := make(map[int64]bool)
|
||||
|
||||
for _, subscriber := range subscribers {
|
||||
if subscriber.UserID == event.Doer.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
if notifiedUsers[subscriber.UserID] {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, has := mentioned[subscriber.UserID]; has {
|
||||
continue
|
||||
}
|
||||
|
||||
n := &TaskCreatedNotification{
|
||||
Doer: event.Doer,
|
||||
Task: event.Task,
|
||||
Project: project,
|
||||
}
|
||||
err = notifications.Notify(subscriber.User, n, sess)
|
||||
if err != nil {
|
||||
// Return so the event is retried: on SQLite the insert can hit
|
||||
// SQLITE_BUSY_SNAPSHOT when a sibling listener wrote first.
|
||||
_ = sess.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
notifiedUsers[subscriber.UserID] = true
|
||||
}
|
||||
|
||||
return sess.Commit()
|
||||
}
|
||||
|
||||
// SendTaskDeletedNotification represents a listener
|
||||
type SendTaskDeletedNotification struct {
|
||||
}
|
||||
|
||||
@@ -300,6 +300,24 @@ func TestSubscriberNotifications_SkipUsersWithoutReadAccess(t *testing.T) {
|
||||
assertOnlySubscriberWithAccessNotified(t, (&TaskDeletedNotification{}).Name())
|
||||
})
|
||||
|
||||
t.Run("task created", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
subscribeBoth(t, s, SubscriptionEntityTask, taskID)
|
||||
|
||||
task, err := GetTaskByIDSimple(s, taskID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
_ = s.Close()
|
||||
|
||||
events.TestListener(t, &TaskCreatedEvent{
|
||||
Task: &task,
|
||||
Doer: &user.User{ID: doerID},
|
||||
}, &SendTaskCreatedNotification{})
|
||||
|
||||
assertOnlySubscriberWithAccessNotified(t, (&TaskCreatedNotification{}).Name())
|
||||
})
|
||||
|
||||
// Subscribers are inherited from the parent project, but the notification
|
||||
// discloses the newly created child, so the child is what gets checked.
|
||||
t.Run("project created", func(t *testing.T) {
|
||||
|
||||
@@ -37,6 +37,7 @@ func init() {
|
||||
notifications.Register(func() notifications.PersistedNotification { return &TaskCommentNotification{} })
|
||||
notifications.Register(func() notifications.PersistedNotification { return &TaskAssignedNotification{} })
|
||||
notifications.Register(func() notifications.PersistedNotification { return &TaskDeletedNotification{} })
|
||||
notifications.Register(func() notifications.PersistedNotification { return &TaskCreatedNotification{} })
|
||||
notifications.Register(func() notifications.PersistedNotification { return &ProjectCreatedNotification{} })
|
||||
notifications.Register(func() notifications.PersistedNotification { return &TeamMemberAddedNotification{} })
|
||||
notifications.Register(func() notifications.PersistedNotification { return &UserMentionedInTaskNotification{} })
|
||||
@@ -267,6 +268,42 @@ func (n *TaskDeletedNotification) ThreadID() string {
|
||||
return getThreadID(n.Task.ID)
|
||||
}
|
||||
|
||||
// TaskCreatedNotification represents a TaskCreatedNotification notification
|
||||
type TaskCreatedNotification struct {
|
||||
Doer *user.User `json:"doer"`
|
||||
Task *Task `json:"task"`
|
||||
Project *Project `json:"project"`
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for TaskCreatedNotification
|
||||
func (n *TaskCreatedNotification) ToTitle(lang string) string {
|
||||
return i18n.T(lang, "notifications.task.created.subject", n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for TaskCreatedNotification
|
||||
func (n *TaskCreatedNotification) ToMail(lang string) *notifications.Mail {
|
||||
return notifications.NewMail().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Line(i18n.T(lang, "notifications.task.created.message", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Task.Title), notifications.EscapeMarkdown(n.Task.GetFullIdentifier()))).
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
||||
IncludeLinkToSettings(lang)
|
||||
}
|
||||
|
||||
// ToDB returns the TaskCreatedNotification notification in a format which can be saved in the db
|
||||
func (n *TaskCreatedNotification) ToDB() interface{} {
|
||||
return n
|
||||
}
|
||||
|
||||
// Name returns the name of the notification
|
||||
func (n *TaskCreatedNotification) Name() string {
|
||||
return "task.created"
|
||||
}
|
||||
|
||||
// ThreadID returns the thread ID for email threading
|
||||
func (n *TaskCreatedNotification) ThreadID() string {
|
||||
return getThreadID(n.Task.ID)
|
||||
}
|
||||
|
||||
// ProjectCreatedNotification represents a ProjectCreatedNotification notification
|
||||
type ProjectCreatedNotification struct {
|
||||
Doer *user.User `json:"doer"`
|
||||
|
||||
@@ -32,6 +32,8 @@ func (n *TaskAssignedNotification) ProjectID() int64 { return notificationProjec
|
||||
|
||||
func (n *TaskDeletedNotification) ProjectID() int64 { return notificationProjectID(n.Task, nil) }
|
||||
|
||||
func (n *TaskCreatedNotification) ProjectID() int64 { return notificationProjectID(n.Task, n.Project) }
|
||||
|
||||
func (n *UserMentionedInTaskNotification) ProjectID() int64 {
|
||||
return notificationProjectID(n.Task, n.Project)
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ func TestNotificationScopeClassification(t *testing.T) {
|
||||
"project.created",
|
||||
"task.assigned",
|
||||
"task.comment",
|
||||
"task.created",
|
||||
"task.deleted",
|
||||
"task.mentioned",
|
||||
"task.reminder",
|
||||
|
||||
@@ -80,6 +80,8 @@ func notificationUsers(n notifications.Notification) []*user.User {
|
||||
return []*user.User{n.Doer, n.Assignee}
|
||||
case *TaskDeletedNotification:
|
||||
return []*user.User{n.Doer}
|
||||
case *TaskCreatedNotification:
|
||||
return []*user.User{n.Doer}
|
||||
case *ProjectCreatedNotification:
|
||||
return []*user.User{n.Doer}
|
||||
case *TeamMemberAddedNotification:
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present 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 License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// 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/events"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// Task 32 lives on project 3, which user 2 can read.
|
||||
func TestSendTaskCreatedNotification(t *testing.T) {
|
||||
const (
|
||||
taskID int64 = 32
|
||||
projectID int64 = 3
|
||||
subscriberID int64 = 2
|
||||
doerID int64 = 1
|
||||
)
|
||||
|
||||
notificationName := (&TaskCreatedNotification{}).Name()
|
||||
|
||||
subscribeToProject := func(t *testing.T, s *xorm.Session, userID int64) {
|
||||
_, err := s.Insert(&Subscription{
|
||||
UserID: userID,
|
||||
EntityType: SubscriptionEntityProject,
|
||||
EntityID: projectID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("notifies project subscribers", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
subscribeToProject(t, s, subscriberID)
|
||||
|
||||
task, err := GetTaskByIDSimple(s, taskID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
_ = s.Close()
|
||||
|
||||
events.TestListener(t, &TaskCreatedEvent{
|
||||
Task: &task,
|
||||
Doer: &user.User{ID: doerID},
|
||||
}, &SendTaskCreatedNotification{})
|
||||
|
||||
db.AssertExists(t, "notifications", map[string]interface{}{
|
||||
"notifiable_id": subscriberID,
|
||||
"name": notificationName,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("does not notify the creator", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
subscribeToProject(t, s, doerID)
|
||||
|
||||
task, err := GetTaskByIDSimple(s, taskID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
_ = s.Close()
|
||||
|
||||
events.TestListener(t, &TaskCreatedEvent{
|
||||
Task: &task,
|
||||
Doer: &user.User{ID: doerID},
|
||||
}, &SendTaskCreatedNotification{})
|
||||
|
||||
db.AssertMissing(t, "notifications", map[string]interface{}{
|
||||
"notifiable_id": doerID,
|
||||
"name": notificationName,
|
||||
})
|
||||
})
|
||||
|
||||
// HandleTaskCreateMentions already notifies mentioned users for the same event.
|
||||
t.Run("does not notify subscribers mentioned in the description", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
subscribeToProject(t, s, subscriberID)
|
||||
|
||||
task, err := GetTaskByIDSimple(s, taskID)
|
||||
require.NoError(t, err)
|
||||
task.Description = `<p><mention-user data-id="user2">@user2</mention-user></p>`
|
||||
require.NoError(t, s.Commit())
|
||||
_ = s.Close()
|
||||
|
||||
events.TestListener(t, &TaskCreatedEvent{
|
||||
Task: &task,
|
||||
Doer: &user.User{ID: doerID},
|
||||
}, &SendTaskCreatedNotification{})
|
||||
|
||||
db.AssertMissing(t, "notifications", map[string]interface{}{
|
||||
"notifiable_id": subscriberID,
|
||||
"name": notificationName,
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user