diff --git a/docs/content/doc/development/events-and-listeners.md b/docs/content/doc/development/events-and-listeners.md index 46f5c0896..091a5fb00 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 e8dac3441..b65b2ac83 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 bf105e110..0616fb0ac 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 b962c3a0f..4d305e0d1 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 000000000..b6acae94e --- /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 222c24412..f1651b10b 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 1751e4c51..9b5c8c1de 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 70795c44b..b86b07b27 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 000000000..5346ee1bb --- /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 000000000..d42e639b1 --- /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 77cb8fb61..2e649ffac 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 56831b0c5..5e0c34e50 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 5397888db..077754ded 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 4641f44a2..2597bd12e 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 655d3f565..ba5476481 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 36050157d..bd312be00 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 f4df92be4..da40c4c10 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 36514639a..5ade58ba0 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)