From e62881b30ac3ee8e932190f2dfbb4eda4e5c69e9 Mon Sep 17 00:00:00 2001 From: eugene Date: Fri, 22 Dec 2017 13:03:42 -0600 Subject: [PATCH] add /vote support --- README.md | 32 ++++++++++++++---------- app/rest/server.go | 38 ++++++++++++++++++++++++++--- app/store/bolt.go | 55 +++++++++++++++++++++++++++++++++++++++--- app/store/bolt_test.go | 24 ++++++++++++++++++ app/store/store.go | 18 ++++++++------ 5 files changed, 138 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 66974d67..fbe2b023 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Comment engine ### Authorization -- `GET /login/{provider}?from=http://url` - login with one of supported providers and redirects to `url` +- `GET /login/{provider}?from=http://url` - perform "social" login with one of supported providers and redirect to `url` - `GET /logout` - logout -- `GET /user` - returns user info, auth required +- `GET /user` - get user info, _auth required_ ``` type User struct { @@ -24,22 +24,28 @@ _currently supported providers are `google` and `github`_ ### Commenting -- `POST /comment` - adds a comment. auth required +- `POST /comment` - add a comment. _auth required_ ``` type Comment struct { - ID int64 `json:"id"` // read only - ParentID int64 `json:"pid"` - Text string `json:"text"` - User User `json:"user"` // read only - Locator Locator `json:"locator"` - Score int `json:"score"` // read only - Timestamp time.Time `json:"time"` // read only + ID int64 `json:"id"` // read only + ParentID int64 `json:"pid"` + Text string `json:"text"` + User User `json:"user"` // read only + Locator Locator `json:"locator"` + Score int `json:"score"` // read only + Votes map[string]bool `json:"votes"` // read only + Timestamp time.Time `json:"time"` // read only +} + +type Locator struct { + SiteID string `json:"site"` + URL string `json:"url"` } ``` -- `GET /find?url=post-url` - find all comments for given post return list of `Comment` +- `GET /find?url=post-url` - find all comments for given post returns list of `Comment` - `GET /last/{max}` - get last `{max}` comments - `GET /id/{id}` - get comment by `id` - -- `DELETE /comment/{id}` - delete comment by `id`. auth and admin required \ No newline at end of file +- `PUT /vote/{id}?url=post-url&vote=1` - vote for comment. `vote`=1 will increase score, -1 decreases. _auth required_ +- `DELETE /comment/{id}` - delete comment by `id`. _auth and admin required_ \ No newline at end of file diff --git a/app/rest/server.go b/app/rest/server.go index 4a20c6a9..0b0be3c6 100644 --- a/app/rest/server.go +++ b/app/rest/server.go @@ -38,17 +38,17 @@ func (s *Server) Run() { router.Get("/login/google", s.AuthGoogle.LoginHandler) router.Get("/auth/google", s.AuthGoogle.AuthHandler) router.Get("/logout", s.AuthGithub.LogoutHandler) // can hit any provider - router.Get("/login/github", s.AuthGithub.LoginHandler) router.Get("/auth/github", s.AuthGithub.AuthHandler) - router.Post("/comment", s.createCommentCtrl) router.Get("/find", s.getURLComments) router.Get("/id/{id}", s.getByID) + router.Get("/last/{max}", s.getLastComments) router.With(Auth(s.SessionStore, s.Admins)).Group(func(r chi.Router) { - r.Get("/last/{max}", s.getLastComments) + r.Post("/comment", s.createCommentCtrl) r.Get("/user", s.getUserInfo) + r.Put("/vote/{id}", s.voteCtrl) r.With(AdminOnly).Delete("/comment/{id}", s.deleteCommentCtrl) }) @@ -94,7 +94,7 @@ func (s *Server) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[INFO] delete comment %d", id) url := r.URL.Query().Get("url") - err = s.Store.Delete(url, id) + err = s.Store.Delete(store.Locator{URL: url}, id) if err != nil { log.Printf("[WARN] can't delete comment, %s", err) httpError(w, r, http.StatusInternalServerError, err, "can't delete comment") @@ -179,6 +179,36 @@ func (s *Server) getUserInfo(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, user) } +// PUT /vote/{id}?url=post-url&vote=1 +func (s *Server) voteCtrl(w http.ResponseWriter, r *http.Request) { + + user, err := GetUserInfo(r) + if err != nil { + httpError(w, r, http.StatusUnauthorized, err, "can't get user info") + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + log.Printf("[WARN] bad id %s", chi.URLParam(r, "id")) + httpError(w, r, http.StatusBadRequest, err, "can't parse id") + } + + log.Printf("[INFO] vote for comment %d", id) + + url := r.URL.Query().Get("url") + vote := r.URL.Query().Get("vote") == "1" + + comment, err := s.Store.Vote(store.Locator{URL: url}, id, user.ID, vote) + if err != nil { + log.Printf("[WARN] vote rejected for %s - %d, %s", user.ID, id, err) + httpError(w, r, http.StatusInternalServerError, err, "can't delete comment") + return + } + + render.JSON(w, r, comment) +} + func httpError(w http.ResponseWriter, r *http.Request, code int, err error, details string) { render.Status(r, code) render.JSON(w, r, JSON{"error": err.Error(), "details": details}) diff --git a/app/store/bolt.go b/app/store/bolt.go index 302c7fda..a7893240 100644 --- a/app/store/bolt.go +++ b/app/store/bolt.go @@ -39,6 +39,7 @@ func (b *BoltDB) Create(comment Comment) (int64, error) { comment.ID = time.Now().UnixNano() comment.Timestamp = time.Now() + comment.Votes = map[string]bool{} err := b.Update(func(tx *bolt.Tx) error { bucket, e := tx.CreateBucketIfNotExists([]byte(comment.Locator.URL)) @@ -81,16 +82,16 @@ func (b *BoltDB) Create(comment Comment) (int64, error) { } // Delete removed comment by url and id from the store -func (b *BoltDB) Delete(url string, id int64) error { +func (b *BoltDB) Delete(locator Locator, id int64) error { return b.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(url)) + bucket := tx.Bucket([]byte(locator.URL)) if bucket == nil { - return errors.Errorf("no bucket %s in store", url) + return errors.Errorf("no bucket %s in store", locator.URL) } key := b.keyFromValue(id) if err := bucket.Delete(key); err != nil { - return errors.Wrapf(err, "can't delete key %s from bucket %s", key, url) + return errors.Wrapf(err, "can't delete key %s from bucket %s", key, locator.URL) } return nil }) @@ -196,6 +197,52 @@ func (b *BoltDB) Last(locator Locator, max int) (result []Comment, err error) { return result, err } +// Get comment by id +func (b *BoltDB) Vote(locator Locator, commentID int64, userID string, val bool) (comment Comment, err error) { + + err = b.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(locator.URL)) + if bucket == nil { + return errors.Errorf("no bucket %s in store", locator.URL) + } + + // get and unmarshal comment for the store + commentVal := bucket.Get(b.keyFromValue(commentID)) + if commentVal == nil { + return errors.Errorf("no comment for %d in store %s", commentID, locator.URL) + } + + if e := json.Unmarshal(commentVal, &comment); e != nil { + return errors.Wrap(e, "failed to unmarshal") + } + + // check if user voted already + for k := range comment.Votes { + if k == userID { + return errors.Errorf("user %s already voted for comment %d", userID, commentID) + } + } + + // update votes and score + comment.Votes[userID] = val + if val { + comment.Score++ + } else { + comment.Score-- + } + data, err := json.Marshal(&comment) + if err != nil { + return errors.Wrap(err, "can't marshal comment with updated votes") + } + if err = bucket.Put(b.keyFromValue(commentID), data); err != nil { + return errors.Wrap(err, "failed to save comment with updated votes") + } + return nil + }) + + return comment, err +} + func (b *BoltDB) keyFromComment(comment Comment) []byte { return []byte(fmt.Sprintf("%22d", comment.ID)) } diff --git a/app/store/bolt_test.go b/app/store/bolt_test.go index 47b58edb..f29209f1 100644 --- a/app/store/bolt_test.go +++ b/app/store/bolt_test.go @@ -71,6 +71,30 @@ func TestBoltDB_Last(t *testing.T) { assert.Equal(t, "some text2", res[0].Text) } +func TestBoltDB_Vote(t *testing.T) { + defer os.Remove(testDb) + b := prep(t) + + res, err := b.Last(Locator{URL: "https://radio-t.com"}, 0) + assert.Nil(t, err) + assert.Equal(t, 2, len(res)) + assert.Equal(t, 0, res[0].Score) + assert.Equal(t, map[string]bool{}, res[0].Votes) + + c, err := b.Vote(Locator{URL: "https://radio-t.com"}, res[0].ID, "user1", true) + assert.Nil(t, err) + assert.Equal(t, 1, c.Score) + assert.Equal(t, map[string]bool{"user1": true}, c.Votes) + + _, err = b.Vote(Locator{URL: "https://radio-t.com"}, res[0].ID, "user1", true) + assert.NotNil(t, err, "double-voting rejected") + + res, err = b.Last(Locator{URL: "https://radio-t.com"}, 0) + assert.Nil(t, err) + assert.Equal(t, 2, len(res)) + assert.Equal(t, 1, res[0].Score) +} + // makes new boltdb, put two records func prep(t *testing.T) *BoltDB { b, err := NewBoltDB(testDb) diff --git a/app/store/store.go b/app/store/store.go index a1d1d182..79c7d1d5 100644 --- a/app/store/store.go +++ b/app/store/store.go @@ -4,13 +4,14 @@ import "time" // Comment represents a single comment with reference to its parent type Comment struct { - ID int64 `json:"id"` - ParentID int64 `json:"pid"` - Text string `json:"text"` - User User `json:"user"` - Locator Locator `json:"locator"` - Score int `json:"score"` - Timestamp time.Time `json:"time"` + ID int64 `json:"id"` + ParentID int64 `json:"pid"` + Text string `json:"text"` + User User `json:"user"` + Locator Locator `json:"locator"` + Score int `json:"score"` + Votes map[string]bool `json:"votes"` + Timestamp time.Time `json:"time"` } // Locator keeps site and url of the post @@ -40,8 +41,9 @@ type Request struct { // Interface defines basic CRUD for comments type Interface interface { Create(comment Comment) (int64, error) - Delete(url string, id int64) error + Delete(locator Locator, id int64) error Find(request Request) ([]Comment, error) Last(locator Locator, max int) ([]Comment, error) Get(locator Locator, id int64) (Comment, error) + Vote(locator Locator, commentID int64, userID string, val bool) (Comment, error) }