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:
parent
e600f61e06
commit
1571dfa825
18 changed files with 619 additions and 15 deletions
|
@ -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.
|
||||||
|
|
|
@ -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!").
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
43
pkg/migration/20210729142940.go
Normal file
43
pkg/migration/20210729142940.go
Normal 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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -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 //
|
||||||
//////////////////////
|
//////////////////////
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
41
pkg/models/mentions.go
Normal 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
180
pkg/models/mentions_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(¬ifications)
|
||||||
|
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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue