Skip to content

Commit

Permalink
Working reactions UI and backend
Browse files Browse the repository at this point in the history
  • Loading branch information
mattwoberts committed Oct 17, 2024
1 parent a876384 commit e1a6ebd
Show file tree
Hide file tree
Showing 18 changed files with 416 additions and 56 deletions.
17 changes: 17 additions & 0 deletions app/actions/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,23 @@ func (action *UpdatePost) Validate(ctx context.Context, user *entity.User) *vali
return result
}

type ToggleCommentReaction struct {
Number int `route:"number"`
Comment int `route:"id"`
Reaction string `route:"reaction"`
}

// IsAuthorized returns true if current user is authorized to perform this action
func (action *ToggleCommentReaction) IsAuthorized(ctx context.Context, user *entity.User) bool {
return user != nil
}

// Validate if current model is valid
func (action *ToggleCommentReaction) Validate(ctx context.Context, user *entity.User) *validate.Result {
result := validate.Success()
return result
}

// AddNewComment represents a new comment to be added
type AddNewComment struct {
Number int `route:"number"`
Expand Down
1 change: 1 addition & 0 deletions app/cmd/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ func routes(r *web.Engine) *web.Engine {

membersApi.Post("/api/v1/posts", apiv1.CreatePost())
membersApi.Put("/api/v1/posts/:number", apiv1.UpdatePost())
membersApi.Post("/api/v1/posts/:number/comments/:id/reactions/:reaction", apiv1.ToggleReaction())
membersApi.Post("/api/v1/posts/:number/comments", apiv1.PostComment())
membersApi.Put("/api/v1/posts/:number/comments/:id", apiv1.UpdateComment())
membersApi.Delete("/api/v1/posts/:number/comments/:id", apiv1.DeleteComment())
Expand Down
28 changes: 28 additions & 0 deletions app/handlers/apiv1/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,34 @@ func GetComment() web.HandlerFunc {
}
}

// ToggleReaction adds or removes a reaction on a comment
func ToggleReaction() web.HandlerFunc {
return func(c *web.Context) error {
action := new(actions.ToggleCommentReaction)
if result := c.BindTo(action); !result.Ok {
return c.HandleValidation(result)
}

getComment := &query.GetCommentByID{CommentID: action.Comment}
if err := bus.Dispatch(c, getComment); err != nil {
return c.Failure(err)
}

toggleReaction := &cmd.ToggleCommentReaction{
Comment: getComment.Result,
Emoji: action.Reaction,
User: c.User(),
}
if err := bus.Dispatch(c, toggleReaction); err != nil {
return c.Failure(err)
}

return c.Ok(web.Map{
"added": toggleReaction.Result,
})
}
}

// PostComment creates a new comment on given post
func PostComment() web.HandlerFunc {
return func(c *web.Context) error {
Expand Down
95 changes: 80 additions & 15 deletions app/handlers/apiv1/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,11 @@ func TestUpdatePostHandler_NonAuthorized(t *testing.T) {
RegisterT(t)

post := &entity.Post{
ID: 5,
Number: 5,
Title: "My First Post",
ID: 5,
Number: 5,
Title: "My First Post",
Description: "Such an amazing description",
User: mock.JonSnow,
User: mock.JonSnow,
}
bus.AddHandler(func(ctx context.Context, q *query.GetPostByNumber) error {
if q.Number == post.Number {
Expand All @@ -151,12 +151,12 @@ func TestUpdatePostHandler_IsOwner_AfterGracePeriod(t *testing.T) {
RegisterT(t)

post := &entity.Post{
ID: 5,
Number: 5,
Title: "My First Post",
ID: 5,
Number: 5,
Title: "My First Post",
Description: "Such an amazing description",
User: mock.AryaStark,
CreatedAt: time.Now().UTC().Add(-2 * time.Hour),
User: mock.AryaStark,
CreatedAt: time.Now().UTC().Add(-2 * time.Hour),
}
bus.AddHandler(func(ctx context.Context, q *query.GetPostByNumber) error {
if q.Number == post.Number {
Expand All @@ -175,17 +175,16 @@ func TestUpdatePostHandler_IsOwner_AfterGracePeriod(t *testing.T) {
Expect(code).Equals(http.StatusForbidden)
}


func TestUpdatePostHandler_IsOwner_WithinGracePeriod(t *testing.T) {
RegisterT(t)

post := &entity.Post{
ID: 5,
Number: 5,
Title: "My First Post",
ID: 5,
Number: 5,
Title: "My First Post",
Description: "Such an amazing description",
User: mock.AryaStark,
CreatedAt: time.Now().UTC(),
User: mock.AryaStark,
CreatedAt: time.Now().UTC(),
}
bus.AddHandler(func(ctx context.Context, q *query.GetPostByNumber) error {
if q.Number == post.Number {
Expand Down Expand Up @@ -654,3 +653,69 @@ func TestListCommentHandler(t *testing.T) {
Expect(query.IsArray()).IsTrue()
Expect(query.ArrayLength()).Equals(2)
}

func TestCommentReactionToggleHandler(t *testing.T) {
RegisterT(t)

comment := &entity.Comment{ID: 5, Content: "Old comment text", User: mock.AryaStark}

bus.AddHandler(func(ctx context.Context, q *query.GetCommentByID) error {
q.Result = comment
return nil
})

testCases := []struct {
name string
user *entity.User
reaction string
}{
{"JonSnow reacts with like", mock.JonSnow, "like"},
{"AryaStark reacts with smile", mock.AryaStark, "smile"},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var toggleReaction *cmd.ToggleCommentReaction
bus.AddHandler(func(ctx context.Context, c *cmd.ToggleCommentReaction) error {
toggleReaction = c
return nil
})

code, _ := mock.NewServer().
OnTenant(mock.DemoTenant).
AsUser(tc.user).
AddParam("number", 1).
AddParam("id", comment.ID).
AddParam("reaction", tc.reaction).
ExecutePost(apiv1.ToggleReaction(), ``)

Expect(code).Equals(http.StatusOK)
Expect(toggleReaction.Emoji).Equals(tc.reaction)
Expect(toggleReaction.Comment).Equals(comment)
Expect(toggleReaction.User).Equals(tc.user)
})
}
}

func TestCommentReactionToggleHandler_UnAuthorised(t *testing.T) {
RegisterT(t)

comment := &entity.Comment{ID: 5, Content: "Old comment text", User: mock.AryaStark}
bus.AddHandler(func(ctx context.Context, q *query.GetCommentByID) error {
q.Result = comment
return nil
})

bus.AddHandler(func(ctx context.Context, c *cmd.ToggleCommentReaction) error {
return nil
})

code, _ := mock.NewServer().
OnTenant(mock.DemoTenant).
AddParam("number", 1).
AddParam("id", comment.ID).
AddParam("reaction", "like").
ExecutePost(apiv1.ToggleReaction(), ``)

Expect(code).Equals(http.StatusForbidden)
}
10 changes: 10 additions & 0 deletions app/models/cmd/reaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package cmd

import "github.com/getfider/fider/app/models/entity"

type ToggleCommentReaction struct {
Comment *entity.Comment
Emoji string
User *entity.User
Result bool
}
22 changes: 11 additions & 11 deletions app/models/entity/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ package entity
import "time"

type ReactionCounts struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
Emoji string `json:"emoji"`
Count int `json:"count"`
IncludesMe bool `json:"includesMe"`
}

// Comment represents an user comment on an post
type Comment struct {
ID int `json:"id"`
Content string `json:"content"`
CreatedAt time.Time `json:"createdAt"`
User *User `json:"user"`
Attachments []string `json:"attachments,omitempty"`
EditedAt *time.Time `json:"editedAt,omitempty"`
EditedBy *User `json:"editedBy,omitempty"`
ReactionCounts map[string]int `json:"reactionCounts,omitempty"`
Reactions string `json:"reactions,omitempty"`
ID int `json:"id"`
Content string `json:"content"`
CreatedAt time.Time `json:"createdAt"`
User *User `json:"user"`
Attachments []string `json:"attachments,omitempty"`
EditedAt *time.Time `json:"editedAt,omitempty"`
EditedBy *User `json:"editedBy,omitempty"`
ReactionCounts []ReactionCounts `json:"reactionCounts,omitempty"`
}
61 changes: 44 additions & 17 deletions app/services/sqlstore/postgres/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ import (
"github.com/getfider/fider/app/pkg/errors"
)

type ReactionCounts struct {
Emoji string `db:"emoji"`
Count int `db:"count"`
}

type dbComment struct {
ID int `db:"id"`
Content string `db:"content"`
Expand All @@ -26,7 +21,6 @@ type dbComment struct {
EditedAt dbx.NullTime `db:"edited_at"`
EditedBy *dbUser `db:"edited_by"`
ReactionCounts dbx.NullString `db:"reaction_counts"`
// Reactions dbx.NullString `db:"reactions"`
}

func (c *dbComment) toModel(ctx context.Context) *entity.Comment {
Expand All @@ -36,22 +30,15 @@ func (c *dbComment) toModel(ctx context.Context) *entity.Comment {
CreatedAt: c.CreatedAt,
User: c.User.toModel(ctx),
Attachments: c.Attachments,
// Reactions: c.Reactions.String,
}
if c.EditedAt.Valid {
comment.EditedBy = c.EditedBy.toModel(ctx)
comment.EditedAt = &c.EditedAt.Time
}

comment.ReactionCounts = make(map[string]int)
if c.ReactionCounts.Valid {
var reactionCounts map[string]int
err := json.Unmarshal([]byte(c.ReactionCounts.String), &reactionCounts)
if err == nil {
comment.ReactionCounts = reactionCounts
}
json.Unmarshal([]byte(c.ReactionCounts.String), &comment.ReactionCounts)
}

return comment
}

Expand All @@ -76,6 +63,38 @@ func addNewComment(ctx context.Context, c *cmd.AddNewComment) error {
})
}

func toggleCommentReaction(ctx context.Context, c *cmd.ToggleCommentReaction) error {
return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error {
var added bool
err := trx.Scalar(&added, `
WITH toggle_reaction AS (
INSERT INTO reactions (comment_id, user_id, emoji, created_on)
VALUES ($1, $2, $3, $4)
ON CONFLICT (comment_id, user_id, emoji) DO NOTHING
RETURNING true AS added
),
delete_existing AS (
DELETE FROM reactions
WHERE comment_id = $1 AND user_id = $2 AND emoji = $3
AND NOT EXISTS (SELECT 1 FROM toggle_reaction)
RETURNING false AS added
)
SELECT COALESCE(
(SELECT added FROM toggle_reaction),
(SELECT added FROM delete_existing),
false
)
`, c.Comment.ID, user.ID, c.Emoji, time.Now())

if err != nil {
return errors.Wrap(err, "failed to toggle reaction")
}

c.Result = added
return nil
})
}

func updateComment(ctx context.Context, c *cmd.UpdateComment) error {
return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error {
_, err := trx.Execute(`
Expand Down Expand Up @@ -168,9 +187,17 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error {
agg_reactions AS (
SELECT
comment_id,
json_object_agg(emoji, count) as reaction_counts
json_agg(json_build_object(
'emoji', emoji,
'count', count,
'includesMe', CASE WHEN $3 = ANY(user_ids) THEN true ELSE false END
)) as reaction_counts
FROM (
SELECT comment_id, emoji, COUNT(*) as count
SELECT
comment_id,
emoji,
COUNT(*) as count,
array_agg(user_id) as user_ids
FROM reactions
WHERE comment_id IN (SELECT id FROM comments WHERE post_id = $1)
GROUP BY comment_id, emoji
Expand Down Expand Up @@ -214,7 +241,7 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error {
WHERE p.id = $1
AND p.tenant_id = $2
AND c.deleted_at IS NULL
ORDER BY c.created_at ASC`, q.Post.ID, tenant.ID)
ORDER BY c.created_at ASC`, q.Post.ID, tenant.ID, user.ID)
if err != nil {
return errors.Wrap(err, "failed get comments of post with id '%d'", q.Post.ID)
}
Expand Down
Loading

0 comments on commit e1a6ebd

Please sign in to comment.