Task mentions (#926)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/926
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-07-29 15:42:49 +00:00
parent e600f61e06
commit 1571dfa825
18 changed files with 619 additions and 15 deletions

View file

@ -193,3 +193,19 @@ This prevents any events from being fired and lets you assert an event has been
{{< highlight golang >}} {{< highlight golang >}}
events.AssertDispatched(t, &TaskCreatedEvent{}) events.AssertDispatched(t, &TaskCreatedEvent{})
{{< /highlight >}} {{< /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.

View file

@ -42,7 +42,7 @@ var testmailCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
log.Info("Sending testmail...") log.Info("Sending testmail...")
message := notifications.NewMail(). message := notifications.NewMail().
From(config.MailerFromEmail.GetString()). From("Vikunja <"+config.MailerFromEmail.GetString()+">").
To(args[0]). To(args[0]).
Subject("Test from Vikunja"). Subject("Test from Vikunja").
Line("This is a test mail!"). Line("This is a test mail!").

View file

@ -17,8 +17,12 @@
package events package events
import ( import (
"encoding/json"
"testing" "testing"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/stretchr/testify/assert" "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.") 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)
}

View file

@ -72,7 +72,7 @@ func SendTestMail(opts *Opts) error {
func sendMail(opts *Opts) *gomail.Message { func sendMail(opts *Opts) *gomail.Message {
m := gomail.NewMessage() m := gomail.NewMessage()
if opts.From == "" { if opts.From == "" {
opts.From = config.MailerFromEmail.GetString() opts.From = "Vikunja <" + config.MailerFromEmail.GetString() + ">"
} }
m.SetHeader("From", opts.From) m.SetHeader("From", opts.From)
m.SetHeader("To", opts.To) m.SetHeader("To", opts.To)

View file

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View file

@ -82,6 +82,18 @@ func (t *TaskCommentCreatedEvent) Name() string {
return "task.comment.created" 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 // // Namespace Events //
////////////////////// //////////////////////

View file

@ -25,7 +25,10 @@ import (
"code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message"
"xorm.io/xorm"
) )
// RegisterListeners registers all event listeners // RegisterListeners registers all event listeners
@ -44,6 +47,9 @@ func RegisterListeners() {
events.RegisterListener((&ListCreatedEvent{}).Name(), &SendListCreatedNotification{}) events.RegisterListener((&ListCreatedEvent{}).Name(), &SendListCreatedNotification{})
events.RegisterListener((&TaskAssigneeCreatedEvent{}).Name(), &SubscribeAssigneeToTask{}) events.RegisterListener((&TaskAssigneeCreatedEvent{}).Name(), &SubscribeAssigneeToTask{})
events.RegisterListener((&TeamMemberAddedEvent{}).Name(), &SendTeamMemberAddedNotification{}) 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" 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) { func (s *IncreaseTaskCounter) Handle(msg *message.Message) (err error) {
return keyvalue.IncrBy(metrics.TaskCountKey, 1) return keyvalue.IncrBy(metrics.TaskCountKey, 1)
} }
@ -72,11 +78,56 @@ func (s *DecreaseTaskCounter) Name() string {
return "task.counter.decrease" 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) { func (s *DecreaseTaskCounter) Handle(msg *message.Message) (err error) {
return keyvalue.DecrBy(metrics.TaskCountKey, 1) 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 // SendTaskCommentNotification represents a listener
type SendTaskCommentNotification struct { type SendTaskCommentNotification struct {
} }
@ -97,6 +148,17 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) {
sess := db.NewSession() sess := db.NewSession()
defer sess.Close() 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) subscribers, err := getSubscribersForEntity(sess, SubscriptionEntityTask, event.Task.ID)
if err != nil { if err != nil {
return err return err
@ -109,6 +171,10 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) {
continue continue
} }
if _, has := mentionedUsers[subscriber.UserID]; has {
continue
}
n := &TaskCommentNotification{ n := &TaskCommentNotification{
Doer: event.Doer, Doer: event.Doer,
Task: event.Task, Task: event.Task,
@ -123,6 +189,36 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) {
return 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 // SendTaskAssignedNotification represents a listener
type SendTaskAssignedNotification struct { type SendTaskAssignedNotification struct {
} }
@ -247,6 +343,65 @@ func (s *SubscribeAssigneeToTask) Handle(msg *message.Message) (err error) {
return sess.Commit() 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 // List Event Listeners

View file

@ -22,9 +22,8 @@ import (
"testing" "testing"
"time" "time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
) )

41
pkg/models/mentions.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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)
}

180
pkg/models/mentions_test.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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)
})
}

View file

@ -61,14 +61,26 @@ type TaskCommentNotification struct {
Doer *user.User `json:"doer"` Doer *user.User `json:"doer"`
Task *Task `json:"task"` Task *Task `json:"task"`
Comment *TaskComment `json:"comment"` Comment *TaskComment `json:"comment"`
Mentioned bool `json:"mentioned"`
}
func (n *TaskCommentNotification) SubjectID() int64 {
return n.Comment.ID
} }
// ToMail returns the mail notification for TaskCommentNotification // ToMail returns the mail notification for TaskCommentNotification
func (n *TaskCommentNotification) ToMail() *notifications.Mail { func (n *TaskCommentNotification) ToMail() *notifications.Mail {
mail := notifications.NewMail(). mail := notifications.NewMail().
From(n.Doer.GetNameAndFromEmail()). From(n.Doer.GetNameAndFromEmail())
Subject("Re: " + n.Task.Title)
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)) lines := bufio.NewScanner(strings.NewReader(n.Comment.Comment))
for lines.Scan() { for lines.Scan() {
@ -248,3 +260,45 @@ func (n *UndoneTasksOverdueNotification) ToDB() interface{} {
func (n *UndoneTasksOverdueNotification) Name() string { func (n *UndoneTasksOverdueNotification) Name() string {
return "task.undone.overdue" 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"
}

View file

@ -132,9 +132,23 @@ func (tc *TaskComment) Update(s *xorm.Session, a web.Auth) error {
if updated == 0 { if updated == 0 {
return ErrTaskCommentDoesNotExist{ID: tc.ID} return ErrTaskCommentDoesNotExist{ID: tc.ID}
} }
if err != nil {
return err 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 // ReadOne handles getting a single comment
// @Summary Remove a task comment // @Summary Remove a task comment
// @Description Remove a task comment. The user doing this need to have at least read access to the task this comment belongs to. // @Description Remove a task comment. The user doing this need to have at least read access to the task this comment belongs to.

View file

@ -19,6 +19,8 @@ package models
import ( import (
"testing" "testing"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -41,6 +43,7 @@ func TestTaskComment_Create(t *testing.T) {
assert.Equal(t, int64(1), tc.Author.ID) assert.Equal(t, int64(1), tc.Author.ID)
err = s.Commit() err = s.Commit()
assert.NoError(t, err) assert.NoError(t, err)
events.AssertDispatched(t, &TaskCommentCreatedEvent{})
db.AssertExists(t, "task_comments", map[string]interface{}{ db.AssertExists(t, "task_comments", map[string]interface{}{
"id": tc.ID, "id": tc.ID,
@ -62,6 +65,32 @@ func TestTaskComment_Create(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(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) { func TestTaskComment_Delete(t *testing.T) {

View file

@ -21,6 +21,7 @@ import (
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/notifications"
) )
// SetupTests takes care of seting up the db, fixtures etc. // SetupTests takes care of seting up the db, fixtures etc.
@ -32,7 +33,11 @@ func SetupTests() {
log.Fatal(err) 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View file

@ -33,6 +33,8 @@ type DatabaseNotification struct {
Notification interface{} `xorm:"json not null" json:"notification"` Notification interface{} `xorm:"json not null" json:"notification"`
// The name of the notification // The name of the notification
Name string `xorm:"varchar(250) index not null" json:"name"` 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. // When this notification is marked as read, this will be updated with the current timestamp.
ReadAt time.Time `xorm:"datetime null" json:"read_at"` 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 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(&notifications)
return
}
// CanMarkNotificationAsRead checks if a user can mark a notification as read. // CanMarkNotificationAsRead checks if a user can mark a notification as read.
func CanMarkNotificationAsRead(s *xorm.Session, notification *DatabaseNotification, notifiableID int64) (can bool, err error) { func CanMarkNotificationAsRead(s *xorm.Session, notification *DatabaseNotification, notifiableID int64) (can bool, err error) {
can, err = s. can, err = s.

View file

@ -20,11 +20,10 @@ import (
"os" "os"
"testing" "testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/config"
) )
// SetupTests initializes all db tests // SetupTests initializes all db tests

View file

@ -29,6 +29,15 @@ type Notification interface {
Name() string Name() string
} }
type SubjectID interface {
SubjectID() int64
}
type NotificationWithSubject interface {
Notification
SubjectID
}
// Notifiable is an entity which can be notified. Usually a user. // Notifiable is an entity which can be notified. Usually a user.
type Notifiable interface { type Notifiable interface {
// Should return the email address this notifiable has. // Should return the email address this notifiable has.
@ -82,6 +91,10 @@ func notifyDB(notifiable Notifiable, notification Notification) (err error) {
Name: notification.Name(), Name: notification.Name(),
} }
if subject, is := notification.(SubjectID); is {
dbNotification.SubjectID = subject.SubjectID()
}
_, err = s.Insert(dbNotification) _, err = s.Insert(dbNotification)
if err != nil { if err != nil {
_ = s.Rollback() _ = s.Rollback()

View file

@ -197,6 +197,27 @@ func GetUserByUsername(s *xorm.Session, username string) (user *User, err error)
return getUser(s, &User{Username: username}, false) 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 // GetUserWithEmail returns a user object with email
func GetUserWithEmail(s *xorm.Session, user *User) (userOut *User, err error) { func GetUserWithEmail(s *xorm.Session, user *User) (userOut *User, err error) {
return getUser(s, user, true) return getUser(s, user, true)