diff --git a/internal/api/middleware/user/user.go b/internal/api/middleware/user/user.go index d0c37c40..e008489b 100644 --- a/internal/api/middleware/user/user.go +++ b/internal/api/middleware/user/user.go @@ -17,7 +17,7 @@ func PrefetchUser(s storage.Storage) gin.HandlerFunc { return } - user, err := s.FindUserByID(userID) + user, err := s.FindUserByID(userID, storage.WithTickers()) if err != nil { c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeNotFound, response.UserNotFound)) return diff --git a/internal/api/middleware/user/user_test.go b/internal/api/middleware/user/user_test.go index b7612455..b845c9b9 100644 --- a/internal/api/middleware/user/user_test.go +++ b/internal/api/middleware/user/user_test.go @@ -32,7 +32,7 @@ func TestPrefetchUserStorageError(t *testing.T) { c, _ := gin.CreateTestContext(w) c.AddParam("userID", "1") s := &storage.MockStorage{} - s.On("FindUserByID", mock.Anything).Return(storage.User{}, errors.New("storage error")) + s.On("FindUserByID", mock.Anything, mock.Anything).Return(storage.User{}, errors.New("storage error")) mw := PrefetchUser(s) mw(c) @@ -46,7 +46,7 @@ func TestPrefetchUser(t *testing.T) { c.AddParam("userID", "1") s := &storage.MockStorage{} user := storage.User{ID: 1} - s.On("FindUserByID", mock.Anything).Return(user, nil) + s.On("FindUserByID", mock.Anything, mock.Anything).Return(user, nil) mw := PrefetchUser(s) mw(c) diff --git a/internal/api/response/user.go b/internal/api/response/user.go index 585fe28d..7c28ed1f 100644 --- a/internal/api/response/user.go +++ b/internal/api/response/user.go @@ -7,11 +7,18 @@ import ( ) type User struct { - ID int `json:"id"` - CreatedAt time.Time `json:"createdAt"` - Email string `json:"email"` - Role string `json:"role"` - IsSuperAdmin bool `json:"isSuperAdmin"` + ID int `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Email string `json:"email"` + Role string `json:"role"` + Tickers []UserTicker `json:"tickers"` + IsSuperAdmin bool `json:"isSuperAdmin"` +} + +type UserTicker struct { + ID int `json:"id"` + Domain string `json:"domain"` + Title string `json:"title"` } func UserResponse(user storage.User) User { @@ -20,6 +27,7 @@ func UserResponse(user storage.User) User { CreatedAt: user.CreatedAt, Email: user.Email, IsSuperAdmin: user.IsSuperAdmin, + Tickers: UserTickersResponse(user.Tickers), } } @@ -31,3 +39,20 @@ func UsersResponse(users []storage.User) []User { return u } + +func UserTickersResponse(tickers []storage.Ticker) []UserTicker { + t := make([]UserTicker, 0) + for _, ticker := range tickers { + t = append(t, UserTickerResponse(ticker)) + } + + return t +} + +func UserTickerResponse(ticker storage.Ticker) UserTicker { + return UserTicker{ + ID: ticker.ID, + Domain: ticker.Domain, + Title: ticker.Title, + } +} diff --git a/internal/api/response/user_test.go b/internal/api/response/user_test.go index 03273ffb..4db9faff 100644 --- a/internal/api/response/user_test.go +++ b/internal/api/response/user_test.go @@ -15,6 +15,13 @@ func TestUsersResponse(t *testing.T) { CreatedAt: time.Now(), Email: "user@systemli.org", IsSuperAdmin: true, + Tickers: []storage.Ticker{ + { + ID: 1, + Domain: "example.com", + Title: "Example", + }, + }, }, } @@ -24,4 +31,8 @@ func TestUsersResponse(t *testing.T) { assert.Equal(t, users[0].CreatedAt, usersResponse[0].CreatedAt) assert.Equal(t, users[0].Email, usersResponse[0].Email) assert.Equal(t, users[0].IsSuperAdmin, usersResponse[0].IsSuperAdmin) + assert.Equal(t, 1, len(usersResponse[0].Tickers)) + assert.Equal(t, users[0].Tickers[0].ID, usersResponse[0].Tickers[0].ID) + assert.Equal(t, users[0].Tickers[0].Domain, usersResponse[0].Tickers[0].Domain) + assert.Equal(t, users[0].Tickers[0].Title, usersResponse[0].Tickers[0].Title) } diff --git a/internal/api/user.go b/internal/api/user.go index 3741d895..dcba70b8 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -11,7 +11,7 @@ import ( func (h *handler) GetUsers(c *gin.Context) { //TODO: Discuss need of Pagination - users, err := h.storage.FindUsers() + users, err := h.storage.FindUsers(storage.WithTickers()) if err != nil { c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.UserNotFound)) return @@ -86,10 +86,10 @@ func (h *handler) PutUser(c *gin.Context) { } var body struct { - Email string `json:"email,omitempty" validate:"email"` - Password string `json:"password,omitempty" validate:"min=10"` - IsSuperAdmin bool `json:"isSuperAdmin,omitempty"` - Tickers []int `json:"tickers,omitempty"` + Email string `json:"email,omitempty" validate:"email"` + Password string `json:"password,omitempty" validate:"min=10"` + IsSuperAdmin bool `json:"isSuperAdmin,omitempty"` + Tickers []storage.Ticker `json:"tickers,omitempty"` } err = c.Bind(&body) @@ -104,22 +104,15 @@ func (h *handler) PutUser(c *gin.Context) { if body.Password != "" { user.UpdatePassword(body.Password) } + if len(body.Tickers) > 0 { + user.Tickers = body.Tickers + } // You only can set/unset other users SuperAdmin property if me.ID != user.ID { user.IsSuperAdmin = body.IsSuperAdmin } - if body.Tickers != nil { - tickers, err := h.storage.FindTickersByIDs(body.Tickers) - if err != nil { - c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) - return - } - - user.Tickers = tickers - } - err = h.storage.SaveUser(&user) if err != nil { c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) diff --git a/internal/api/user_test.go b/internal/api/user_test.go index 754d6e15..f830997a 100644 --- a/internal/api/user_test.go +++ b/internal/api/user_test.go @@ -23,7 +23,7 @@ func TestGetUsersStorageError(t *testing.T) { c, _ := gin.CreateTestContext(w) c.Set("me", storage.User{IsSuperAdmin: true}) s := &storage.MockStorage{} - s.On("FindUsers").Return([]storage.User{}, errors.New("storage error")) + s.On("FindUsers", mock.Anything).Return([]storage.User{}, errors.New("storage error")) h := handler{ storage: s, config: config.NewConfig(), @@ -39,7 +39,7 @@ func TestGetUsers(t *testing.T) { c, _ := gin.CreateTestContext(w) c.Set("me", storage.User{IsSuperAdmin: true}) s := &storage.MockStorage{} - s.On("FindUsers").Return([]storage.User{}, nil) + s.On("FindUsers", mock.Anything).Return([]storage.User{}, nil) h := handler{ storage: s, @@ -89,7 +89,7 @@ func TestGetUserStorageError(t *testing.T) { c.Set("me", storage.User{ID: 1, IsSuperAdmin: true}) s := &storage.MockStorage{} - s.On("FindUserByID", mock.Anything).Return(storage.User{}, errors.New("storage error")) + s.On("FindUserByID", mock.Anything, mock.Anything).Return(storage.User{}, errors.New("storage error")) h := handler{ storage: s, config: config.NewConfig(), @@ -267,7 +267,7 @@ func TestPutUserStorageError(t *testing.T) { c, _ := gin.CreateTestContext(w) c.Set("me", storage.User{ID: 1, IsSuperAdmin: true}) c.Set("user", storage.User{}) - json := `{"email":"louis@systemli.org","password":"password1234","isSuperAdmin":true,"tickers":[1]}` + json := `{"email":"louis@systemli.org","password":"password1234","isSuperAdmin":true,"tickers":[{"id":1}]}` c.Request = httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(json)) c.Request.Header.Add("Content-Type", "application/json") s := &storage.MockStorage{} @@ -288,7 +288,7 @@ func TestPutUserStorageError2(t *testing.T) { c, _ := gin.CreateTestContext(w) c.Set("me", storage.User{ID: 1, IsSuperAdmin: true}) c.Set("user", storage.User{}) - json := `{"email":"louis@systemli.org","password":"password1234","isSuperAdmin":true,"tickers":[1]}` + json := `{"email":"louis@systemli.org","password":"password1234","isSuperAdmin":true,"tickers":[{"id":1}]}` c.Request = httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(json)) c.Request.Header.Add("Content-Type", "application/json") s := &storage.MockStorage{} @@ -309,7 +309,7 @@ func TestPutUser(t *testing.T) { c, _ := gin.CreateTestContext(w) c.Set("me", storage.User{ID: 1, IsSuperAdmin: true}) c.Set("user", storage.User{}) - json := `{"email":"louis@systemli.org","password":"password1234","isSuperAdmin":true,"tickers":[1]}` + json := `{"email":"louis@systemli.org","password":"password1234","isSuperAdmin":true,"tickers":[{"id":1}]}` c.Request = httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(json)) c.Request.Header.Add("Content-Type", "application/json") s := &storage.MockStorage{} diff --git a/internal/storage/mock_Storage.go b/internal/storage/mock_Storage.go index f137410c..c3913946 100644 --- a/internal/storage/mock_Storage.go +++ b/internal/storage/mock_Storage.go @@ -432,23 +432,30 @@ func (_m *MockStorage) FindUploadsByIDs(ids []int) ([]Upload, error) { return r0, r1 } -// FindUserByEmail provides a mock function with given fields: email -func (_m *MockStorage) FindUserByEmail(email string) (User, error) { - ret := _m.Called(email) +// FindUserByEmail provides a mock function with given fields: email, opts +func (_m *MockStorage) FindUserByEmail(email string, opts ...func(*gorm.DB) *gorm.DB) (User, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, email) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) var r0 User var r1 error - if rf, ok := ret.Get(0).(func(string) (User, error)); ok { - return rf(email) + if rf, ok := ret.Get(0).(func(string, ...func(*gorm.DB) *gorm.DB) (User, error)); ok { + return rf(email, opts...) } - if rf, ok := ret.Get(0).(func(string) User); ok { - r0 = rf(email) + if rf, ok := ret.Get(0).(func(string, ...func(*gorm.DB) *gorm.DB) User); ok { + r0 = rf(email, opts...) } else { r0 = ret.Get(0).(User) } - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(email) + if rf, ok := ret.Get(1).(func(string, ...func(*gorm.DB) *gorm.DB) error); ok { + r1 = rf(email, opts...) } else { r1 = ret.Error(1) } @@ -456,23 +463,30 @@ func (_m *MockStorage) FindUserByEmail(email string) (User, error) { return r0, r1 } -// FindUserByID provides a mock function with given fields: id -func (_m *MockStorage) FindUserByID(id int) (User, error) { - ret := _m.Called(id) +// FindUserByID provides a mock function with given fields: id, opts +func (_m *MockStorage) FindUserByID(id int, opts ...func(*gorm.DB) *gorm.DB) (User, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, id) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) var r0 User var r1 error - if rf, ok := ret.Get(0).(func(int) (User, error)); ok { - return rf(id) + if rf, ok := ret.Get(0).(func(int, ...func(*gorm.DB) *gorm.DB) (User, error)); ok { + return rf(id, opts...) } - if rf, ok := ret.Get(0).(func(int) User); ok { - r0 = rf(id) + if rf, ok := ret.Get(0).(func(int, ...func(*gorm.DB) *gorm.DB) User); ok { + r0 = rf(id, opts...) } else { r0 = ret.Get(0).(User) } - if rf, ok := ret.Get(1).(func(int) error); ok { - r1 = rf(id) + if rf, ok := ret.Get(1).(func(int, ...func(*gorm.DB) *gorm.DB) error); ok { + r1 = rf(id, opts...) } else { r1 = ret.Error(1) } @@ -480,25 +494,31 @@ func (_m *MockStorage) FindUserByID(id int) (User, error) { return r0, r1 } -// FindUsers provides a mock function with given fields: -func (_m *MockStorage) FindUsers() ([]User, error) { - ret := _m.Called() +// FindUsers provides a mock function with given fields: opts +func (_m *MockStorage) FindUsers(opts ...func(*gorm.DB) *gorm.DB) ([]User, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) var r0 []User var r1 error - if rf, ok := ret.Get(0).(func() ([]User, error)); ok { - return rf() + if rf, ok := ret.Get(0).(func(...func(*gorm.DB) *gorm.DB) ([]User, error)); ok { + return rf(opts...) } - if rf, ok := ret.Get(0).(func() []User); ok { - r0 = rf() + if rf, ok := ret.Get(0).(func(...func(*gorm.DB) *gorm.DB) []User); ok { + r0 = rf(opts...) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]User) } } - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() + if rf, ok := ret.Get(1).(func(...func(*gorm.DB) *gorm.DB) error); ok { + r1 = rf(opts...) } else { r1 = ret.Error(1) } @@ -506,25 +526,32 @@ func (_m *MockStorage) FindUsers() ([]User, error) { return r0, r1 } -// FindUsersByIDs provides a mock function with given fields: ids -func (_m *MockStorage) FindUsersByIDs(ids []int) ([]User, error) { - ret := _m.Called(ids) +// FindUsersByIDs provides a mock function with given fields: ids, opts +func (_m *MockStorage) FindUsersByIDs(ids []int, opts ...func(*gorm.DB) *gorm.DB) ([]User, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ids) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) var r0 []User var r1 error - if rf, ok := ret.Get(0).(func([]int) ([]User, error)); ok { - return rf(ids) + if rf, ok := ret.Get(0).(func([]int, ...func(*gorm.DB) *gorm.DB) ([]User, error)); ok { + return rf(ids, opts...) } - if rf, ok := ret.Get(0).(func([]int) []User); ok { - r0 = rf(ids) + if rf, ok := ret.Get(0).(func([]int, ...func(*gorm.DB) *gorm.DB) []User); ok { + r0 = rf(ids, opts...) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]User) } } - if rf, ok := ret.Get(1).(func([]int) error); ok { - r1 = rf(ids) + if rf, ok := ret.Get(1).(func([]int, ...func(*gorm.DB) *gorm.DB) error); ok { + r1 = rf(ids, opts...) } else { r1 = ret.Error(1) } @@ -532,25 +559,32 @@ func (_m *MockStorage) FindUsersByIDs(ids []int) ([]User, error) { return r0, r1 } -// FindUsersByTicker provides a mock function with given fields: ticker -func (_m *MockStorage) FindUsersByTicker(ticker Ticker) ([]User, error) { - ret := _m.Called(ticker) +// FindUsersByTicker provides a mock function with given fields: ticker, opts +func (_m *MockStorage) FindUsersByTicker(ticker Ticker, opts ...func(*gorm.DB) *gorm.DB) ([]User, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ticker) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) var r0 []User var r1 error - if rf, ok := ret.Get(0).(func(Ticker) ([]User, error)); ok { - return rf(ticker) + if rf, ok := ret.Get(0).(func(Ticker, ...func(*gorm.DB) *gorm.DB) ([]User, error)); ok { + return rf(ticker, opts...) } - if rf, ok := ret.Get(0).(func(Ticker) []User); ok { - r0 = rf(ticker) + if rf, ok := ret.Get(0).(func(Ticker, ...func(*gorm.DB) *gorm.DB) []User); ok { + r0 = rf(ticker, opts...) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]User) } } - if rf, ok := ret.Get(1).(func(Ticker) error); ok { - r1 = rf(ticker) + if rf, ok := ret.Get(1).(func(Ticker, ...func(*gorm.DB) *gorm.DB) error); ok { + r1 = rf(ticker, opts...) } else { r1 = ret.Error(1) } diff --git a/internal/storage/sql_storage.go b/internal/storage/sql_storage.go index 8edfafb5..9fdf51a7 100644 --- a/internal/storage/sql_storage.go +++ b/internal/storage/sql_storage.go @@ -21,45 +21,58 @@ func NewSqlStorage(db *gorm.DB, uploadPath string) *SqlStorage { } } -func (s *SqlStorage) FindUsers() ([]User, error) { +func (s *SqlStorage) FindUsers(opts ...func(*gorm.DB) *gorm.DB) ([]User, error) { users := make([]User, 0) - err := s.DB.Find(&users).Error + db := s.prepareDb(opts...) + err := db.Find(&users).Error return users, err } -func (s *SqlStorage) FindUserByID(id int) (User, error) { +func (s *SqlStorage) FindUserByID(id int, opts ...func(*gorm.DB) *gorm.DB) (User, error) { var user User - - err := s.DB.First(&user, id).Error + db := s.prepareDb(opts...) + err := db.First(&user, id).Error return user, err } -func (s *SqlStorage) FindUsersByIDs(ids []int) ([]User, error) { +func (s *SqlStorage) FindUsersByIDs(ids []int, opts ...func(*gorm.DB) *gorm.DB) ([]User, error) { users := make([]User, 0) - err := s.DB.Find(&users, ids).Error + db := s.prepareDb(opts...) + err := db.Find(&users, ids).Error return users, err } -func (s *SqlStorage) FindUsersByTicker(ticker Ticker) ([]User, error) { +func (s *SqlStorage) FindUsersByTicker(ticker Ticker, opts ...func(*gorm.DB) *gorm.DB) ([]User, error) { users := make([]User, 0) - err := s.DB.Model(&ticker).Association("Users").Find(&users) + db := s.prepareDb(opts...) + err := db.Model(&ticker).Association("Users").Find(&users) return users, err } -func (s *SqlStorage) FindUserByEmail(email string) (User, error) { +func (s *SqlStorage) FindUserByEmail(email string, opts ...func(*gorm.DB) *gorm.DB) (User, error) { var user User - - err := s.DB.First(&user, "email = ?", email).Error + db := s.prepareDb(opts...) + err := db.First(&user, "email = ?", email).Error return user, err } func (s *SqlStorage) SaveUser(user *User) error { - return s.DB.Save(user).Error + if user.ID == 0 { + return s.DB.Create(user).Error + } + + // Replace all Tickers associations + err := s.DB.Model(user).Association("Tickers").Replace(user.Tickers) + if err != nil { + log.WithError(err).WithField("user_id", user.ID).Error("failed to replace user tickers") + } + + return s.DB.Session(&gorm.Session{FullSaveAssociations: true}).Updates(user).Error } func (s *SqlStorage) DeleteUser(user User) error { @@ -344,3 +357,10 @@ func WithAttachments() func(*gorm.DB) *gorm.DB { return db.Preload("Attachments") } } + +// WithTickers is a helper function to preload the tickers association. +func WithTickers() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("Tickers") + } +} diff --git a/internal/storage/sql_storage_test.go b/internal/storage/sql_storage_test.go index 4fccc8e1..e2b3ab71 100644 --- a/internal/storage/sql_storage_test.go +++ b/internal/storage/sql_storage_test.go @@ -59,6 +59,22 @@ var _ = Describe("SqlStorage", func() { Expect(err).ToNot(HaveOccurred()) Expect(users).To(HaveLen(1)) }) + + It("returns all users with preloaded tickers", func() { + var ticker Ticker + err = db.Create(&ticker).Error + Expect(err).ToNot(HaveOccurred()) + + err = db.Create(&User{ + Tickers: []Ticker{ticker}, + }).Error + Expect(err).ToNot(HaveOccurred()) + + users, err := store.FindUsers(WithTickers()) + Expect(err).ToNot(HaveOccurred()) + Expect(users).To(HaveLen(1)) + Expect(users[0].Tickers).To(HaveLen(1)) + }) }) Describe("FindUserByID", func() { @@ -144,6 +160,81 @@ var _ = Describe("SqlStorage", func() { Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(1))) }) + + It("persists the user with tickers", func() { + ticker := &Ticker{ + Title: "Title", + Domain: "systemli.org", + } + err = store.SaveTicker(ticker) + Expect(err).ToNot(HaveOccurred()) + + user, err := NewUser("user@systemli.org", "password") + Expect(err).ToNot(HaveOccurred()) + user.Tickers = append(user.Tickers, *ticker) + + err = store.SaveUser(&user) + Expect(err).ToNot(HaveOccurred()) + + user, err = store.FindUserByID(user.ID, WithTickers()) + Expect(err).ToNot(HaveOccurred()) + Expect(user.Tickers).To(HaveLen(1)) + }) + + It("updates existing user", func() { + user, err := NewUser("user@systemli.org", "password") + Expect(err).ToNot(HaveOccurred()) + + err = store.SaveUser(&user) + Expect(err).ToNot(HaveOccurred()) + + user.Email = "new@systemli.org" + err = store.SaveUser(&user) + Expect(err).ToNot(HaveOccurred()) + + user, err = store.FindUserByID(user.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(user.Email).To(Equal("new@systemli.org")) + + ticker := &Ticker{ + Title: "Title", + Domain: "systemli.org", + } + err = store.SaveTicker(ticker) + Expect(err).ToNot(HaveOccurred()) + + user.Tickers = append(user.Tickers, *ticker) + err = store.SaveUser(&user) + Expect(err).ToNot(HaveOccurred()) + + user, err = store.FindUserByID(user.ID, WithTickers()) + Expect(err).ToNot(HaveOccurred()) + Expect(user.Tickers).To(HaveLen(1)) + }) + + It("updates existing user with less tickers", func() { + ticker := &Ticker{ + Title: "Title", + Domain: "systemli.org", + } + err = store.SaveTicker(ticker) + Expect(err).ToNot(HaveOccurred()) + + user, err := NewUser("user@systemli.org", "password") + Expect(err).ToNot(HaveOccurred()) + + user.Tickers = append(user.Tickers, *ticker) + err = store.SaveUser(&user) + Expect(err).ToNot(HaveOccurred()) + + user.Tickers = []Ticker{} + err = store.SaveUser(&user) + Expect(err).ToNot(HaveOccurred()) + + user, err = store.FindUserByID(user.ID, WithTickers()) + Expect(err).ToNot(HaveOccurred()) + Expect(user.Tickers).To(BeEmpty()) + }) }) Describe("DeleteUser", func() { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index caadc56d..26af4537 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -9,11 +9,11 @@ import ( var log = logrus.WithField("package", "storage") type Storage interface { - FindUsers() ([]User, error) - FindUserByID(id int) (User, error) - FindUsersByIDs(ids []int) ([]User, error) - FindUserByEmail(email string) (User, error) - FindUsersByTicker(ticker Ticker) ([]User, error) + FindUsers(opts ...func(*gorm.DB) *gorm.DB) ([]User, error) + FindUserByID(id int, opts ...func(*gorm.DB) *gorm.DB) (User, error) + FindUsersByIDs(ids []int, opts ...func(*gorm.DB) *gorm.DB) ([]User, error) + FindUserByEmail(email string, opts ...func(*gorm.DB) *gorm.DB) (User, error) + FindUsersByTicker(ticker Ticker, opts ...func(*gorm.DB) *gorm.DB) ([]User, error) SaveUser(user *User) error DeleteUser(user User) error DeleteTickerUsers(ticker *Ticker) error