diff --git a/core/models/msgs.go b/core/models/msgs.go index 87fbb225d..e148c3d56 100644 --- a/core/models/msgs.go +++ b/core/models/msgs.go @@ -697,22 +697,24 @@ type BroadcastTranslation struct { // Broadcast represents a broadcast that needs to be sent type Broadcast struct { b struct { - BroadcastID BroadcastID `json:"broadcast_id,omitempty" db:"id"` + BroadcastID BroadcastID `json:"broadcast_id,omitempty" db:"id"` Translations map[envs.Language]*BroadcastTranslation `json:"translations"` - Text hstore.Hstore ` db:"text"` + Text hstore.Hstore ` db:"text"` TemplateState TemplateState `json:"template_state"` - BaseLanguage envs.Language `json:"base_language" db:"base_language"` + BaseLanguage envs.Language `json:"base_language" db:"base_language"` URNs []urns.URN `json:"urns,omitempty"` ContactIDs []ContactID `json:"contact_ids,omitempty"` GroupIDs []GroupID `json:"group_ids,omitempty"` - OrgID OrgID `json:"org_id" db:"org_id"` - ParentID BroadcastID `json:"parent_id,omitempty" db:"parent_id"` - TicketID TicketID `json:"ticket_id,omitempty" db:"ticket_id"` + OrgID OrgID `json:"org_id" db:"org_id"` + CreatedByID UserID `json:"created_by_id,omitempty" db:"created_by_id"` + ParentID BroadcastID `json:"parent_id,omitempty" db:"parent_id"` + TicketID TicketID `json:"ticket_id,omitempty" db:"ticket_id"` } } func (b *Broadcast) ID() BroadcastID { return b.b.BroadcastID } func (b *Broadcast) OrgID() OrgID { return b.b.OrgID } +func (b *Broadcast) CreatedByID() UserID { return b.b.CreatedByID } func (b *Broadcast) ContactIDs() []ContactID { return b.b.ContactIDs } func (b *Broadcast) GroupIDs() []GroupID { return b.b.GroupIDs } func (b *Broadcast) URNs() []urns.URN { return b.b.URNs } @@ -727,7 +729,7 @@ func (b *Broadcast) UnmarshalJSON(data []byte) error { return json.Unmarshal(dat // NewBroadcast creates a new broadcast with the passed in parameters func NewBroadcast( orgID OrgID, id BroadcastID, translations map[envs.Language]*BroadcastTranslation, - state TemplateState, baseLanguage envs.Language, urns []urns.URN, contactIDs []ContactID, groupIDs []GroupID, ticketID TicketID) *Broadcast { + state TemplateState, baseLanguage envs.Language, urns []urns.URN, contactIDs []ContactID, groupIDs []GroupID, ticketID TicketID, createdByID UserID) *Broadcast { bcast := &Broadcast{} bcast.b.OrgID = orgID @@ -739,6 +741,7 @@ func NewBroadcast( bcast.b.ContactIDs = contactIDs bcast.b.GroupIDs = groupIDs bcast.b.TicketID = ticketID + bcast.b.CreatedByID = createdByID return bcast } @@ -755,8 +758,8 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (* parent.b.ContactIDs, parent.b.GroupIDs, parent.b.TicketID, + parent.b.CreatedByID, ) - // populate our parent id child.b.ParentID = parent.ID() // populate text from our translations @@ -894,7 +897,7 @@ func NewBroadcastFromEvent(ctx context.Context, tx Queryer, oa *OrgAssets, event } } - return NewBroadcast(oa.OrgID(), NilBroadcastID, translations, TemplateStateEvaluated, event.BaseLanguage, event.URNs, contactIDs, groupIDs, NilTicketID), nil + return NewBroadcast(oa.OrgID(), NilBroadcastID, translations, TemplateStateEvaluated, event.BaseLanguage, event.URNs, contactIDs, groupIDs, NilTicketID, NilUserID), nil } func (b *Broadcast) CreateBatch(contactIDs []ContactID) *BroadcastBatch { @@ -904,6 +907,7 @@ func (b *Broadcast) CreateBatch(contactIDs []ContactID) *BroadcastBatch { batch.b.Translations = b.b.Translations batch.b.TemplateState = b.b.TemplateState batch.b.OrgID = b.b.OrgID + batch.b.CreatedByID = b.b.CreatedByID batch.b.TicketID = b.b.TicketID batch.b.ContactIDs = contactIDs return batch @@ -920,6 +924,7 @@ type BroadcastBatch struct { ContactIDs []ContactID `json:"contact_ids,omitempty"` IsLast bool `json:"is_last"` OrgID OrgID `json:"org_id"` + CreatedByID UserID `json:"created_by_id"` TicketID TicketID `json:"ticket_id"` } } @@ -929,6 +934,7 @@ func (b *BroadcastBatch) ContactIDs() []ContactID { return b.b.Conta func (b *BroadcastBatch) URNs() map[ContactID]urns.URN { return b.b.URNs } func (b *BroadcastBatch) SetURNs(urns map[ContactID]urns.URN) { b.b.URNs = urns } func (b *BroadcastBatch) OrgID() OrgID { return b.b.OrgID } +func (b *BroadcastBatch) CreatedByID() UserID { return b.b.CreatedByID } func (b *BroadcastBatch) TicketID() TicketID { return b.b.TicketID } func (b *BroadcastBatch) Translations() map[envs.Language]*BroadcastTranslation { return b.b.Translations @@ -1146,6 +1152,21 @@ func CreateBroadcastMessages(ctx context.Context, rt *runtime.Runtime, oa *OrgAs if err != nil { return nil, errors.Wrapf(err, "error updating broadcast ticket") } + + // record reply counts for org, user and team + replyCounts := map[string]int{scopeOrg(oa): 1} + + if bcast.CreatedByID() != NilUserID { + user := oa.UserByID(bcast.CreatedByID()) + if user != nil { + replyCounts[scopeUser(oa, user)] = 1 + if user.Team() != nil { + replyCounts[scopeTeam(user.Team())] = 1 + } + } + } + + insertTicketDailyCounts(ctx, rt.DB, TicketDailyCountReply, oa.Org().Timezone(), replyCounts) } return msgs, nil diff --git a/core/models/msgs_test.go b/core/models/msgs_test.go index 2ab317cd8..ace219b91 100644 --- a/core/models/msgs_test.go +++ b/core/models/msgs_test.go @@ -611,6 +611,7 @@ func TestNonPersistentBroadcasts(t *testing.T) { []models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID, testdata.Cathy.ID}, []models.GroupID{testdata.DoctorsGroup.ID}, ticket.ID, + models.NilUserID, ) assert.Equal(t, models.NilBroadcastID, bcast.ID()) diff --git a/core/models/teams.go b/core/models/teams.go new file mode 100644 index 000000000..a6aeec210 --- /dev/null +++ b/core/models/teams.go @@ -0,0 +1,43 @@ +package models + +import ( + "database/sql/driver" + + "github.com/nyaruka/null" +) + +const ( + // NilTeamID is the id 0 considered as nil user id + NilTeamID = TeamID(0) +) + +// TeamID is our type for team ids, which can be null +type TeamID null.Int + +type TeamUUID string + +type Team struct { + ID TeamID `json:"id"` + UUID TeamUUID `json:"uuid"` + Name string `json:"name"` +} + +// MarshalJSON marshals into JSON. 0 values will become null +func (i TeamID) MarshalJSON() ([]byte, error) { + return null.Int(i).MarshalJSON() +} + +// UnmarshalJSON unmarshals from JSON. null values become 0 +func (i *TeamID) UnmarshalJSON(b []byte) error { + return null.UnmarshalInt(b, (*null.Int)(i)) +} + +// Value returns the db value, null is returned for 0 +func (i TeamID) Value() (driver.Value, error) { + return null.Int(i).Value() +} + +// Scan scans from the db value. null values become 0 +func (i *TeamID) Scan(value interface{}) error { + return null.ScanInt(value, (*null.Int)(i)) +} diff --git a/core/models/tickets.go b/core/models/tickets.go index 0467057c3..026c41ab1 100644 --- a/core/models/tickets.go +++ b/core/models/tickets.go @@ -353,7 +353,10 @@ func InsertTickets(ctx context.Context, tx Queryer, oa *OrgAssets, tickets []*Ti ts[i] = &t.t if t.AssigneeID() != NilUserID { - assignmentCounts[scopeUser(oa, t.AssigneeID())]++ + assignee := oa.UserByID(t.AssigneeID()) + if assignee != nil { + assignmentCounts[scopeUser(oa, assignee)]++ + } } } @@ -449,7 +452,10 @@ func TicketsAssign(ctx context.Context, db Queryer, oa *OrgAssets, userID UserID // if this is an initial assignment record count for user if ticket.AssigneeID() == NilUserID && assigneeID != NilUserID { - assignmentCounts[scopeUser(oa, assigneeID)]++ + assignee := oa.UserByID(assigneeID) + if assignee != nil { + assignmentCounts[scopeUser(oa, assignee)]++ + } } ids = append(ids, ticket.ID()) @@ -899,6 +905,10 @@ func scopeOrg(oa *OrgAssets) string { return fmt.Sprintf("o:%d", oa.OrgID()) } -func scopeUser(oa *OrgAssets, uid UserID) string { - return fmt.Sprintf("o:%d:u:%d", oa.OrgID(), uid) +func scopeTeam(t *Team) string { + return fmt.Sprintf("t:%d", t.ID) +} + +func scopeUser(oa *OrgAssets, u *User) string { + return fmt.Sprintf("o:%d:u:%d", oa.OrgID(), u.ID()) } diff --git a/core/models/users.go b/core/models/users.go index 551fc0442..121e50e2e 100644 --- a/core/models/users.go +++ b/core/models/users.go @@ -61,6 +61,7 @@ type User struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Role UserRole `json:"role"` + Team *Team `json:"team"` } } @@ -85,6 +86,11 @@ func (u *User) Name() string { return strings.Join(names, " ") } +// Team returns the user's ticketing team if any +func (u *User) Team() *Team { + return u.u.Team +} + var _ assets.User = (*User)(nil) const selectOrgUsersSQL = ` @@ -93,7 +99,8 @@ SELECT ROW_TO_JSON(r) FROM (SELECT u.email AS "email", u.first_name as "first_name", u.last_name as "last_name", - o.role AS "role" + o.role AS "role", + row_to_json(team_struct) AS team FROM auth_user u INNER JOIN ( @@ -107,6 +114,8 @@ INNER JOIN ( UNION SELECT user_id, 'S' AS "role" FROM orgs_org_surveyors WHERE org_id = $1 ) o ON o.user_id = u.id +LEFT JOIN orgs_usersettings s ON s.user_id = u.id +LEFT JOIN LATERAL (SELECT id, uuid, name FROM tickets_team WHERE tickets_team.id = s.team_id) AS team_struct ON True WHERE u.is_active = TRUE ORDER BY diff --git a/core/models/users_test.go b/core/models/users_test.go index 28103ba05..dee438067 100644 --- a/core/models/users_test.go +++ b/core/models/users_test.go @@ -19,17 +19,21 @@ func TestLoadUsers(t *testing.T) { users, err := oa.Users() require.NoError(t, err) + partners := &models.Team{testdata.Partners.ID, testdata.Partners.UUID, "Partners"} + office := &models.Team{testdata.Office.ID, testdata.Office.UUID, "Office"} + expectedUsers := []struct { id models.UserID email string name string role models.UserRole + team *models.Team }{ - {testdata.Admin.ID, testdata.Admin.Email, "Andy Admin", models.UserRoleAdministrator}, - {testdata.Agent.ID, testdata.Agent.Email, "Ann D'Agent", models.UserRoleAgent}, - {testdata.Editor.ID, testdata.Editor.Email, "Ed McEditor", models.UserRoleEditor}, - {testdata.Surveyor.ID, testdata.Surveyor.Email, "Steve Surveys", models.UserRoleSurveyor}, - {testdata.Viewer.ID, testdata.Viewer.Email, "Veronica Views", models.UserRoleViewer}, + {id: testdata.Admin.ID, email: testdata.Admin.Email, name: "Andy Admin", role: models.UserRoleAdministrator, team: office}, + {id: testdata.Agent.ID, email: testdata.Agent.Email, name: "Ann D'Agent", role: models.UserRoleAgent, team: partners}, + {id: testdata.Editor.ID, email: testdata.Editor.Email, name: "Ed McEditor", role: models.UserRoleEditor, team: office}, + {id: testdata.Surveyor.ID, email: testdata.Surveyor.Email, name: "Steve Surveys", role: models.UserRoleSurveyor, team: nil}, + {id: testdata.Viewer.ID, email: testdata.Viewer.Email, name: "Veronica Views", role: models.UserRoleViewer, team: nil}, } require.Equal(t, len(expectedUsers), len(users)) @@ -43,6 +47,7 @@ func TestLoadUsers(t *testing.T) { assert.Equal(t, expected.id, modelUser.ID()) assert.Equal(t, expected.email, modelUser.Email()) assert.Equal(t, expected.role, modelUser.Role()) + assert.Equal(t, expected.team, modelUser.Team()) assert.Equal(t, modelUser, oa.UserByID(expected.id)) assert.Equal(t, modelUser, oa.UserByEmail(expected.email)) diff --git a/core/tasks/msgs/send_broadcast_test.go b/core/tasks/msgs/send_broadcast_test.go index 448e423f4..cddefc88c 100644 --- a/core/tasks/msgs/send_broadcast_test.go +++ b/core/tasks/msgs/send_broadcast_test.go @@ -177,6 +177,7 @@ func TestBroadcastTask(t *testing.T) { ContactIDs []models.ContactID URNs []urns.URN TicketID models.TicketID + CreatedByID models.UserID Queue string BatchCount int MsgCount int @@ -190,7 +191,8 @@ func TestBroadcastTask(t *testing.T) { doctorsOnly, cathyOnly, nil, - ticket.ID, + models.NilTicketID, + testdata.Admin.ID, queue.BatchQueue, 2, 121, @@ -205,6 +207,7 @@ func TestBroadcastTask(t *testing.T) { cathyOnly, nil, models.NilTicketID, + models.NilUserID, queue.HandlerQueue, 1, 1, @@ -218,7 +221,8 @@ func TestBroadcastTask(t *testing.T) { nil, cathyOnly, nil, - models.NilTicketID, + ticket.ID, + testdata.Agent.ID, queue.HandlerQueue, 1, 1, @@ -231,7 +235,7 @@ func TestBroadcastTask(t *testing.T) { for i, tc := range tcs { // handle our start task - bcast := models.NewBroadcast(oa.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs, tc.TicketID) + bcast := models.NewBroadcast(oa.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs, tc.TicketID, tc.CreatedByID) err = msgs.CreateBroadcastBatches(ctx, rt, bcast) assert.NoError(t, err) @@ -277,4 +281,7 @@ func TestBroadcastTask(t *testing.T) { lastNow = time.Now() time.Sleep(10 * time.Millisecond) } + + assertdb.Query(t, db, `SELECT SUM(count) FROM tickets_ticketdailycount WHERE count_type = 'R' AND scope = CONCAT('o:', $1::text)`, testdata.Org1.ID).Returns(1) + assertdb.Query(t, db, `SELECT SUM(count) FROM tickets_ticketdailycount WHERE count_type = 'R' AND scope = CONCAT('o:', $1::text, ':u:', $2::text)`, testdata.Org1.ID, testdata.Agent.ID).Returns(1) } diff --git a/mailroom_test.dump b/mailroom_test.dump index 0bb289543..7c1155cd6 100644 Binary files a/mailroom_test.dump and b/mailroom_test.dump differ diff --git a/services/tickets/utils.go b/services/tickets/utils.go index 86ea8214d..0cc62f171 100644 --- a/services/tickets/utils.go +++ b/services/tickets/utils.go @@ -101,7 +101,7 @@ func SendReply(ctx context.Context, rt *runtime.Runtime, ticket *models.Ticket, translations := map[envs.Language]*models.BroadcastTranslation{envs.Language("base"): base} // we'll use a broadcast to send this message - bcast := models.NewBroadcast(oa.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil, ticket.ID()) + bcast := models.NewBroadcast(oa.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil, ticket.ID(), models.NilUserID) batch := bcast.CreateBatch([]models.ContactID{ticket.ContactID()}) msgs, err := models.CreateBroadcastMessages(ctx, rt, oa, batch) if err != nil { diff --git a/testsuite/testdata/constants.go b/testsuite/testdata/constants.go index 29718067a..ba8edc977 100644 --- a/testsuite/testdata/constants.go +++ b/testsuite/testdata/constants.go @@ -54,6 +54,9 @@ var Mailgun = &Ticketer{2, "f9c9447f-a291-4f3c-8c79-c089bbd4e713"} var Zendesk = &Ticketer{3, "4ee6d4f3-f92b-439b-9718-8da90c05490b"} var RocketChat = &Ticketer{4, "6c50665f-b4ff-4e37-9625-bc464fe6a999"} +var Partners = &Team{1, "4321c30b-b596-46fa-adb4-4a46d37923f6"} +var Office = &Team{2, "f14c1762-d38b-4072-ae63-2705332a3719"} + var Luis = &Classifier{1, "097e026c-ae79-4740-af67-656dbedf0263"} var Wit = &Classifier{2, "ff2a817c-040a-4eb2-8404-7d92e8b79dd0"} var Bothub = &Classifier{3, "859b436d-3005-4e43-9ad5-3de5f26ede4c"} diff --git a/testsuite/testdata/tickets.go b/testsuite/testdata/tickets.go index bf0bf0225..806409c64 100644 --- a/testsuite/testdata/tickets.go +++ b/testsuite/testdata/tickets.go @@ -23,6 +23,11 @@ type Ticket struct { UUID flows.TicketUUID } +type Team struct { + ID models.TeamID + UUID models.TeamUUID +} + func (k *Ticket) Load(db *sqlx.DB) *models.Ticket { tickets, err := models.LoadTickets(context.Background(), db, []models.TicketID{k.ID}) must(err, len(tickets) == 1)