add /vote support

This commit is contained in:
eugene
2017-12-22 13:03:42 -06:00
parent abf9811aaa
commit e62881b30a
5 changed files with 138 additions and 29 deletions
+19 -13
View File
@@ -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
- `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_
+34 -4
View File
@@ -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})
+51 -4
View File
@@ -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))
}
+24
View File
@@ -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)
+10 -8
View File
@@ -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)
}