Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion cmd/thoughts/controllers/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,23 @@ func RetroNotesIndex(db *sqlx.DB) http.Handler {
return
}

reactions, err := dal.ReactionsForRetro(r.Context(), db, retroID)
if err != nil {
slog.Error("problem fetching reactions", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}

reactionsByNote := lo.GroupBy(reactions, func(reaction *model.Reaction) uuid.UUID {
return reaction.NoteID
})

user := auth.UserFromRequest(r)

obfuscate := retro.Status == model.RetroStatusBrainstorm

writeJSON(w, lo.Map(notes, func(note *model.Note, _ int) *resources.Note {
return resources.NoteFromModel(note, userMap[note.UserID], user.ID, obfuscate)
return resources.NoteFromModel(note, userMap[note.UserID], user.ID, obfuscate, reactionsByNote[note.ID])
}))
})
}
89 changes: 89 additions & 0 deletions cmd/thoughts/dal/reaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package dal

import (
"context"
"fmt"
"time"

"github.com/ellgreen/thoughts/cmd/thoughts/model"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)

func ReactionsForNote(
ctx context.Context,
db *sqlx.DB,
noteID uuid.UUID,
) ([]*model.Reaction, error) {
reactions := []*model.Reaction{}
if err := db.SelectContext(ctx, &reactions, "select * from reactions where note_id = ?", noteID); err != nil {
return nil, fmt.Errorf("%w: failed to get reactions for note: %w", ErrExecution, err)
}

return reactions, nil
}

func ReactionsForRetro(
ctx context.Context,
db *sqlx.DB,
retroID uuid.UUID,
) ([]*model.Reaction, error) {
reactions := []*model.Reaction{}
if err := db.SelectContext(ctx, &reactions, "select * from reactions where retro_id = ?", retroID); err != nil {
return nil, fmt.Errorf("%w: failed to get reactions for retro: %w", ErrExecution, err)
}

return reactions, nil
}

func ReactionInsert(
ctx context.Context,
db *sqlx.DB,
retroID uuid.UUID,
noteID uuid.UUID,
userID uuid.UUID,
emoji string,
) (*model.Reaction, error) {
reaction := &model.Reaction{
ID: uuid.New(),
RetroID: retroID,
NoteID: noteID,
UserID: userID,
Emoji: emoji,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}

_, err := db.NamedExecContext(ctx, `
insert or ignore into reactions
(id, retro_id, note_id, user_id, emoji, created_at, updated_at)
values
(:id, :retro_id, :note_id, :user_id, :emoji, :created_at, :updated_at)
`, reaction)

if err != nil {
return nil, fmt.Errorf("%w: failed to insert reaction: %w", ErrExecution, err)
}

return reaction, nil
}

func ReactionDelete(
ctx context.Context,
db *sqlx.DB,
retroID uuid.UUID,
noteID uuid.UUID,
userID uuid.UUID,
emoji string,
) error {
_, err := db.ExecContext(ctx, `
delete from reactions
where retro_id = $1 and note_id = $2 and user_id = $3 and emoji = $4
`, retroID, noteID, userID, emoji)

if err != nil {
return fmt.Errorf("%w: failed to delete reaction: %w", ErrExecution, err)
}

return nil
}
1 change: 1 addition & 0 deletions cmd/thoughts/event/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func NewBroker(db *sqlx.DB, retroID uuid.UUID) *Broker {
b.register("task_create", b.handleTaskCreate(db, retroID))
b.register("task_update", b.handleTaskUpdate(db))
b.register("task_complete", b.handleTaskComplete(db))
b.register("reaction_toggle", b.handleReactionToggle(db, retroID))

return b
}
Expand Down
16 changes: 12 additions & 4 deletions cmd/thoughts/event/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,15 @@ func (b *Broker) handleNoteUpdate(db *sqlx.DB, retroID uuid.UUID) Handler {
return newErrorEvent("problem updating note")
}

b.dispatchUserDependent(newNoteUpdatedEvent(note, author, retro, refFrom(payload)))
// The frontend replaces the whole note on this event, so its
// reactions have to come along or they'd vanish for everyone.
reactions, err := dal.ReactionsForNote(ctx, db, note.ID)
if err != nil {
slog.Error("problem getting note reactions", "error", err)
return newErrorEvent("problem updating note")
}

