diff --git a/docs/content/doc/development/events-and-listeners.md b/docs/content/doc/development/events-and-listeners.md
index 46f5c08965..091a5fb00b 100644
--- a/docs/content/doc/development/events-and-listeners.md
+++ b/docs/content/doc/development/events-and-listeners.md
@@ -193,3 +193,19 @@ This prevents any events from being fired and lets you assert an event has been
{{< highlight golang >}}
events.AssertDispatched(t, &TaskCreatedEvent{})
{{< /highlight >}}
+
+### Testing a listener
+
+You can call an event listener manually with the `events.TestListener` method like so:
+
+{{< highlight golang >}}
+ev := &TaskCommentCreatedEvent{
+ Task: &task,
+ Doer: u,
+ Comment: tc,
+}
+
+events.TestListener(t, ev, &SendTaskCommentNotification{})
+{{< /highlight >}}
+
+This will call the listener's `Handle` method and assert it did not return an error when calling.
diff --git a/pkg/cmd/testmail.go b/pkg/cmd/testmail.go
index e8dac3441d..b65b2ac83f 100644
--- a/pkg/cmd/testmail.go
+++ b/pkg/cmd/testmail.go
@@ -42,7 +42,7 @@ var testmailCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
log.Info("Sending testmail...")
message := notifications.NewMail().
- From(config.MailerFromEmail.GetString()).
+ From("Vikunja <"+config.MailerFromEmail.GetString()+">").
To(args[0]).
Subject("Test from Vikunja").
Line("This is a test mail!").
diff --git a/pkg/events/testing.go b/pkg/events/testing.go
index bf105e110e..0616fb0acb 100644
--- a/pkg/events/testing.go
+++ b/pkg/events/testing.go
@@ -17,8 +17,12 @@
package events
import (
+ "encoding/json"
"testing"
+ "github.com/ThreeDotsLabs/watermill"
+ "github.com/ThreeDotsLabs/watermill/message"
+
"github.com/stretchr/testify/assert"
)
@@ -47,3 +51,13 @@ func AssertDispatched(t *testing.T, event Event) {
assert.True(t, found, "Failed to assert "+event.Name()+" has been dispatched.")
}
+
+// TestListener takes an event and a listener and calls the listener's Handle method.
+func TestListener(t *testing.T, event Event, listener Listener) {
+ content, err := json.Marshal(event)
+ assert.NoError(t, err)
+
+ msg := message.NewMessage(watermill.NewUUID(), content)
+ err = listener.Handle(msg)
+ assert.NoError(t, err)
+}
diff --git a/pkg/mail/send_mail.go b/pkg/mail/send_mail.go
index b962c3a0f5..4d305e0d18 100644
--- a/pkg/mail/send_mail.go
+++ b/pkg/mail/send_mail.go
@@ -72,7 +72,7 @@ func SendTestMail(opts *Opts) error {
func sendMail(opts *Opts) *gomail.Message {
m := gomail.NewMessage()
if opts.From == "" {
- opts.From = config.MailerFromEmail.GetString()
+ opts.From = "Vikunja <" + config.MailerFromEmail.GetString() + ">"
}
m.SetHeader("From", opts.From)
m.SetHeader("To", opts.To)
diff --git a/pkg/migration/20210729142940.go b/pkg/migration/20210729142940.go
new file mode 100644
index 0000000000..b6acae94e5
--- /dev/null
+++ b/pkg/migration/20210729142940.go
@@ -0,0 +1,43 @@
+// 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 .
+
+package migration
+
+import (
+ "src.techknowlogick.com/xormigrate"
+ "xorm.io/xorm"
+)
+
+type notifications20210729142940 struct {
+ SubjectID int64 `xorm:"bigint null" json:"-"`
+}
+
+func (notifications20210729142940) TableName() string {
+ return "notifications"
+}
+
+func init() {
+ migrations = append(migrations, &xormigrate.Migration{
+ ID: "20210729142940",
+ Description: "Add subject id to notification",
+ Migrate: func(tx *xorm.Engine) error {
+ return tx.Sync2(notifications20210729142940{})
+ },
+ Rollback: func(tx *xorm.Engine) error {
+ return nil
+ },
+ })
+}
diff --git a/pkg/models/events.go b/pkg/models/events.go
index 222c244120..f1651b10b8 100644
--- a/pkg/models/events.go
+++ b/pkg/models/events.go
@@ -82,6 +82,18 @@ func (t *TaskCommentCreatedEvent) Name() string {
return "task.comment.created"
}
+// TaskCommentUpdatedEvent represents a TaskCommentUpdatedEvent event
+type TaskCommentUpdatedEvent struct {
+ Task *Task
+ Comment *TaskComment
+ Doer *user.User
+}
+
+// Name defines the name for TaskCommentUpdatedEvent
+func (t *TaskCommentUpdatedEvent) Name() string {
+ return "task.comment.edited"
+}
+
//////////////////////
// Namespace Events //
//////////////////////
diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go
index 1751e4c51d..9b5c8c1de6 100644
--- a/pkg/models/listeners.go
+++ b/pkg/models/listeners.go
@@ -25,7 +25,10 @@ import (
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/notifications"
+ "code.vikunja.io/api/pkg/user"
+
"github.com/ThreeDotsLabs/watermill/message"
+ "xorm.io/xorm"
)
// RegisterListeners registers all event listeners
@@ -44,6 +47,9 @@ func RegisterListeners() {
events.RegisterListener((&ListCreatedEvent{}).Name(), &SendListCreatedNotification{})
events.RegisterListener((&TaskAssigneeCreatedEvent{}).Name(), &SubscribeAssigneeToTask{})
events.RegisterListener((&TeamMemberAddedEvent{}).Name(), &SendTeamMemberAddedNotification{})
+ events.RegisterListener((&TaskCommentUpdatedEvent{}).Name(), &HandleTaskCommentEditMentions{})
+ events.RegisterListener((&TaskCreatedEvent{}).Name(), &HandleTaskCreateMentions{})
+ events.RegisterListener((&TaskUpdatedEvent{}).Name(), &HandleTaskUpdatedMentions{})
}
//////
@@ -58,7 +64,7 @@ func (s *IncreaseTaskCounter) Name() string {
return "task.counter.increase"
}
-// Hanlde is executed when the event IncreaseTaskCounter listens on is fired
+// Handle is executed when the event IncreaseTaskCounter listens on is fired
func (s *IncreaseTaskCounter) Handle(msg *message.Message) (err error) {
return keyvalue.IncrBy(metrics.TaskCountKey, 1)
}
@@ -72,11 +78,56 @@ func (s *DecreaseTaskCounter) Name() string {
return "task.counter.decrease"
}
-// Hanlde is executed when the event DecreaseTaskCounter listens on is fired
+// Handle is executed when the event DecreaseTaskCounter listens on is fired
func (s *DecreaseTaskCounter) Handle(msg *message.Message) (err error) {
return keyvalue.DecrBy(metrics.TaskCountKey, 1)
}
+func notifyMentionedUsers(sess *xorm.Session, task *Task, text string, n notifications.NotificationWithSubject) (users map[int64]*user.User, err error) {
+ users, err = FindMentionedUsersInText(sess, text)
+ if err != nil {
+ return
+ }
+
+ if len(users) == 0 {
+ return
+ }
+
+ log.Debugf("Processing %d mentioned users for text %d", len(users), n.SubjectID())
+
+ var notified int
+ for _, u := range users {
+ can, _, err := task.CanRead(sess, u)
+ if err != nil {
+ return users, err
+ }
+
+ if !can {
+ continue
+ }
+
+ // Don't notify a user if they were already notified
+ dbn, err := notifications.GetNotificationsForNameAndUser(sess, u.ID, n.Name(), n.SubjectID())
+ if err != nil {
+ return users, err
+ }
+
+ if len(dbn) > 0 {
+ continue
+ }
+
+ err = notifications.Notify(u, n)
+ if err != nil {
+ return users, err
+ }
+ notified++
+ }
+
+ log.Debugf("Notified %d mentioned users for text %d", notified, n.SubjectID())
+
+ return
+}
+
// SendTaskCommentNotification represents a listener
type SendTaskCommentNotification struct {
}
@@ -97,6 +148,17 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) {
sess := db.NewSession()
defer sess.Close()
+ n := &TaskCommentNotification{
+ Doer: event.Doer,
+ Task: event.Task,
+ Comment: event.Comment,
+ Mentioned: true,
+ }
+ mentionedUsers, err := notifyMentionedUsers(sess, event.Task, event.Comment.Comment, n)
+ if err != nil {
+ return err
+ }
+
subscribers, err := getSubscribersForEntity(sess, SubscriptionEntityTask, event.Task.ID)
if err != nil {
return err
@@ -109,6 +171,10 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) {
continue
}
+ if _, has := mentionedUsers[subscriber.UserID]; has {
+ continue
+ }
+
n := &TaskCommentNotification{
Doer: event.Doer,
Task: event.Task,
@@ -123,6 +189,36 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) {
return
}
+// HandleTaskCommentEditMentions represents a listener
+type HandleTaskCommentEditMentions struct {
+}
+
+// Name defines the name for the HandleTaskCommentEditMentions listener
+func (s *HandleTaskCommentEditMentions) Name() string {
+ return "handle.task.comment.edit.mentions"
+}
+
+// Handle is executed when the event HandleTaskCommentEditMentions listens on is fired
+func (s *HandleTaskCommentEditMentions) Handle(msg *message.Message) (err error) {
+ event := &TaskCommentUpdatedEvent{}
+ err = json.Unmarshal(msg.Payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ n := &TaskCommentNotification{
+ Doer: event.Doer,
+ Task: event.Task,
+ Comment: event.Comment,
+ Mentioned: true,
+ }
+ _, err = notifyMentionedUsers(sess, event.Task, event.Comment.Comment, n)
+ return err
+}
+
// SendTaskAssignedNotification represents a listener
type SendTaskAssignedNotification struct {
}
@@ -247,6 +343,65 @@ func (s *SubscribeAssigneeToTask) Handle(msg *message.Message) (err error) {
return sess.Commit()
}
+// HandleTaskCreateMentions represents a listener
+type HandleTaskCreateMentions struct {
+}
+
+// Name defines the name for the HandleTaskCreateMentions listener
+func (s *HandleTaskCreateMentions) Name() string {
+ return "task.created.mentions"
+}
+
+// Handle is executed when the event HandleTaskCreateMentions listens on is fired
+func (s *HandleTaskCreateMentions) Handle(msg *message.Message) (err error) {
+ event := &TaskCreatedEvent{}
+ err = json.Unmarshal(msg.Payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ n := &UserMentionedInTaskNotification{
+ Task: event.Task,
+ Doer: event.Doer,
+ IsNew: true,
+ }
+ _, err = notifyMentionedUsers(sess, event.Task, event.Task.Description, n)
+ return err
+}
+
+// HandleTaskUpdatedMentions represents a listener
+type HandleTaskUpdatedMentions struct {
+}
+
+// Name defines the name for the HandleTaskUpdatedMentions listener
+func (s *HandleTaskUpdatedMentions) Name() string {
+ return "task.updated.mentions"
+}
+
+// Handle is executed when the event HandleTaskUpdatedMentions listens on is fired
+func (s *HandleTaskUpdatedMentions) Handle(msg *message.Message) (err error) {
+ event := &TaskUpdatedEvent{}
+ err = json.Unmarshal(msg.Payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ n := &UserMentionedInTaskNotification{
+ Task: event.Task,
+ Doer: event.Doer,
+ IsNew: false,
+ }
+ _, err = notifyMentionedUsers(sess, event.Task, event.Task.Description, n)
+ return err
+
+}
+
///////
// List Event Listeners
diff --git a/pkg/models/main_test.go b/pkg/models/main_test.go
index 70795c44bb..b86b07b279 100644
--- a/pkg/models/main_test.go
+++ b/pkg/models/main_test.go
@@ -22,9 +22,8 @@ import (
"testing"
"time"
- "code.vikunja.io/api/pkg/events"
-
"code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/user"
)
diff --git a/pkg/models/mentions.go b/pkg/models/mentions.go
new file mode 100644
index 0000000000..5346ee1bbf
--- /dev/null
+++ b/pkg/models/mentions.go
@@ -0,0 +1,41 @@
+// 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 .
+
+package models
+
+import (
+ "regexp"
+ "strings"
+
+ "code.vikunja.io/api/pkg/user"
+
+ "xorm.io/xorm"
+)
+
+func FindMentionedUsersInText(s *xorm.Session, text string) (users map[int64]*user.User, err error) {
+ reg := regexp.MustCompile(`@\w+`)
+ matches := reg.FindAllString(text, -1)
+ if matches == nil {
+ return
+ }
+
+ usernames := []string{}
+ for _, match := range matches {
+ usernames = append(usernames, strings.TrimPrefix(match, "@"))
+ }
+
+ return user.GetUsersByUsername(s, usernames, true)
+}
diff --git a/pkg/models/mentions_test.go b/pkg/models/mentions_test.go
new file mode 100644
index 0000000000..d42e639b1a
--- /dev/null
+++ b/pkg/models/mentions_test.go
@@ -0,0 +1,180 @@
+// 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 .
+
+package models
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/notifications"
+ "code.vikunja.io/api/pkg/user"
+)
+
+func TestFindMentionedUsersInText(t *testing.T) {
+ user1 := &user.User{
+ ID: 1,
+ }
+ user2 := &user.User{
+ ID: 2,
+ }
+
+ tests := []struct {
+ name string
+ text string
+ wantUsers []*user.User
+ wantErr bool
+ }{
+ {
+ name: "no users mentioned",
+ text: "Lorem Ipsum dolor sit amet",
+ },
+ {
+ name: "one user at the beginning",
+ text: "@user1 Lorem Ipsum",
+ wantUsers: []*user.User{user1},
+ },
+ {
+ name: "one user at the end",
+ text: "Lorem Ipsum @user1",
+ wantUsers: []*user.User{user1},
+ },
+ {
+ name: "one user in the middle",
+ text: "Lorem @user1 Ipsum",
+ wantUsers: []*user.User{user1},
+ },
+ {
+ name: "same user multiple times",
+ text: "Lorem @user1 Ipsum @user1 @user1",
+ wantUsers: []*user.User{user1},
+ },
+ {
+ name: "Multiple users",
+ text: "Lorem @user1 Ipsum @user2",
+ wantUsers: []*user.User{user1, user2},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ gotUsers, err := FindMentionedUsersInText(s, tt.text)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("FindMentionedUsersInText() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ for _, u := range tt.wantUsers {
+ _, has := gotUsers[u.ID]
+ if !has {
+ t.Errorf("wanted user %d but did not get it", u.ID)
+ }
+ }
+ })
+ }
+}
+
+func TestSendingMentionNotification(t *testing.T) {
+ u := &user.User{ID: 1}
+
+ t.Run("should send notifications to all users having access", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ task, err := GetTaskByIDSimple(s, 32)
+ assert.NoError(t, err)
+ tc := &TaskComment{
+ Comment: "Lorem Ipsum @user1 @user2 @user3 @user4 @user5 @user6",
+ TaskID: 32, // user2 has access to the list that task belongs to
+ }
+ err = tc.Create(s, u)
+ assert.NoError(t, err)
+ n := &TaskCommentNotification{
+ Doer: u,
+ Task: &task,
+ Comment: tc,
+ }
+
+ _, err = notifyMentionedUsers(s, &task, tc.Comment, n)
+ assert.NoError(t, err)
+
+ db.AssertExists(t, "notifications", map[string]interface{}{
+ "subject_id": tc.ID,
+ "notifiable_id": 1,
+ "name": n.Name(),
+ }, false)
+ db.AssertExists(t, "notifications", map[string]interface{}{
+ "subject_id": tc.ID,
+ "notifiable_id": 2,
+ "name": n.Name(),
+ }, false)
+ db.AssertExists(t, "notifications", map[string]interface{}{
+ "subject_id": tc.ID,
+ "notifiable_id": 3,
+ "name": n.Name(),
+ }, false)
+ db.AssertMissing(t, "notifications", map[string]interface{}{
+ "subject_id": tc.ID,
+ "notifiable_id": 4,
+ "name": n.Name(),
+ })
+ db.AssertMissing(t, "notifications", map[string]interface{}{
+ "subject_id": tc.ID,
+ "notifiable_id": 5,
+ "name": n.Name(),
+ })
+ db.AssertMissing(t, "notifications", map[string]interface{}{
+ "subject_id": tc.ID,
+ "notifiable_id": 6,
+ "name": n.Name(),
+ })
+ })
+ t.Run("should not send notifications multiple times", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ task, err := GetTaskByIDSimple(s, 32)
+ assert.NoError(t, err)
+ tc := &TaskComment{
+ Comment: "Lorem Ipsum @user2",
+ TaskID: 32, // user2 has access to the list that task belongs to
+ }
+ err = tc.Create(s, u)
+ assert.NoError(t, err)
+ n := &TaskCommentNotification{
+ Doer: u,
+ Task: &task,
+ Comment: tc,
+ }
+
+ _, err = notifyMentionedUsers(s, &task, tc.Comment, n)
+ assert.NoError(t, err)
+
+ _, err = notifyMentionedUsers(s, &task, "Lorem Ipsum @user2 @user3", n)
+ assert.NoError(t, err)
+
+ // The second time mentioning the user in the same task should not create another notification
+ dbNotifications, err := notifications.GetNotificationsForNameAndUser(s, 2, n.Name(), tc.ID)
+ assert.NoError(t, err)
+ assert.Len(t, dbNotifications, 1)
+ })
+}
diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go
index 77cb8fb61a..2e649ffac7 100644
--- a/pkg/models/notifications.go
+++ b/pkg/models/notifications.go
@@ -58,17 +58,29 @@ func (n *ReminderDueNotification) Name() string {
// TaskCommentNotification represents a TaskCommentNotification notification
type TaskCommentNotification struct {
- Doer *user.User `json:"doer"`
- Task *Task `json:"task"`
- Comment *TaskComment `json:"comment"`
+ Doer *user.User `json:"doer"`
+ Task *Task `json:"task"`
+ Comment *TaskComment `json:"comment"`
+ Mentioned bool `json:"mentioned"`
+}
+
+func (n *TaskCommentNotification) SubjectID() int64 {
+ return n.Comment.ID
}
// ToMail returns the mail notification for TaskCommentNotification
func (n *TaskCommentNotification) ToMail() *notifications.Mail {
mail := notifications.NewMail().
- From(n.Doer.GetNameAndFromEmail()).
- Subject("Re: " + n.Task.Title)
+ From(n.Doer.GetNameAndFromEmail())
+
+ subject := "Re: " + n.Task.Title
+ if n.Mentioned {
+ subject = n.Doer.GetName() + ` mentioned you in a comment in "` + n.Task.Title + `"`
+ mail.Line("**" + n.Doer.GetName() + "** mentioned you in a comment:")
+ }
+
+ mail.Subject(subject)
lines := bufio.NewScanner(strings.NewReader(n.Comment.Comment))
for lines.Scan() {
@@ -248,3 +260,45 @@ func (n *UndoneTasksOverdueNotification) ToDB() interface{} {
func (n *UndoneTasksOverdueNotification) Name() string {
return "task.undone.overdue"
}
+
+// UserMentionedInTaskNotification represents a UserMentionedInTaskNotification notification
+type UserMentionedInTaskNotification struct {
+ Doer *user.User `json:"doer"`
+ Task *Task `json:"task"`
+ IsNew bool `json:"is_new"`
+}
+
+func (n *UserMentionedInTaskNotification) SubjectID() int64 {
+ return n.Task.ID
+}
+
+// ToMail returns the mail notification for UserMentionedInTaskNotification
+func (n *UserMentionedInTaskNotification) ToMail() *notifications.Mail {
+ subject := n.Doer.GetName() + ` mentioned you in a new task "` + n.Task.Title + `"`
+ if n.IsNew {
+ subject = n.Doer.GetName() + ` mentioned you in a task "` + n.Task.Title + `"`
+ }
+
+ mail := notifications.NewMail().
+ From(n.Doer.GetNameAndFromEmail()).
+ Subject(subject).
+ Line("**" + n.Doer.GetName() + "** mentioned you in a task:")
+
+ lines := bufio.NewScanner(strings.NewReader(n.Task.Description))
+ for lines.Scan() {
+ mail.Line(lines.Text())
+ }
+
+ return mail.
+ Action("View Task", n.Task.GetFrontendURL())
+}
+
+// ToDB returns the UserMentionedInTaskNotification notification in a format which can be saved in the db
+func (n *UserMentionedInTaskNotification) ToDB() interface{} {
+ return n
+}
+
+// Name returns the name of the notification
+func (n *UserMentionedInTaskNotification) Name() string {
+ return "task.mentioned"
+}
diff --git a/pkg/models/task_comments.go b/pkg/models/task_comments.go
index 56831b0c5b..5e0c34e500 100644
--- a/pkg/models/task_comments.go
+++ b/pkg/models/task_comments.go
@@ -132,7 +132,21 @@ func (tc *TaskComment) Update(s *xorm.Session, a web.Auth) error {
if updated == 0 {
return ErrTaskCommentDoesNotExist{ID: tc.ID}
}
- return err
+
+ if err != nil {
+ return err
+ }
+
+ task, err := GetTaskSimple(s, &Task{ID: tc.TaskID})
+ if err != nil {
+ return err
+ }
+
+ return events.Dispatch(&TaskCommentUpdatedEvent{
+ Task: &task,
+ Comment: tc,
+ Doer: tc.Author,
+ })
}
// ReadOne handles getting a single comment
diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go
index 5397888db0..077754ded2 100644
--- a/pkg/models/task_comments_test.go
+++ b/pkg/models/task_comments_test.go
@@ -19,6 +19,8 @@ package models
import (
"testing"
+ "code.vikunja.io/api/pkg/events"
+
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
@@ -41,6 +43,7 @@ func TestTaskComment_Create(t *testing.T) {
assert.Equal(t, int64(1), tc.Author.ID)
err = s.Commit()
assert.NoError(t, err)
+ events.AssertDispatched(t, &TaskCommentCreatedEvent{})
db.AssertExists(t, "task_comments", map[string]interface{}{
"id": tc.ID,
@@ -62,6 +65,32 @@ func TestTaskComment_Create(t *testing.T) {
assert.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(err))
})
+ t.Run("should send notifications for comment mentions", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ task, err := GetTaskByIDSimple(s, 32)
+ assert.NoError(t, err)
+ tc := &TaskComment{
+ Comment: "Lorem Ipsum @user2",
+ TaskID: 32, // user2 has access to the list that task belongs to
+ }
+ err = tc.Create(s, u)
+ assert.NoError(t, err)
+ ev := &TaskCommentCreatedEvent{
+ Task: &task,
+ Doer: u,
+ Comment: tc,
+ }
+
+ events.TestListener(t, ev, &SendTaskCommentNotification{})
+ db.AssertExists(t, "notifications", map[string]interface{}{
+ "subject_id": tc.ID,
+ "notifiable_id": 2,
+ "name": (&TaskCommentNotification{}).Name(),
+ }, false)
+ })
}
func TestTaskComment_Delete(t *testing.T) {
diff --git a/pkg/models/unit_tests.go b/pkg/models/unit_tests.go
index 4641f44a2e..2597bd12e8 100644
--- a/pkg/models/unit_tests.go
+++ b/pkg/models/unit_tests.go
@@ -21,6 +21,7 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
+ "code.vikunja.io/api/pkg/notifications"
)
// SetupTests takes care of seting up the db, fixtures etc.
@@ -32,7 +33,11 @@ func SetupTests() {
log.Fatal(err)
}
- err = x.Sync2(GetTables()...)
+ tables := []interface{}{}
+ tables = append(tables, GetTables()...)
+ tables = append(tables, notifications.GetTables()...)
+
+ err = x.Sync2(tables...)
if err != nil {
log.Fatal(err)
}
diff --git a/pkg/notifications/database.go b/pkg/notifications/database.go
index 655d3f565e..ba54764813 100644
--- a/pkg/notifications/database.go
+++ b/pkg/notifications/database.go
@@ -33,6 +33,8 @@ type DatabaseNotification struct {
Notification interface{} `xorm:"json not null" json:"notification"`
// The name of the notification
Name string `xorm:"varchar(250) index not null" json:"name"`
+ // The thing the notification is about. Used to check if a notification for this thing already happened or not.
+ SubjectID int64 `xorm:"bigint null" json:"-"`
// When this notification is marked as read, this will be updated with the current timestamp.
ReadAt time.Time `xorm:"datetime null" json:"read_at"`
@@ -65,6 +67,13 @@ func GetNotificationsForUser(s *xorm.Session, notifiableID int64, limit, start i
return notifications, len(notifications), total, err
}
+func GetNotificationsForNameAndUser(s *xorm.Session, notifiableID int64, event string, subjectID int64) (notifications []*DatabaseNotification, err error) {
+ notifications = []*DatabaseNotification{}
+ err = s.Where("notifiable_id = ? AND name = ? AND subject_id = ?", notifiableID, event, subjectID).
+ Find(¬ifications)
+ return
+}
+
// CanMarkNotificationAsRead checks if a user can mark a notification as read.
func CanMarkNotificationAsRead(s *xorm.Session, notification *DatabaseNotification, notifiableID int64) (can bool, err error) {
can, err = s.
diff --git a/pkg/notifications/main_test.go b/pkg/notifications/main_test.go
index 36050157dc..bd312be008 100644
--- a/pkg/notifications/main_test.go
+++ b/pkg/notifications/main_test.go
@@ -20,11 +20,10 @@ import (
"os"
"testing"
+ "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
-
- "code.vikunja.io/api/pkg/config"
)
// SetupTests initializes all db tests
diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go
index f4df92be41..da40c4c102 100644
--- a/pkg/notifications/notification.go
+++ b/pkg/notifications/notification.go
@@ -29,6 +29,15 @@ type Notification interface {
Name() string
}
+type SubjectID interface {
+ SubjectID() int64
+}
+
+type NotificationWithSubject interface {
+ Notification
+ SubjectID
+}
+
// Notifiable is an entity which can be notified. Usually a user.
type Notifiable interface {
// Should return the email address this notifiable has.
@@ -82,6 +91,10 @@ func notifyDB(notifiable Notifiable, notification Notification) (err error) {
Name: notification.Name(),
}
+ if subject, is := notification.(SubjectID); is {
+ dbNotification.SubjectID = subject.SubjectID()
+ }
+
_, err = s.Insert(dbNotification)
if err != nil {
_ = s.Rollback()
diff --git a/pkg/user/user.go b/pkg/user/user.go
index 36514639a2..5ade58ba05 100644
--- a/pkg/user/user.go
+++ b/pkg/user/user.go
@@ -197,6 +197,27 @@ func GetUserByUsername(s *xorm.Session, username string) (user *User, err error)
return getUser(s, &User{Username: username}, false)
}
+// GetUsersByUsername returns a slice of users with the provided usernames
+func GetUsersByUsername(s *xorm.Session, usernames []string, withEmails bool) (users map[int64]*User, err error) {
+ if len(usernames) == 0 {
+ return
+ }
+
+ users = make(map[int64]*User)
+ err = s.In("username", usernames).Find(&users)
+ if err != nil {
+ return
+ }
+
+ if !withEmails {
+ for _, u := range users {
+ u.Email = ""
+ }
+ }
+
+ return
+}
+
// GetUserWithEmail returns a user object with email
func GetUserWithEmail(s *xorm.Session, user *User) (userOut *User, err error) {
return getUser(s, user, true)