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:
kolaente
2026-08-27 15:41:58 +02:00
parent af3de11489
commit 64a5518511
11 changed files with 270 additions and 0 deletions
@@ -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}}
+1
View File
@@ -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',
+9
View File
@@ -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:
+4
View File
@@ -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)"
+81
View File
@@ -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 {
}
+18
View File
@@ -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
View File
@@ -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"`
+2
View File
@@ -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",
+2
View File
@@ -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,
})
})
}