b.dispatchUserDependent(newNoteUpdatedEvent(note, author, retro, reactions, refFrom(payload)))

return nil
}
Expand Down Expand Up @@ -186,7 +194,7 @@ func payloadHasAny(payload Payload, keys ...string) bool {
// "unknown" for everyone once it was moved or edited.
func newNoteCreatedEvent(note *model.Note, author *model.User, retro *model.Retro, ref string) UserDependentEvent {
return func(user *model.User) *Event {
resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming())
resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming(), nil)
payload := resources.StructToMap(resource)

return &Event{
Expand All @@ -196,9 +204,9 @@ func newNoteCreatedEvent(note *model.Note, author *model.User, retro *model.Retr
}
}

func newNoteUpdatedEvent(note *model.Note, author *model.User, retro *model.Retro, ref string) UserDependentEvent {
func newNoteUpdatedEvent(note *model.Note, author *model.User, retro *model.Retro, reactions []*model.Reaction, ref string) UserDependentEvent {
return func(user *model.User) *Event {
resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming())
resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming(), reactions)
payload := resources.StructToMap(resource)

return &Event{
Expand Down
91 changes: 91 additions & 0 deletions cmd/thoughts/event/reactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package event

import (
"context"
"log/slog"

"github.com/ellgreen/thoughts/cmd/thoughts/dal"
"github.com/ellgreen/thoughts/cmd/thoughts/model"
"github.com/ellgreen/thoughts/cmd/thoughts/requests"
"github.com/ellgreen/thoughts/cmd/thoughts/resources"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)

// Kept in step with ui/src/lib/reactions.ts: reactions are a fixed set, not
// free-form emoji, so both sides list the same six.
var allowedReactionEmoji = map[string]bool{
"๐Ÿ‘": true,
"๐ŸŽ‰": true,
"๐Ÿ˜‚": true,
"๐Ÿ˜ฎ": true,
"๐Ÿ‘€": true,
"โค๏ธ": true,
}

type reactionToggleRequest struct {
NoteID uuid.UUID `json:"note_id" validate:"required,uuid"`
Emoji string `json:"emoji" validate:"required"`
Value bool `json:"value"`
}

func (b *Broker) handleReactionToggle(db *sqlx.DB, retroID uuid.UUID) Handler {
return func(ctx context.Context, user *model.User, payload Payload) error {
req, err := requests.FromMap[reactionToggleRequest](payload)
if err != nil {
return newErrorEvent(err.Error())
}

if !allowedReactionEmoji[req.Emoji] {
return newErrorEvent("that's not a reaction we support")
}

if err := authoriseNote(ctx, db, user, retroID, req.NoteID, false); err != nil {
return err
}

retro, err := dal.RetroGet(ctx, db, retroID)
if err != nil {
slog.Error("problem getting retro", "error", err)
return newErrorEvent("problem getting retro")
}

if retro.Status != model.RetroStatusDiscuss {
return newErrorEvent("reactions are only available while discussing")
}

if req.Value {
if _, err := dal.ReactionInsert(ctx, db, retroID, req.NoteID, user.ID, req.Emoji); err != nil {
slog.Error("problem inserting reaction", "error", err)
return newErrorEvent("problem adding reaction")
}
} else {
if err := dal.ReactionDelete(ctx, db, retroID, req.NoteID, user.ID, req.Emoji); err != nil {
slog.Error("problem deleting reaction", "error", err)
return newErrorEvent("problem removing reaction")
}
}

reactions, err := dal.ReactionsForNote(ctx, db, req.NoteID)
if err != nil {
slog.Error("problem getting note reactions", "error", err)
return newErrorEvent("problem updating reactions")
}

b.dispatchUserDependent(newNoteReactionsUpdatedEvent(req.NoteID, reactions, refFrom(payload)))

return nil
}
}

func newNoteReactionsUpdatedEvent(noteID uuid.UUID, reactions []*model.Reaction, ref string) UserDependentEvent {
return func(user *model.User) *Event {
return &Event{
Name: "note_reactions_updated",
Payload: withRef(Payload{
"id": noteID,
"reactions": resources.ReactionSummariesFromModel(reactions, user.ID),
}, ref),
}
}
}
Loading
Loading