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 >}}
|
||||
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.
|
||||
|
|
|
@ -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!").
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
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"
|
||||
}
|
||||
|
||||
// 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 //
|
||||
//////////////////////
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
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)
|
||||
})
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue