diff --git a/frontend/src/components/notifications/Notifications.vue b/frontend/src/components/notifications/Notifications.vue index 42f38e847..5cd51b8bc 100644 --- a/frontend/src/components/notifications/Notifications.vue +++ b/frontend/src/components/notifications/Notifications.vue @@ -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}} diff --git a/frontend/src/modelTypes/INotification.ts b/frontend/src/modelTypes/INotification.ts index 056979532..e7792cdae 100644 --- a/frontend/src/modelTypes/INotification.ts +++ b/frontend/src/modelTypes/INotification.ts @@ -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', diff --git a/frontend/src/models/notification.ts b/frontend/src/models/notification.ts index ed2e6c36a..f94075473 100644 --- a/frontend/src/models/notification.ts +++ b/frontend/src/models/notification.ts @@ -43,6 +43,13 @@ export default class NotificationModel extends AbstractModel 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 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: diff --git a/pkg/i18n/lang/en.json b/pkg/i18n/lang/en.json index b122d8610..91c3fc26c 100644 --- a/pkg/i18n/lang/en.json +++ b/pkg/i18n/lang/en.json @@ -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)" diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 5475a994e..b9dad87b7 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -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 { } diff --git a/pkg/models/listeners_test.go b/pkg/models/listeners_test.go index 45e5695fe..931a90d32 100644 --- a/pkg/models/listeners_test.go +++ b/pkg/models/listeners_test.go @@ -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) { diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index c283d0d8b..e9ab20926 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -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"` diff --git a/pkg/models/notifications_permissions.go b/pkg/models/notifications_permissions.go index e8fe41dea..36f7cc8af 100644 --- a/pkg/models/notifications_permissions.go +++ b/pkg/models/notifications_permissions.go @@ -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) } diff --git a/pkg/models/notifications_permissions_test.go b/pkg/models/notifications_permissions_test.go index edd06154d..a5e043cf7 100644 --- a/pkg/models/notifications_permissions_test.go +++ b/pkg/models/notifications_permissions_test.go @@ -169,6 +169,7 @@ func TestNotificationScopeClassification(t *testing.T) { "project.created", "task.assigned", "task.comment", + "task.created", "task.deleted", "task.mentioned", "task.reminder", diff --git a/pkg/models/notifications_refresh.go b/pkg/models/notifications_refresh.go index f825b3ea2..87d8c3c2e 100644 --- a/pkg/models/notifications_refresh.go +++ b/pkg/models/notifications_refresh.go @@ -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: diff --git a/pkg/models/task_created_notification_test.go b/pkg/models/task_created_notification_test.go new file mode 100644 index 000000000..f124c2856 --- /dev/null +++ b/pkg/models/task_created_notification_test.go @@ -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 . + +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 = `

@user2

` + 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, + }) + }) +}