diff --git a/backend/_example/memory_store/accessor/data.go b/backend/_example/memory_store/accessor/data.go index 6fcea040b7..6a537f48f5 100644 --- a/backend/_example/memory_store/accessor/data.go +++ b/backend/_example/memory_store/accessor/data.go @@ -285,7 +285,7 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro // and all site's details listing under the same function (and not to extend engine interface by two separate functions). func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) { switch req.Detail { - case engine.UserEmail: + case engine.UserEmail, engine.UserTelegram: if req.UserID == "" { return nil, errors.New("userid cannot be empty in request for single detail") } @@ -443,6 +443,8 @@ func (m *MemData) getUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta switch req.Detail { case engine.UserEmail: return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}, nil + case engine.UserTelegram: + return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}, nil } } @@ -473,6 +475,10 @@ func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta entry.Details.Email = req.Update m.metaUsers[req.UserID] = entry return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}, nil + case engine.UserTelegram: + entry.Details.Telegram = req.Update + m.metaUsers[req.UserID] = entry + return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}, nil } return []engine.UserDetailEntry{}, nil @@ -509,6 +515,8 @@ func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDet switch userDetail { case engine.UserEmail: entry.Details.Email = "" + case engine.UserTelegram: + entry.Details.Telegram = "" case engine.AllUserDetails: entry.Details = engine.UserDetailEntry{UserID: userID} } diff --git a/backend/_example/memory_store/accessor/data_test.go b/backend/_example/memory_store/accessor/data_test.go index 2aec845a6e..abc22d7a39 100644 --- a/backend/_example/memory_store/accessor/data_test.go +++ b/backend/_example/memory_store/accessor/data_test.go @@ -658,12 +658,40 @@ func TestMemData_DeleteAll(t *testing.T) { assert.Equal(t, 0, len(comments), "nothing left") } +func TestMemData_UserDetailAll(t *testing.T) { + b := prepMem(t) + + val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails}) + require.NoError(t, err) + require.Nil(t, val) +} + +func TestMemData_UserDetailErrors(t *testing.T) { + b := prepMem(t) + + val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.UserEmail, Update: "value1"}) + require.EqualError(t, err, "userid cannot be empty in request for single detail") + require.Nil(t, val) + + val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails, Update: "value1"}) + require.EqualError(t, err, "unsupported request with userdetail all") + require.Nil(t, val) + + val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: "bad"}) + require.EqualError(t, err, "unsupported detail \"bad\"") + require.Nil(t, val) +} + func TestMemData_DeleteUserDetail(t *testing.T) { var ( - createUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"} - readUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail} - emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}} - emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}} + createEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"} + readEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail} + createTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram, Update: "value1"} + readTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram} + emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}} + emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}} + telegramSet = []engine.UserDetailEntry{{UserID: "user1", Telegram: "value1"}} + telegramUnset = []engine.UserDetailEntry{{UserID: "user1", Telegram: ""}} ) b := prepMem(t) @@ -674,15 +702,21 @@ func TestMemData_DeleteUserDetail(t *testing.T) { expected []engine.UserDetailEntry }{ {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail}, - detailReq: createUser, expected: emailSet}, + detailReq: createEmailUser, expected: emailSet}, {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserEmail}, - detailReq: readUser, expected: emailSet}, + detailReq: readEmailUser, expected: emailSet}, {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail}, - detailReq: readUser, expected: emailUnset}, + detailReq: readEmailUser, expected: emailUnset}, + {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram}, + detailReq: createTelegramUser, expected: telegramSet}, + {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserTelegram}, + detailReq: readTelegramUser, expected: telegramSet}, + {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram}, + detailReq: readTelegramUser, expected: telegramUnset}, {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails}, - detailReq: createUser, expected: emailSet}, + detailReq: createEmailUser, expected: emailSet}, {delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails}, - detailReq: readUser, expected: emailUnset}, + detailReq: readEmailUser, expected: emailUnset}, } for i, x := range testData { diff --git a/backend/_example/memory_store/accessor/image_test.go b/backend/_example/memory_store/accessor/image_test.go index 92679c63c1..d13651b712 100644 --- a/backend/_example/memory_store/accessor/image_test.go +++ b/backend/_example/memory_store/accessor/image_test.go @@ -58,6 +58,8 @@ func TestMemImage_LoadAfterSave(t *testing.T) { assert.NoError(t, err) assert.Equal(t, gopher, img) + svc.ResetCleanupTimer(id) + err = svc.Commit(id) assert.NoError(t, err) diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index 26ee73c626..bdcef9d97f 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -35,13 +35,18 @@ type Destination interface { type Store interface { Get(locator store.Locator, id string, user store.User) (store.Comment, error) GetUserEmail(siteID string, userID string) (string, error) + GetUserTelegram(siteID string, userID string) (string, error) } +// used for email and telegram retrieval from user details +type getUserDetail func(string, string) (string, error) + // Request notification for a Comment type Request struct { - Comment store.Comment - parent store.Comment - Emails []string + Comment store.Comment + parent store.Comment + Emails []string + Telegrams []string } // VerificationRequest notification for user @@ -84,7 +89,8 @@ func (s *Service) Submit(req Request) { if s.dataService != nil && req.Comment.ParentID != "" { if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil { req.parent = p - req.Emails = deduplicateStrings(s.getNotificationEmails(req, p)) + req.Emails = s.getNotificationTargets(req, p, s.dataService.GetUserEmail) + req.Telegrams = s.getNotificationTargets(req, p, s.dataService.GetUserTelegram) } } select { @@ -94,25 +100,32 @@ func (s *Service) Submit(req Request) { } } -// getNotificationEmails returns list of emails for notifications for provided comment. -// Emails is not added to the returned list in case original message is from the same user as the notification receiver. -func (s *Service) getNotificationEmails(req Request, notifyComment store.Comment) (result []string) { +// getNotificationTargets returns list of notification targets (like email or telegram username) for users +// interested in notifications for provided comment. +// Targets are not added to the returned list in case the original message +// is from the same user as the notification receiver. +// Results are deduplicated. +func (s *Service) getNotificationTargets( + req Request, + notifyComment store.Comment, + getUserDetail getUserDetail, +) (result []string) { // add current user email only if the user is not the one who wrote the original comment if notifyComment.User.ID != req.Comment.User.ID { - email, err := s.dataService.GetUserEmail(req.Comment.Locator.SiteID, notifyComment.User.ID) + detail, err := getUserDetail(req.Comment.Locator.SiteID, notifyComment.User.ID) if err != nil { - log.Printf("[WARN] can't read email for %s, %v", notifyComment.User.ID, err) + log.Printf("[WARN] can't read notification detail for %s, %v", notifyComment.User.ID, err) } - if email != "" { - result = append(result, email) + if detail != "" { + result = append(result, detail) } } if notifyComment.ParentID != "" { if p, err := s.dataService.Get(req.Comment.Locator, notifyComment.ParentID, store.User{}); err == nil { - result = append(result, s.getNotificationEmails(req, p)...) + result = append(result, s.getNotificationTargets(req, p, getUserDetail)...) } } - return result + return deduplicateStrings(result) } // SubmitVerification to internal channel if not busy, drop if can't send diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go index 7da1a7b9d4..7f696a08a7 100644 --- a/backend/app/notify/notify_test.go +++ b/backend/app/notify/notify_test.go @@ -139,13 +139,13 @@ func TestService_WithParent(t *testing.T) { func TestService_EmailRetrieval(t *testing.T) { dest := &MockDest{id: 1} - dataStore := &mockStore{data: map[string]store.Comment{}, emailData: map[string]string{}} + dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}} dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}} dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}} dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}} dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}} - dataStore.emailData["u1"] = "u1@example.com" + dataStore.userDetails["u1"] = "u1@example.com" s := NewService(dataStore, 1, dest) assert.NotNil(t, s) @@ -198,16 +198,16 @@ func TestService_EmailRetrieval(t *testing.T) { func TestService_Recursive(t *testing.T) { dest := &MockDest{id: 1} - dataStore := &mockStore{data: map[string]store.Comment{}, emailData: map[string]string{}} + dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}} dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}} dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}} dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}} dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}} dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}} - dataStore.emailData["u1"] = "u1@example.com" + dataStore.userDetails["u1"] = "u1@example.com" // second comment goes without email address for notification - dataStore.emailData["u3"] = "u3@example.com" + dataStore.userDetails["u3"] = "u3@example.com" s := NewService(dataStore, 1, dest) assert.NotNil(t, s) @@ -277,8 +277,16 @@ func TestService_Nop(t *testing.T) { } type mockStore struct { - data map[string]store.Comment - emailData map[string]string + data map[string]store.Comment + userDetails map[string]string +} + +func (m mockStore) getUserDetail(userID string) (string, error) { + detail, ok := m.userDetails[userID] + if !ok { + return "", errors.New("no such user") + } + return detail, nil } func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) { @@ -290,9 +298,9 @@ func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, } func (m mockStore) GetUserEmail(_, userID string) (string, error) { - email, ok := m.emailData[userID] - if !ok { - return "", errors.New("no such user") - } - return email, nil + return m.getUserDetail(userID) +} + +func (m mockStore) GetUserTelegram(_, userID string) (string, error) { + return m.getUserDetail(userID) } diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 0c24592f9b..41454edc18 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -111,9 +111,9 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { return } - if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.UserEmail); err != nil { + if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.AllUserDetails); err != nil { code := parseError(err, rest.ErrInternal) - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code) + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code) return } diff --git a/backend/app/store/engine/bolt.go b/backend/app/store/engine/bolt.go index 25725a0ee7..d0df652fe9 100644 --- a/backend/app/store/engine/bolt.go +++ b/backend/app/store/engine/bolt.go @@ -211,7 +211,7 @@ func (b *BoltDB) Flag(req FlagRequest) (val bool, err error) { // and all site's details listing under the same function (and not to extend interface by two separate functions). func (b *BoltDB) UserDetail(req UserDetailRequest) ([]UserDetailEntry, error) { switch req.Detail { - case UserEmail: + case UserEmail, UserTelegram: if req.UserID == "" { return nil, errors.New("userid cannot be empty in request for single detail") } @@ -668,6 +668,8 @@ func (b *BoltDB) getUserDetail(req UserDetailRequest) (result []UserDetailEntry, switch req.Detail { case UserEmail: result = []UserDetailEntry{{UserID: req.UserID, Email: entry.Email}} + case UserTelegram: + result = []UserDetailEntry{{UserID: req.UserID, Telegram: entry.Telegram}} } } return nil @@ -708,6 +710,8 @@ func (b *BoltDB) setUserDetail(req UserDetailRequest) (result []UserDetailEntry, switch req.Detail { case UserEmail: entry.Email = req.Update + case UserTelegram: + entry.Telegram = req.Update } err = bdb.Update(func(tx *bolt.Tx) error { @@ -765,6 +769,8 @@ func (b *BoltDB) deleteUserDetail(bdb *bolt.DB, userID string, userDetail UserDe switch userDetail { case UserEmail: entry.Email = "" + case UserTelegram: + entry.Telegram = "" case AllUserDetails: entry = UserDetailEntry{UserID: userID} } diff --git a/backend/app/store/engine/bolt_test.go b/backend/app/store/engine/bolt_test.go index 61461c7cfc..b6e7c6c415 100644 --- a/backend/app/store/engine/bolt_test.go +++ b/backend/app/store/engine/bolt_test.go @@ -641,6 +641,10 @@ func TestBoltDB_UserDetail(t *testing.T) { }{ {req: UserDetailRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "u1", Detail: UserEmail}, expected: []UserDetailEntry{{UserID: "u1", Email: "test@example.com"}}}, + {req: UserDetailRequest{Locator: store.Locator{SiteID: "radio-t"}, Detail: UserEmail}, + error: "userid cannot be empty in request for single detail"}, + {req: UserDetailRequest{Locator: store.Locator{SiteID: "radio-t"}, Detail: AllUserDetails, UserID: "u1"}, + error: "unsupported request with userdetail all"}, {req: UserDetailRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "u1", Detail: UserEmail}, error: `site "bad" not found`}, {req: UserDetailRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "u1xyz", Detail: UserEmail}}, diff --git a/backend/app/store/engine/engine.go b/backend/app/store/engine/engine.go index 0a76542819..5e8555ff40 100644 --- a/backend/app/store/engine/engine.go +++ b/backend/app/store/engine/engine.go @@ -91,6 +91,8 @@ const ( const ( // UserEmail is a user email UserEmail = UserDetail("email") + // UserTelegram is a user telegram + UserTelegram = UserDetail("telegram") // AllUserDetails used for listing and deletion requests AllUserDetails = UserDetail("all") ) @@ -109,8 +111,9 @@ type UserDetail string // UserDetailEntry contains single user details entry type UserDetailEntry struct { - UserID string `json:"user_id"` // duplicate user's id to use this structure not only embedded but separately - Email string `json:"email,omitempty"` // UserEmail + UserID string `json:"user_id"` // duplicate user's id to use this structure not only embedded but separately + Email string `json:"email,omitempty"` // UserEmail + Telegram string `json:"telegram,omitempty"` // UserTelegram } // UserDetailRequest is the input for both get/set for details, like email diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index 19c8e4b156..395d15f050 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -194,6 +194,39 @@ func (s *DataStore) SetUserEmail(siteID, userID, value string) (string, error) { return "", nil } +// GetUserTelegram gets user telegram +func (s *DataStore) GetUserTelegram(siteID, userID string) (string, error) { + res, err := s.Engine.UserDetail(engine.UserDetailRequest{ + Detail: engine.UserTelegram, + Locator: store.Locator{SiteID: siteID}, + UserID: userID, + }) + if err != nil { + return "", err + } + if len(res) == 1 { + return res[0].Telegram, nil + } + return "", nil +} + +// SetUserTelegram sets user telegram +func (s *DataStore) SetUserTelegram(siteID, userID, value string) (string, error) { + res, err := s.Engine.UserDetail(engine.UserDetailRequest{ + Detail: engine.UserTelegram, + Locator: store.Locator{SiteID: siteID}, + UserID: userID, + Update: value, + }) + if err != nil { + return "", err + } + if len(res) == 1 { + return res[0].Telegram, nil + } + return "", nil +} + // DeleteUserDetail deletes user detail func (s *DataStore) DeleteUserDetail(siteID, userID string, detail engine.UserDetail) error { return s.Engine.Delete(engine.DeleteRequest{ diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index fd45ce73ed..c3406b4691 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -908,30 +908,47 @@ func TestService_UserDetailsOperations(t *testing.T) { result, err := b.SetUserEmail("radio-t", "u1", "test@example.com") assert.NoError(t, err, "No error inserting entry expected") assert.Equal(t, "test@example.com", result) + result, err = b.SetUserTelegram("radio-t", "u1", "test@example.com") + assert.NoError(t, err, "No error inserting entry expected") + assert.Equal(t, "test@example.com", result) // read valid entry back result, err = b.GetUserEmail("radio-t", "u1") assert.NoError(t, err, "No error reading entry expected") assert.Equal(t, "test@example.com", result) + result, err = b.GetUserTelegram("radio-t", "u1") + assert.NoError(t, err, "No error reading entry expected") + assert.Equal(t, "test@example.com", result) // delete existing entry err = b.DeleteUserDetail("radio-t", "u1", engine.UserEmail) assert.NoError(t, err, "No error deleting entry expected") + err = b.DeleteUserDetail("radio-t", "u1", engine.UserTelegram) + assert.NoError(t, err, "No error deleting entry expected") // read deleted entry result, err = b.GetUserEmail("radio-t", "u1") assert.NoError(t, err, "No error reading entry expected") assert.Empty(t, result) + result, err = b.GetUserTelegram("radio-t", "u1") + assert.NoError(t, err, "No error reading entry expected") + assert.Empty(t, result) // insert entry with invalid site_id result, err = b.SetUserEmail("bad-site", "u3", "does_not_matter@example.com") assert.Error(t, err, "Site not found") assert.Empty(t, result) + result, err = b.SetUserTelegram("bad-site", "u3", "does_not_matter@example.com") + assert.Error(t, err, "Site not found") + assert.Empty(t, result) // read entry with invalid site_id result, err = b.GetUserEmail("bad-site", "u3") assert.Error(t, err, "Site not found") assert.Empty(t, result) + result, err = b.GetUserTelegram("bad-site", "u3") + assert.Error(t, err, "Site not found") + assert.Empty(t, result) } func TestService_IsAdmin(t *testing.T) { @@ -1370,7 +1387,7 @@ func TestService_submitImages(t *testing.T) { b.submitImages(c) mockStore.AssertNumberOfCalls(t, "ResetCleanupTimer", 2) - time.Sleep(b.EditDuration + 100 * time.Millisecond) + time.Sleep(b.EditDuration + 100*time.Millisecond) mockStore.AssertNumberOfCalls(t, "Commit", 2) } @@ -1415,7 +1432,7 @@ func TestService_ResubmitStagingImages(t *testing.T) { mockStore.On("Commit", "dev_user/bqf122eq9r8ad657n3ng").Once().Return(nil) mockStore.On("Commit", "dev_user/bqf321eq9r8ad657n3ng").Once().Return(nil) mockStore.On("Commit", "cached_images/12318fbd4c55e9d177b8b5ae197bc89c5afd8e07-a41fcb00643f28d700504256ec81cbf2e1aac53e").Once().Return(nil) - time.Sleep(b.EditDuration + time.Millisecond * 100) + time.Sleep(b.EditDuration + time.Millisecond*100) mockStore.AssertNumberOfCalls(t, "Info", 1) mockStore.AssertNumberOfCalls(t, "Commit", 3)