forked from rapidpro/mailroom
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request rapidpro#489 from nyaruka/notifications
📟 Notifications
- Loading branch information
Showing
10 changed files
with
307 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
package models | ||
|
||
import ( | ||
"context" | ||
"time" | ||
|
||
"github.com/nyaruka/mailroom/utils/dbutil" | ||
"github.com/pkg/errors" | ||
) | ||
|
||
// NotificationID is our type for notification ids | ||
type NotificationID int | ||
|
||
type NotificationType string | ||
|
||
const ( | ||
NotificationTypeChannelAlert NotificationType = "channel:alert" | ||
NotificationTypeExportFinished NotificationType = "export:finished" | ||
NotificationTypeImportFinished NotificationType = "import:finished" | ||
NotificationTypeTicketsOpened NotificationType = "tickets:opened" | ||
NotificationTypeTicketsActivity NotificationType = "tickets:activity" | ||
) | ||
|
||
type Notification struct { | ||
ID NotificationID `db:"id"` | ||
OrgID OrgID `db:"org_id"` | ||
Type NotificationType `db:"notification_type"` | ||
Scope string `db:"scope"` | ||
UserID UserID `db:"user_id"` | ||
IsSeen bool `db:"is_seen"` | ||
CreatedOn time.Time `db:"created_on"` | ||
|
||
ChannelID ChannelID `db:"channel_id"` | ||
ContactImportID ContactImportID `db:"contact_import_id"` | ||
} | ||
|
||
var ticketAssignableToles = []UserRole{UserRoleAdministrator, UserRoleEditor, UserRoleAgent} | ||
|
||
// NotificationsFromTicketEvents logs the opening of new tickets and notifies all assignable users if tickets is not already assigned | ||
func NotificationsFromTicketEvents(ctx context.Context, db Queryer, oa *OrgAssets, events map[*Ticket]*TicketEvent) error { | ||
notifyTicketsOpened := make(map[UserID]bool) | ||
notifyTicketsActivity := make(map[UserID]bool) | ||
|
||
for ticket, evt := range events { | ||
switch evt.EventType() { | ||
case TicketEventTypeOpened: | ||
// if ticket is unassigned notify all possible assignees | ||
if evt.AssigneeID() == NilUserID { | ||
for _, u := range oa.users { | ||
user := u.(*User) | ||
|
||
if hasAnyRole(user, ticketAssignableToles) && evt.CreatedByID() != user.ID() { | ||
notifyTicketsOpened[user.ID()] = true | ||
} | ||
} | ||
} else if evt.AssigneeID() != evt.CreatedByID() { | ||
notifyTicketsActivity[evt.AssigneeID()] = true | ||
} | ||
case TicketEventTypeAssigned: | ||
// notify new ticket assignee if they didn't self-assign | ||
if evt.AssigneeID() != NilUserID && evt.AssigneeID() != evt.CreatedByID() { | ||
notifyTicketsActivity[evt.AssigneeID()] = true | ||
} | ||
case TicketEventTypeNoteAdded: | ||
// notify ticket assignee if they didn't add note themselves | ||
if ticket.AssigneeID() != NilUserID && ticket.AssigneeID() != evt.CreatedByID() { | ||
notifyTicketsActivity[ticket.AssigneeID()] = true | ||
} | ||
} | ||
} | ||
|
||
notifications := make([]*Notification, 0, len(events)) | ||
|
||
for userID := range notifyTicketsOpened { | ||
notifications = append(notifications, &Notification{ | ||
OrgID: oa.OrgID(), | ||
Type: NotificationTypeTicketsOpened, | ||
Scope: "", | ||
UserID: userID, | ||
}) | ||
} | ||
|
||
for userID := range notifyTicketsActivity { | ||
notifications = append(notifications, &Notification{ | ||
OrgID: oa.OrgID(), | ||
Type: NotificationTypeTicketsActivity, | ||
Scope: "", | ||
UserID: userID, | ||
}) | ||
} | ||
|
||
return insertNotifications(ctx, db, notifications) | ||
} | ||
|
||
const insertNotificationSQL = ` | ||
INSERT INTO notifications_notification(org_id, notification_type, scope, user_id, is_seen, created_on, channel_id, contact_import_id) | ||
VALUES(:org_id, :notification_type, :scope, :user_id, FALSE, NOW(), :channel_id, :contact_import_id) | ||
ON CONFLICT DO NOTHING` | ||
|
||
func insertNotifications(ctx context.Context, db Queryer, notifications []*Notification) error { | ||
is := make([]interface{}, len(notifications)) | ||
for i := range notifications { | ||
is[i] = notifications[i] | ||
} | ||
|
||
err := dbutil.BulkQuery(ctx, db, insertNotificationSQL, is) | ||
return errors.Wrap(err, "error inserting notifications") | ||
} | ||
|
||
func hasAnyRole(user *User, roles []UserRole) bool { | ||
for _, r := range roles { | ||
if user.Role() == r { | ||
return true | ||
} | ||
} | ||
return false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
package models_test | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
"time" | ||
|
||
"github.com/jmoiron/sqlx" | ||
"github.com/nyaruka/mailroom/core/models" | ||
"github.com/nyaruka/mailroom/testsuite" | ||
"github.com/nyaruka/mailroom/testsuite/testdata" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestTicketNotifications(t *testing.T) { | ||
ctx, _, db, _ := testsuite.Get() | ||
|
||
defer deleteTickets(db) | ||
|
||
oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) | ||
require.NoError(t, err) | ||
|
||
t0 := time.Now() | ||
|
||
// open unassigned tickets by a flow (i.e. no user) | ||
ticket1, openedEvent1 := openTicket(t, ctx, db, nil, nil) | ||
ticket2, openedEvent2 := openTicket(t, ctx, db, nil, nil) | ||
err = models.NotificationsFromTicketEvents(ctx, db, oa, map[*models.Ticket]*models.TicketEvent{ticket1: openedEvent1, ticket2: openedEvent2}) | ||
require.NoError(t, err) | ||
|
||
// check that all assignable users are notified once | ||
assertNotifications(t, ctx, db, t0, map[*testdata.User][]models.NotificationType{ | ||
testdata.Admin: {models.NotificationTypeTicketsOpened}, | ||
testdata.Editor: {models.NotificationTypeTicketsOpened}, | ||
testdata.Agent: {models.NotificationTypeTicketsOpened}, | ||
}) | ||
|
||
t1 := time.Now() | ||
|
||
// another ticket opened won't create new notifications | ||
ticket3, openedEvent3 := openTicket(t, ctx, db, nil, nil) | ||
err = models.NotificationsFromTicketEvents(ctx, db, oa, map[*models.Ticket]*models.TicketEvent{ticket3: openedEvent3}) | ||
require.NoError(t, err) | ||
|
||
assertNotifications(t, ctx, db, t1, map[*testdata.User][]models.NotificationType{}) | ||
|
||
// mark all notifications as seen | ||
db.MustExec(`UPDATE notifications_notification SET is_seen = TRUE`) | ||
|
||
// open an unassigned ticket by a user | ||
ticket4, openedEvent4 := openTicket(t, ctx, db, testdata.Editor, nil) | ||
err = models.NotificationsFromTicketEvents(ctx, db, oa, map[*models.Ticket]*models.TicketEvent{ticket4: openedEvent4}) | ||
require.NoError(t, err) | ||
|
||
// check that all assignable users are notified except the user that opened the ticket | ||
assertNotifications(t, ctx, db, t1, map[*testdata.User][]models.NotificationType{ | ||
testdata.Admin: {models.NotificationTypeTicketsOpened}, | ||
testdata.Agent: {models.NotificationTypeTicketsOpened}, | ||
}) | ||
|
||
t2 := time.Now() | ||
db.MustExec(`UPDATE notifications_notification SET is_seen = TRUE`) | ||
|
||
// open an already assigned ticket | ||
ticket5, openedEvent5 := openTicket(t, ctx, db, nil, testdata.Agent) | ||
err = models.NotificationsFromTicketEvents(ctx, db, oa, map[*models.Ticket]*models.TicketEvent{ticket5: openedEvent5}) | ||
require.NoError(t, err) | ||
|
||
// check that the assigned user gets a ticket activity notification | ||
assertNotifications(t, ctx, db, t2, map[*testdata.User][]models.NotificationType{ | ||
testdata.Agent: {models.NotificationTypeTicketsActivity}, | ||
}) | ||
|
||
t3 := time.Now() | ||
|
||
// however if a user opens a ticket which is assigned to themselves, no notification | ||
ticket6, openedEvent6 := openTicket(t, ctx, db, testdata.Admin, testdata.Admin) | ||
err = models.NotificationsFromTicketEvents(ctx, db, oa, map[*models.Ticket]*models.TicketEvent{ticket6: openedEvent6}) | ||
require.NoError(t, err) | ||
|
||
// check that the assigned user gets a ticket activity notification | ||
assertNotifications(t, ctx, db, t3, map[*testdata.User][]models.NotificationType{}) | ||
|
||
t4 := time.Now() | ||
db.MustExec(`UPDATE notifications_notification SET is_seen = TRUE`) | ||
|
||
// now have a user assign existing tickets to another user | ||
_, err = models.TicketsAssign(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{ticket1, ticket2}, testdata.Agent.ID, "") | ||
require.NoError(t, err) | ||
|
||
// check that the assigned user gets a ticket activity notification | ||
assertNotifications(t, ctx, db, t4, map[*testdata.User][]models.NotificationType{ | ||
testdata.Agent: {models.NotificationTypeTicketsActivity}, | ||
}) | ||
|
||
t5 := time.Now() | ||
db.MustExec(`UPDATE notifications_notification SET is_seen = TRUE`) | ||
|
||
// and finally a user assigning a ticket to themselves | ||
_, err = models.TicketsAssign(ctx, db, oa, testdata.Editor.ID, []*models.Ticket{ticket3}, testdata.Editor.ID, "") | ||
require.NoError(t, err) | ||
|
||
// no notifications for self-assignment | ||
assertNotifications(t, ctx, db, t5, map[*testdata.User][]models.NotificationType{}) | ||
} | ||
|
||
func assertNotifications(t *testing.T, ctx context.Context, db *sqlx.DB, after time.Time, expected map[*testdata.User][]models.NotificationType) { | ||
// check last log | ||
var notifications []*models.Notification | ||
err := db.SelectContext(ctx, ¬ifications, `SELECT id, org_id, notification_type, scope, user_id, is_seen, created_on FROM notifications_notification WHERE created_on > $1 ORDER BY id`, after) | ||
require.NoError(t, err) | ||
|
||
expectedByID := map[models.UserID][]models.NotificationType{} | ||
for user, notificationTypes := range expected { | ||
expectedByID[user.ID] = notificationTypes | ||
} | ||
|
||
actual := map[models.UserID][]models.NotificationType{} | ||
for _, notification := range notifications { | ||
actual[notification.UserID] = append(actual[notification.UserID], notification.Type) | ||
} | ||
|
||
assert.Equal(t, expectedByID, actual) | ||
} | ||
|
||
func openTicket(t *testing.T, ctx context.Context, db *sqlx.DB, openedBy *testdata.User, assignee *testdata.User) (*models.Ticket, *models.TicketEvent) { | ||
ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.SupportTopic, "", "Where my pants", "", assignee) | ||
modelTicket := ticket.Load(db) | ||
|
||
openedEvent := models.NewTicketOpenedEvent(modelTicket, openedBy.SafeID(), assignee.SafeID()) | ||
err := models.InsertTicketEvents(ctx, db, []*models.TicketEvent{openedEvent}) | ||
require.NoError(t, err) | ||
|
||
return modelTicket, openedEvent | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
Oops, something went wrong.