From e72b9a5b35ffce657af4ee0824ab7b08c6a45ea4 Mon Sep 17 00:00:00 2001 From: wschoepke Date: Fri, 31 Jan 2025 12:08:58 +0100 Subject: [PATCH] refactor: event_filter and do some refactorings and additional implementations * try to simplify event_filter methods (some methods are redundant and can be removed for example filter or better updating votes per note), * move some business (board, vote etc) specific structs and methods to new folder structure (vote specific stuff to vote), * introduce technical helpers (should be only small methods) for unmarshal event data, * reduce complexity of event_filter. use a switch case structure with small blocks to call the specific methods. every method returns a status in the form of true/false if the event is successful proceeded or not, * every method have nearly the same structure now ( easier to read and more maintainable now) * encapsulate logic in business specific services (board, notes, votes etc), * try to add some new unit tests, * implement some helper methods like map and filter and add tests for them. --- server/src/api/boards.go | 25 +- server/src/api/boards_listen_on_board.go | 21 +- server/src/api/event_filter.go | 340 ++++++------------ server/src/api/event_filter_test.go | 150 +++++--- server/src/api/notes_test.go | 13 +- server/src/api/votings.go | 6 +- server/src/api/votings_test.go | 23 +- server/src/columns/service.go | 80 +++++ server/src/common/dto/boards.go | 21 +- server/src/common/dto/columns.go | 51 --- server/src/common/dto/notes.go | 68 +--- server/src/notes/service.go | 108 ++++++ server/src/notes/service_test.go | 66 ++++ server/src/services/boards/boards.go | 3 +- server/src/services/boards/columns.go | 26 +- server/src/services/boards/sessions.go | 6 +- server/src/services/notes/notes.go | 25 +- server/src/services/notes/notes_test.go | 7 +- server/src/services/services.go | 29 +- server/src/services/votings/votings.go | 39 +- server/src/services/votings/votings_test.go | 19 +- server/src/session_helper/role_checker.go | 19 + server/src/technical_helper/slice.go | 19 + server/src/technical_helper/slice_test.go | 33 ++ .../unmarshaller.go} | 6 +- .../unmarshaller_test.go} | 14 +- server/src/votes/request_dto.go | 21 ++ server/src/votes/result_dto.go | 18 + .../dto/votings.go => votes/service.go} | 84 +++-- 29 files changed, 798 insertions(+), 542 deletions(-) create mode 100644 server/src/columns/service.go create mode 100644 server/src/notes/service.go create mode 100644 server/src/notes/service_test.go create mode 100644 server/src/session_helper/role_checker.go create mode 100644 server/src/technical_helper/slice.go create mode 100644 server/src/technical_helper/slice_test.go rename server/src/{api/event_content_unmarshaller.go => technical_helper/unmarshaller.go} (73%) rename server/src/{api/event_content_unmarshaller_test.go => technical_helper/unmarshaller_test.go} (74%) create mode 100644 server/src/votes/request_dto.go create mode 100644 server/src/votes/result_dto.go rename server/src/{common/dto/votings.go => votes/service.go} (67%) diff --git a/server/src/api/boards.go b/server/src/api/boards.go index 3a33156218..251073a8d4 100644 --- a/server/src/api/boards.go +++ b/server/src/api/boards.go @@ -6,6 +6,9 @@ import ( "errors" "fmt" "net/http" + "scrumlr.io/server/columns" + "scrumlr.io/server/notes" + "scrumlr.io/server/votes" "strconv" "scrumlr.io/server/identifiers" @@ -315,14 +318,14 @@ func (s *Server) exportBoard(w http.ResponseWriter, r *http.Request) { return } - visibleColumns := []*dto.Column{} + var visibleColumns []*columns.Column for _, column := range fullBoard.Columns { if column.Visible { visibleColumns = append(visibleColumns, column) } } - visibleNotes := []*dto.Note{} + var visibleNotes []*notes.Note for _, note := range fullBoard.Notes { for _, column := range visibleColumns { if note.Position.Column == column.ID { @@ -336,9 +339,9 @@ func (s *Server) exportBoard(w http.ResponseWriter, r *http.Request) { render.Respond(w, r, struct { Board *dto.Board `json:"board"` Participants []*dto.BoardSession `json:"participants"` - Columns []*dto.Column `json:"columns"` - Notes []*dto.Note `json:"notes"` - Votings []*dto.Voting `json:"votings"` + Columns []*columns.Column `json:"columns"` + Notes []*notes.Note `json:"notes"` + Votings []*votes.Voting `json:"votings"` }{ Board: fullBoard.Board, Participants: fullBoard.BoardSessions, @@ -459,11 +462,11 @@ func (s *Server) importBoard(w http.ResponseWriter, r *http.Request) { } type ParentChildNotes struct { - Parent dto.Note - Children []dto.Note + Parent notes.Note + Children []notes.Note } - parentNotes := make(map[uuid.UUID]dto.Note) - childNotes := make(map[uuid.UUID][]dto.Note) + parentNotes := make(map[uuid.UUID]notes.Note) + childNotes := make(map[uuid.UUID][]notes.Note) for _, note := range body.Notes { if !note.Position.Stack.Valid { @@ -480,7 +483,7 @@ func (s *Server) importBoard(w http.ResponseWriter, r *http.Request) { note, err := s.notes.Import(r.Context(), dto.NoteImportRequest{ Text: parentNote.Text, - Position: dto.NotePosition{ + Position: notes.NotePosition{ Column: cols[i].ID, Stack: uuid.NullUUID{}, Rank: 0, @@ -508,7 +511,7 @@ func (s *Server) importBoard(w http.ResponseWriter, r *http.Request) { Text: note.Text, Board: b.ID, User: note.Author, - Position: dto.NotePosition{ + Position: notes.NotePosition{ Column: node.Parent.Position.Column, Rank: note.Position.Rank, Stack: uuid.NullUUID{ diff --git a/server/src/api/boards_listen_on_board.go b/server/src/api/boards_listen_on_board.go index d1d2e7d31c..4de8f7fcd4 100644 --- a/server/src/api/boards_listen_on_board.go +++ b/server/src/api/boards_listen_on_board.go @@ -3,6 +3,9 @@ package api import ( "context" "net/http" + "scrumlr.io/server/columns" + "scrumlr.io/server/notes" + "scrumlr.io/server/votes" "scrumlr.io/server/identifiers" @@ -18,8 +21,8 @@ type BoardSubscription struct { clients map[uuid.UUID]*websocket.Conn boardParticipants []*dto.BoardSession boardSettings *dto.Board - boardColumns []*dto.Column - boardNotes []*dto.Note + boardColumns []*columns.Column + boardNotes []*notes.Note boardReactions []*dto.Reaction } @@ -30,10 +33,10 @@ type InitEvent struct { type EventData struct { Board *dto.Board `json:"board"` - Columns []*dto.Column `json:"columns"` - Notes []*dto.Note `json:"notes"` + Columns []*columns.Column `json:"columns"` + Notes []*notes.Note `json:"notes"` Reactions []*dto.Reaction `json:"reactions"` - Votings []*dto.Voting `json:"votings"` + Votings []*votes.Voting `json:"votings"` Votes []*dto.Vote `json:"votes"` Sessions []*dto.BoardSession `json:"participants"` Requests []*dto.BoardSessionRequest `json:"requests"` @@ -119,11 +122,11 @@ func (s *Server) listenOnBoard(boardID, userID uuid.UUID, conn *websocket.Conn, } } -func (b *BoardSubscription) startListeningOnBoard() { - for msg := range b.subscription { +func (bs *BoardSubscription) startListeningOnBoard() { + for msg := range bs.subscription { logger.Get().Debugw("message received", "message", msg) - for id, conn := range b.clients { - filteredMsg := b.eventFilter(msg, id) + for id, conn := range bs.clients { + filteredMsg := bs.eventFilter(msg, id) if err := conn.WriteJSON(filteredMsg); err != nil { logger.Get().Warnw("failed to send message", "message", filteredMsg, "err", err) } diff --git a/server/src/api/event_filter.go b/server/src/api/event_filter.go index 47c8c5801b..8edc84579a 100644 --- a/server/src/api/event_filter.go +++ b/server/src/api/event_filter.go @@ -2,270 +2,169 @@ package api import ( "github.com/google/uuid" + columnService "scrumlr.io/server/columns" "scrumlr.io/server/common/dto" "scrumlr.io/server/database/types" "scrumlr.io/server/logger" + "scrumlr.io/server/notes" "scrumlr.io/server/realtime" + "scrumlr.io/server/session_helper" + "scrumlr.io/server/technical_helper" + "scrumlr.io/server/votes" ) -func isModerator(clientID uuid.UUID, sessions []*dto.BoardSession) bool { - for _, session := range sessions { - if clientID == session.User.ID { - if session.Role == types.SessionRoleModerator || session.Role == types.SessionRoleOwner { - return true - } - } - } - return false -} - -type VotingUpdated struct { - Notes []*dto.Note `json:"notes"` - Voting *dto.Voting `json:"voting"` -} +func (bs *BoardSubscription) eventFilter(event *realtime.BoardEvent, userID uuid.UUID) *realtime.BoardEvent { + isMod := session_helper.CheckSessionRole(userID, bs.boardParticipants, []types.SessionRole{types.SessionRoleModerator, types.SessionRoleOwner}) -func filterColumns(eventColumns []*dto.Column) []*dto.Column { - var visibleColumns = make([]*dto.Column, 0, len(eventColumns)) - for _, column := range eventColumns { - if column.Visible { - visibleColumns = append(visibleColumns, column) + switch event.Type { + case realtime.BoardEventColumnsUpdated: + if updated, ok := bs.columnsUpdated(event, userID, isMod); ok { + return updated } - } - - return visibleColumns -} - -func filterNotes(eventNotes []*dto.Note, userID uuid.UUID, boardSettings *dto.Board, columns []*dto.Column) []*dto.Note { - var visibleNotes = make([]*dto.Note, 0) - for _, note := range eventNotes { - for _, column := range columns { - if (note.Position.Column == column.ID) && column.Visible { - // BoardSettings -> Remove other participant cards - if boardSettings.ShowNotesOfOtherUsers { - visibleNotes = append(visibleNotes, note) - } else if userID == note.Author { - visibleNotes = append(visibleNotes, note) - } - } + case realtime.BoardEventNotesUpdated, realtime.BoardEventNotesSync: + if updated, ok := bs.notesUpdated(event, userID, isMod); ok { + return updated } - } - // Authors - for _, note := range visibleNotes { - if !boardSettings.ShowAuthors && note.Author != userID { - note.Author = uuid.Nil + case realtime.BoardEventBoardUpdated: + if updated, ok := bs.boardUpdated(event, isMod); ok { + return updated + } + case realtime.BoardEventVotingUpdated: + if updated, ok := bs.votingUpdated(event, userID, isMod); ok { + return updated + } + case realtime.BoardEventParticipantUpdated: + _ = bs.participantUpdated(event, isMod) + case realtime.BoardEventVotesDeleted: + if updated, ok := bs.votesDeleted(event, userID); ok { + return updated } } - - return visibleNotes + // returns, if no filter match occurred + return event } -func filterVotingUpdated(voting *VotingUpdated, userID uuid.UUID, boardSettings *dto.Board, columns []*dto.Column) *VotingUpdated { - filteredVoting := voting - // Filter voting notes - filteredVotingNotes := filterNotes(voting.Notes, userID, boardSettings, columns) +func (bs *BoardSubscription) columnsUpdated(event *realtime.BoardEvent, userID uuid.UUID, isMod bool) (*realtime.BoardEvent, bool) { + var columns columnService.ColumnSlice + columns, err := columnService.UnmarshallColumnData(event.Data) - // Safeguard if voting is terminated without any votes - if voting.Voting.VotingResults == nil { - ret := &VotingUpdated{ - Notes: filteredVotingNotes, - Voting: voting.Voting, - } - return ret + if err != nil { + logger.Get().Errorw("unable to parse columnUpdated in event filter", "board", bs.boardSettings.ID, "session", userID, "err", err) + return nil, false } - // Filter voting results - filteredVotingResult := &dto.VotingResults{ - Votes: make(map[uuid.UUID]dto.VotingResultsPerNote), - } - overallVoteCount := 0 - mappedResultVotes := voting.Voting.VotingResults.Votes - for _, note := range filteredVotingNotes { - if voteResults, ok := mappedResultVotes[note.ID]; ok { // Check if note was voted on - filteredVotingResult.Votes[note.ID] = dto.VotingResultsPerNote{ - Total: voteResults.Total, - Users: voteResults.Users, - } - overallVoteCount += mappedResultVotes[note.ID].Total - } + if isMod { + bs.boardColumns = columns + return event, true + } else { + return &realtime.BoardEvent{ + Type: event.Type, + Data: columns.FilterVisibleColumns(), + }, true } - filteredVotingResult.Total = overallVoteCount - - filteredVoting.Notes = filteredVotingNotes - filteredVoting.Voting.VotingResults = filteredVotingResult - - return filteredVoting } -func filterVoting(voting *dto.Voting, filteredNotes []*dto.Note) *dto.Voting { - if voting.VotingResults == nil { - return voting +func (bs *BoardSubscription) notesUpdated(event *realtime.BoardEvent, userID uuid.UUID, isMod bool) (*realtime.BoardEvent, bool) { + noteSlice, err := notes.UnmarshallNotaData(event.Data) + if err != nil { + logger.Get().Errorw("unable to parse notesUpdated or eventNotesSync in event filter", "board", bs.boardSettings.ID, "session", userID, "err", err) + return nil, false } - filteredVoting := voting - filteredVotingResult := &dto.VotingResults{ - Votes: make(map[uuid.UUID]dto.VotingResultsPerNote), - } - overallVoteCount := 0 - mappedResultVotes := voting.VotingResults.Votes - for _, note := range filteredNotes { - if votingResult, ok := mappedResultVotes[note.ID]; ok { // Check if note was voted on - filteredVotingResult.Votes[note.ID] = dto.VotingResultsPerNote{ - Total: votingResult.Total, - Users: votingResult.Users, - } - overallVoteCount += mappedResultVotes[note.ID].Total - } + if isMod { + bs.boardNotes = noteSlice + return event, true + } else { + return &realtime.BoardEvent{ + Type: event.Type, + Data: noteSlice.FilterNotesByBoardSettingsOrAuthorInformation(userID, bs.boardSettings.ShowNotesOfOtherUsers, bs.boardSettings.ShowAuthors, bs.boardColumns), + }, true } - filteredVotingResult.Total = overallVoteCount - - filteredVoting.VotingResults = filteredVotingResult - - return filteredVoting } -func (boardSubscription *BoardSubscription) eventFilter(event *realtime.BoardEvent, userID uuid.UUID) *realtime.BoardEvent { - isMod := isModerator(userID, boardSubscription.boardParticipants) - if event.Type == realtime.BoardEventColumnsUpdated { - columns, err := unmarshalSlice[dto.Column](event.Data) - if err != nil { - logger.Get().Errorw("unable to parse columnUpdated in event filter", "board", boardSubscription.boardSettings.ID, "session", userID, "err", err) - } - // Cache the incoming changes, mod only since they receive all changes - if isMod { - boardSubscription.boardColumns = columns - return event - } - - filteredColumns := filterColumns(columns) - ret := realtime.BoardEvent{ - Type: event.Type, - Data: filteredColumns, - } - - return &ret // after this event, a syncNotes event is triggered from the board service +func (bs *BoardSubscription) boardUpdated(event *realtime.BoardEvent, isMod bool) (*realtime.BoardEvent, bool) { + boardSettings, err := technical_helper.Unmarshal[dto.Board](event.Data) + if err != nil { + logger.Get().Errorw("unable to parse boardUpdated in event filter", "board", bs.boardSettings.ID, "err", err) + return nil, false } - - if event.Type == realtime.BoardEventNotesUpdated { - notes, err := unmarshalSlice[dto.Note](event.Data) - if err != nil { - logger.Get().Errorw("unable to parse notesUpdated in event filter", "board", boardSubscription.boardSettings.ID, "session", userID, "err", err) - } - - if isMod { - boardSubscription.boardNotes = notes - return event - } - - filteredNotes := filterNotes(notes, userID, boardSubscription.boardSettings, boardSubscription.boardColumns) - ret := realtime.BoardEvent{ - Type: event.Type, - Data: filteredNotes, - } - - return &ret + if isMod { + bs.boardSettings = boardSettings + event.Data = boardSettings + return event, true + } else { + return event, true // after this event, a syncNotes event is triggered from the board service } +} - if event.Type == realtime.BoardEventBoardUpdated { - boardSettings, err := unmarshal[dto.Board](event.Data) - if err != nil { - logger.Get().Errorw("unable to parse boardUpdated in event filter", "board", boardSubscription.boardSettings.ID, "session", userID, "err", err) - } - if isMod { - boardSubscription.boardSettings = boardSettings - event.Data = boardSettings - return event - } - return event // after this event, a syncNotes event is triggered from the board service +func (bs *BoardSubscription) votesDeleted(event *realtime.BoardEvent, userID uuid.UUID) (*realtime.BoardEvent, bool) { + //filter deleted votes after user + votings, err := technical_helper.UnmarshalSlice[dto.Vote](event.Data) + if err != nil { + logger.Get().Errorw("unable to parse deleteVotes in event filter", "board", bs.boardSettings.ID, "session", userID, "err", err) + return nil, false } - if event.Type == realtime.BoardEventVotingUpdated { - voting, err := unmarshal[VotingUpdated](event.Data) - if err != nil { - logger.Get().Errorw("unable to parse votingUpdated in event filter", "board", boardSubscription.boardSettings.ID, "session", userID, "err", err) - } - if isMod { - return event - } - if voting.Voting.Status != types.VotingStatusClosed { - return event - } - - filteredVoting := filterVotingUpdated(voting, userID, boardSubscription.boardSettings, boardSubscription.boardColumns) - ret := realtime.BoardEvent{ - Type: event.Type, - Data: filteredVoting, - } - return &ret + ret := realtime.BoardEvent{ + Type: event.Type, + Data: technical_helper.Filter[*dto.Vote](votings, func(vote *dto.Vote) bool { + return vote.User == userID + }), } - if event.Type == realtime.BoardEventNotesSync { - notes, err := unmarshalSlice[dto.Note](event.Data) - - if err != nil { - logger.Get().Errorw("unable to parse notesUpdated in event filter", "board", boardSubscription.boardSettings.ID, "session", userID, "err", err) - } + return &ret, true +} - if isMod { - boardSubscription.boardNotes = notes - return event - } +func (bs *BoardSubscription) votingUpdated(event *realtime.BoardEvent, userID uuid.UUID, isMod bool) (*realtime.BoardEvent, bool) { + voting, err := votes.UnmarshallVoteData(event.Data) + if err != nil { + logger.Get().Errorw("unable to parse votingUpdated in event filter", "board", bs.boardSettings.ID, "session", userID, "err", err) + return nil, false + } - filteredNotes := filterNotes(notes, userID, boardSubscription.boardSettings, boardSubscription.boardColumns) + if isMod { + return event, true + } else if voting.Voting.Status != types.VotingStatusClosed { + return event, true + } else { + filteredVotingNotes := voting.Notes.FilterNotesByBoardSettingsOrAuthorInformation(userID, bs.boardSettings.ShowNotesOfOtherUsers, bs.boardSettings.ShowAuthors, bs.boardColumns) + voting.Notes = filteredVotingNotes ret := realtime.BoardEvent{ Type: event.Type, - Data: filteredNotes, + Data: voting.Voting.UpdateVoting(filteredVotingNotes), } - - return &ret + return &ret, true } +} - if event.Type == realtime.BoardEventParticipantUpdated { - participant, err := unmarshal[dto.BoardSession](event.Data) - if err != nil { - logger.Get().Errorw("unable to parse participantUpdated in event filter", "board", boardSubscription.boardSettings.ID, "session", userID, "err", err) - } - - if isMod { - // Cache the changes of when a participant got updated - for idx, user := range boardSubscription.boardParticipants { - if user.User.ID == participant.User.ID { - boardSubscription.boardParticipants[idx] = participant - } - } - } +func (bs *BoardSubscription) participantUpdated(event *realtime.BoardEvent, isMod bool) bool { + participantSession, err := technical_helper.Unmarshal[dto.BoardSession](event.Data) + if err != nil { + logger.Get().Errorw("unable to parse participantUpdated in event filter", "board", bs.boardSettings.ID, "err", err) + return false } - if event.Type == realtime.BoardEventVotesDeleted { - //filter deleted votes after user - votes, err := unmarshalSlice[dto.Vote](event.Data) - if err != nil { - logger.Get().Errorw("unable to parse deleteVotes in event filter", "board", boardSubscription.boardSettings.ID, "session", userID, "err", err) - } - userVotes := make([]*dto.Vote, 0) - for _, v := range votes { - if v.User == userID { - userVotes = append(userVotes, v) + if isMod { + // Cache the changes of when a participant got updated + updatedSessions := technical_helper.Map(bs.boardParticipants, func(boardSession *dto.BoardSession) *dto.BoardSession { + if boardSession.User.ID == participantSession.User.ID { + return participantSession + } else { + return boardSession } - } - - ret := realtime.BoardEvent{ - Type: event.Type, - Data: userVotes, - } + }) - return &ret + bs.boardParticipants = updatedSessions } - - // returns, if no filter match occured - return event + return true } func eventInitFilter(event InitEvent, clientID uuid.UUID) InitEvent { - isMod := isModerator(clientID, event.Data.BoardSessions) + isMod := session_helper.CheckSessionRole(clientID, event.Data.BoardSessions, []types.SessionRole{types.SessionRoleModerator, types.SessionRoleOwner}) // filter to only respond with the latest voting and its votes if len(event.Data.Votings) != 0 { - latestVoting := make([]*dto.Voting, 0) + latestVoting := make([]*votes.Voting, 0) activeNotes := make([]*dto.Vote, 0) latestVoting = append(latestVoting, event.Data.Votings[0]) @@ -304,10 +203,10 @@ func eventInitFilter(event InitEvent, clientID uuid.UUID) InitEvent { } // Columns - filteredColumns := filterColumns(event.Data.Columns) + filteredColumns := columnService.ColumnSlice(event.Data.Columns).FilterVisibleColumns() // Notes TODO: make to map for easier checks - filteredNotes := filterNotes(event.Data.Notes, clientID, event.Data.Board, event.Data.Columns) - notesMap := make(map[uuid.UUID]*dto.Note) + filteredNotes := notes.NoteSlice(event.Data.Notes).FilterNotesByBoardSettingsOrAuthorInformation(clientID, event.Data.Board.ShowNotesOfOtherUsers, event.Data.Board.ShowAuthors, event.Data.Columns) + notesMap := make(map[uuid.UUID]*notes.Note) for _, n := range filteredNotes { notesMap[n.ID] = n } @@ -319,10 +218,9 @@ func eventInitFilter(event InitEvent, clientID uuid.UUID) InitEvent { } } // Votings - visibleVotings := make([]*dto.Voting, 0) + visibleVotings := make([]*votes.Voting, 0) for _, v := range event.Data.Votings { - filteredVoting := filterVoting(v, filteredNotes) - visibleVotings = append(visibleVotings, filteredVoting) + visibleVotings = append(visibleVotings, v.UpdateVoting(filteredNotes).Voting) } retEvent.Data.Columns = filteredColumns diff --git a/server/src/api/event_filter_test.go b/server/src/api/event_filter_test.go index 4b9b9c99d6..aaf70c430f 100644 --- a/server/src/api/event_filter_test.go +++ b/server/src/api/event_filter_test.go @@ -1,6 +1,11 @@ package api import ( + "scrumlr.io/server/columns" + "scrumlr.io/server/notes" + "scrumlr.io/server/session_helper" + "scrumlr.io/server/technical_helper" + "scrumlr.io/server/votes" "testing" "github.com/google/uuid" @@ -20,7 +25,10 @@ var ( Role: types.SessionRoleOwner, } participantBoardSession = dto.BoardSession{ - User: dto.User{ID: uuid.New()}, + User: dto.User{ + ID: uuid.New(), + AccountType: types.AccountTypeAnonymous, + }, Role: types.SessionRoleParticipant, } boardSessions = []*dto.BoardSession{ @@ -35,45 +43,45 @@ var ( ShowNotesOfOtherUsers: true, AllowStacking: true, } - aSeeableColumn = dto.Column{ + aSeeableColumn = columns.Column{ ID: uuid.New(), Name: "Main Thread", Color: "backlog-blue", Visible: true, Index: 0, } - aModeratorNote = dto.Note{ + aModeratorNote = notes.Note{ ID: uuid.New(), Author: moderatorBoardSession.User.ID, Text: "Moderator Text", - Position: dto.NotePosition{ + Position: notes.NotePosition{ Column: aSeeableColumn.ID, Stack: uuid.NullUUID{}, Rank: 1, }, } - aParticipantNote = dto.Note{ + aParticipantNote = notes.Note{ ID: uuid.New(), Author: participantBoardSession.User.ID, Text: "User Text", - Position: dto.NotePosition{ + Position: notes.NotePosition{ Column: aSeeableColumn.ID, Stack: uuid.NullUUID{}, Rank: 0, }, } - aHiddenColumn = dto.Column{ + aHiddenColumn = columns.Column{ ID: uuid.New(), Name: "Lean Coffee", Color: "poker-purple", Visible: false, Index: 1, } - aOwnerNote = dto.Note{ + aOwnerNote = notes.Note{ ID: uuid.New(), Author: ownerBoardSession.User.ID, Text: "Owner Text", - Position: dto.NotePosition{ + Position: notes.NotePosition{ Column: aHiddenColumn.ID, Rank: 1, Stack: uuid.NullUUID{}, @@ -81,8 +89,8 @@ var ( } boardSub = &BoardSubscription{ boardParticipants: []*dto.BoardSession{&moderatorBoardSession, &ownerBoardSession, &participantBoardSession}, - boardColumns: []*dto.Column{&aSeeableColumn, &aHiddenColumn}, - boardNotes: []*dto.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, + boardColumns: []*columns.Column{&aSeeableColumn, &aHiddenColumn}, + boardNotes: []*notes.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, boardSettings: &dto.Board{ ShowNotesOfOtherUsers: false, }, @@ -93,24 +101,24 @@ var ( } columnEvent = &realtime.BoardEvent{ Type: realtime.BoardEventColumnsUpdated, - Data: []*dto.Column{&aSeeableColumn, &aHiddenColumn}, + Data: []*columns.Column{&aSeeableColumn, &aHiddenColumn}, } noteEvent = &realtime.BoardEvent{ Type: realtime.BoardEventNotesUpdated, - Data: []*dto.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, + Data: []*notes.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, } votingID = uuid.New() - votingData = &VotingUpdated{ - Notes: []*dto.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, - Voting: &dto.Voting{ + votingData = &votes.VotingUpdated{ + Notes: []*notes.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, + Voting: &votes.Voting{ ID: votingID, VoteLimit: 5, AllowMultipleVotes: true, ShowVotesOfOthers: false, Status: "CLOSED", - VotingResults: &dto.VotingResults{ + VotingResults: &votes.VotingResults{ Total: 5, - Votes: map[uuid.UUID]dto.VotingResultsPerNote{ + Votes: map[uuid.UUID]votes.VotingResultsPerNote{ aParticipantNote.ID: { Total: 2, Users: nil, @@ -127,6 +135,10 @@ var ( }, }, } + participantUpdated = &realtime.BoardEvent{ + Type: realtime.BoardEventParticipantUpdated, + Data: participantBoardSession, + } votingEvent = &realtime.BoardEvent{ Type: realtime.BoardEventVotingUpdated, Data: votingData, @@ -135,9 +147,9 @@ var ( Type: realtime.BoardEventInit, Data: dto.FullBoard{ Board: &dto.Board{}, - Columns: []*dto.Column{&aSeeableColumn, &aHiddenColumn}, - Notes: []*dto.Note{&aOwnerNote, &aModeratorNote, &aParticipantNote}, - Votings: []*dto.Voting{votingData.Voting}, + Columns: []*columns.Column{&aSeeableColumn, &aHiddenColumn}, + Notes: []*notes.Note{&aOwnerNote, &aModeratorNote, &aParticipantNote}, + Votings: []*votes.Voting{votingData.Voting}, Votes: []*dto.Vote{}, BoardSessions: boardSessions, BoardSessionRequests: []*dto.BoardSessionRequest{}, @@ -166,11 +178,53 @@ func TestEventFilter(t *testing.T) { t.Run("TestInitEventAsOwner", testInitFilterAsOwner) t.Run("TestInitEventAsModerator", testInitFilterAsModerator) t.Run("TestInitEventAsParticipant", testInitFilterAsParticipant) + t.Run("TestRaiseHandShouldBeUpdatedAfterParticipantUpdated", testRaiseHandShouldBeUpdatedAfterParticipantUpdated) + t.Run("TestParticipantUpdatedShouldHandleError", testParticipantUpdatedShouldHandleError) +} + +func testRaiseHandShouldBeUpdatedAfterParticipantUpdated(t *testing.T) { + + originalParticipantSession := technical_helper.Filter(boardSub.boardParticipants, func(session *dto.BoardSession) bool { + return session.User.AccountType == types.AccountTypeAnonymous + })[0] + + updateEvent := &realtime.BoardEvent{ + Type: realtime.BoardEventParticipantUpdated, + Data: dto.BoardSession{ + RaisedHand: true, + User: dto.User{ + ID: originalParticipantSession.User.ID, + AccountType: types.AccountTypeAnonymous, + }, + Role: types.SessionRoleParticipant, + }, + } + + isUpdated := boardSub.participantUpdated(updateEvent, true) + + updatedParticipantSession := technical_helper.Filter(boardSub.boardParticipants, func(session *dto.BoardSession) bool { + return session.User.AccountType == types.AccountTypeAnonymous + })[0] + + assert.Equal(t, true, isUpdated) + assert.Equal(t, false, originalParticipantSession.RaisedHand) + assert.Equal(t, true, updatedParticipantSession.RaisedHand) +} + +func testParticipantUpdatedShouldHandleError(t *testing.T) { + + updateEvent := &realtime.BoardEvent{ + Type: realtime.BoardEventParticipantUpdated, + Data: "SHOULD FAIL", + } + + isUpdated := boardSub.participantUpdated(updateEvent, true) + assert.Equal(t, false, isUpdated) } func testIsModModerator(t *testing.T) { - isMod := isModerator(moderatorBoardSession.User.ID, boardSessions) + isMod := session_helper.CheckSessionRole(moderatorBoardSession.User.ID, boardSessions, []types.SessionRole{types.SessionRoleModerator, types.SessionRoleOwner}) assert.NotNil(t, isMod) assert.True(t, isMod) @@ -178,7 +232,7 @@ func testIsModModerator(t *testing.T) { } func testIsOwnerModerator(t *testing.T) { - isMod := isModerator(ownerBoardSession.User.ID, boardSessions) + isMod := session_helper.CheckSessionRole(ownerBoardSession.User.ID, boardSessions, []types.SessionRole{types.SessionRoleModerator, types.SessionRoleOwner}) assert.NotNil(t, isMod) assert.True(t, isMod) @@ -186,14 +240,14 @@ func testIsOwnerModerator(t *testing.T) { } func testIsParticipantModerator(t *testing.T) { - isMod := isModerator(participantBoardSession.User.ID, boardSessions) + isMod := session_helper.CheckSessionRole(participantBoardSession.User.ID, boardSessions, []types.SessionRole{types.SessionRoleModerator, types.SessionRoleOwner}) assert.NotNil(t, isMod) assert.False(t, isMod) } func testIsUnknownUuidModerator(t *testing.T) { - isMod := isModerator(uuid.New(), boardSessions) + isMod := session_helper.CheckSessionRole(uuid.New(), boardSessions, []types.SessionRole{types.SessionRoleModerator, types.SessionRoleOwner}) assert.NotNil(t, isMod) assert.False(t, isMod) @@ -201,7 +255,7 @@ func testIsUnknownUuidModerator(t *testing.T) { func testParseBoardSettingsData(t *testing.T) { expectedBoardSettings := boardSettings - actualBoardSettings, err := unmarshal[dto.Board](boardEvent.Data) + actualBoardSettings, err := technical_helper.Unmarshal[dto.Board](boardEvent.Data) assert.Nil(t, err) assert.NotNil(t, actualBoardSettings) @@ -209,8 +263,8 @@ func testParseBoardSettingsData(t *testing.T) { } func testParseColumnData(t *testing.T) { - expectedColumns := []*dto.Column{&aSeeableColumn, &aHiddenColumn} - actualColumns, err := unmarshalSlice[dto.Column](columnEvent.Data) + expectedColumns := []*columns.Column{&aSeeableColumn, &aHiddenColumn} + actualColumns, err := technical_helper.UnmarshalSlice[columns.Column](columnEvent.Data) assert.Nil(t, err) assert.NotNil(t, actualColumns) @@ -218,8 +272,8 @@ func testParseColumnData(t *testing.T) { } func testParseNoteData(t *testing.T) { - expectedNotes := []*dto.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote} - actualNotes, err := unmarshalSlice[dto.Note](noteEvent.Data) + expectedNotes := []*notes.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote} + actualNotes, err := technical_helper.UnmarshalSlice[notes.Note](noteEvent.Data) assert.Nil(t, err) assert.NotNil(t, actualNotes) @@ -228,7 +282,7 @@ func testParseNoteData(t *testing.T) { func testParseVotingData(t *testing.T) { expectedVoting := votingData - actualVoting, err := unmarshal[VotingUpdated](votingEvent.Data) + actualVoting, err := technical_helper.Unmarshal[votes.VotingUpdated](votingEvent.Data) assert.Nil(t, err) assert.NotNil(t, actualVoting) @@ -238,7 +292,7 @@ func testParseVotingData(t *testing.T) { func testColumnFilterAsParticipant(t *testing.T) { expectedColumnEvent := &realtime.BoardEvent{ Type: realtime.BoardEventColumnsUpdated, - Data: []*dto.Column{&aSeeableColumn}, + Data: []*columns.Column{&aSeeableColumn}, } returnedColumnEvent := boardSub.eventFilter(columnEvent, participantBoardSession.User.ID) @@ -248,7 +302,7 @@ func testColumnFilterAsParticipant(t *testing.T) { func testColumnFilterAsOwner(t *testing.T) { expectedColumnEvent := &realtime.BoardEvent{ Type: realtime.BoardEventColumnsUpdated, - Data: []*dto.Column{&aSeeableColumn, &aHiddenColumn}, + Data: []*columns.Column{&aSeeableColumn, &aHiddenColumn}, } returnedColumnEvent := boardSub.eventFilter(columnEvent, ownerBoardSession.User.ID) @@ -258,7 +312,7 @@ func testColumnFilterAsOwner(t *testing.T) { func testColumnFilterAsModerator(t *testing.T) { expectedColumnEvent := &realtime.BoardEvent{ Type: realtime.BoardEventColumnsUpdated, - Data: []*dto.Column{&aSeeableColumn, &aHiddenColumn}, + Data: []*columns.Column{&aSeeableColumn, &aHiddenColumn}, } returnedColumnEvent := boardSub.eventFilter(columnEvent, moderatorBoardSession.User.ID) @@ -269,7 +323,7 @@ func testColumnFilterAsModerator(t *testing.T) { func testNoteFilterAsParticipant(t *testing.T) { expectedNoteEvent := &realtime.BoardEvent{ Type: realtime.BoardEventNotesUpdated, - Data: []*dto.Note{&aParticipantNote}, + Data: notes.NoteSlice{&aParticipantNote}, } returnedNoteEvent := boardSub.eventFilter(noteEvent, participantBoardSession.User.ID) @@ -279,7 +333,7 @@ func testNoteFilterAsParticipant(t *testing.T) { func testNoteFilterAsOwner(t *testing.T) { expectedNoteEvent := &realtime.BoardEvent{ Type: realtime.BoardEventNotesUpdated, - Data: []*dto.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, + Data: []*notes.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, } returnedNoteEvent := boardSub.eventFilter(noteEvent, ownerBoardSession.User.ID) @@ -289,7 +343,7 @@ func testNoteFilterAsOwner(t *testing.T) { func testNoteFilterAsModerator(t *testing.T) { expectedNoteEvent := &realtime.BoardEvent{ Type: realtime.BoardEventNotesUpdated, - Data: []*dto.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, + Data: []*notes.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote}, } returnedNoteEvent := boardSub.eventFilter(noteEvent, moderatorBoardSession.User.ID) @@ -319,17 +373,17 @@ func testFilterVotingUpdatedAsModerator(t *testing.T) { } func testFilterVotingUpdatedAsParticipant(t *testing.T) { - expectedVoting := &VotingUpdated{ - Notes: []*dto.Note{&aParticipantNote}, - Voting: &dto.Voting{ + expectedVoting := &votes.VotingUpdated{ + Notes: []*notes.Note{&aParticipantNote}, + Voting: &votes.Voting{ ID: votingID, VoteLimit: 5, AllowMultipleVotes: true, ShowVotesOfOthers: false, Status: "CLOSED", - VotingResults: &dto.VotingResults{ + VotingResults: &votes.VotingResults{ Total: 2, - Votes: map[uuid.UUID]dto.VotingResultsPerNote{ + Votes: map[uuid.UUID]votes.VotingResultsPerNote{ aParticipantNote.ID: { Total: 2, Users: nil, @@ -363,15 +417,15 @@ func testInitFilterAsModerator(t *testing.T) { } func testInitFilterAsParticipant(t *testing.T) { - expectedVoting := dto.Voting{ + expectedVoting := votes.Voting{ ID: votingID, VoteLimit: 5, AllowMultipleVotes: true, ShowVotesOfOthers: false, Status: "CLOSED", - VotingResults: &dto.VotingResults{ + VotingResults: &votes.VotingResults{ Total: 2, - Votes: map[uuid.UUID]dto.VotingResultsPerNote{ + Votes: map[uuid.UUID]votes.VotingResultsPerNote{ aParticipantNote.ID: { Total: 2, Users: nil, @@ -383,9 +437,9 @@ func testInitFilterAsParticipant(t *testing.T) { Type: realtime.BoardEventInit, Data: dto.FullBoard{ Board: &dto.Board{}, - Columns: []*dto.Column{&aSeeableColumn}, - Notes: []*dto.Note{&aParticipantNote}, - Votings: []*dto.Voting{&expectedVoting}, + Columns: []*columns.Column{&aSeeableColumn}, + Notes: []*notes.Note{&aParticipantNote}, + Votings: []*votes.Voting{&expectedVoting}, Votes: []*dto.Vote{}, BoardSessions: boardSessions, BoardSessionRequests: []*dto.BoardSessionRequest{}, diff --git a/server/src/api/notes_test.go b/server/src/api/notes_test.go index ec4cecda5c..a3aec58101 100644 --- a/server/src/api/notes_test.go +++ b/server/src/api/notes_test.go @@ -15,6 +15,7 @@ import ( "scrumlr.io/server/common/filter" "scrumlr.io/server/identifiers" "scrumlr.io/server/logger" + "scrumlr.io/server/notes" "scrumlr.io/server/services" "strings" "testing" @@ -25,13 +26,13 @@ type NotesMock struct { mock.Mock } -func (m *NotesMock) Create(ctx context.Context, req dto.NoteCreateRequest) (*dto.Note, error) { +func (m *NotesMock) Create(ctx context.Context, req dto.NoteCreateRequest) (*notes.Note, error) { args := m.Called(req) - return args.Get(0).(*dto.Note), args.Error(1) + return args.Get(0).(*notes.Note), args.Error(1) } -func (m *NotesMock) Get(ctx context.Context, id uuid.UUID) (*dto.Note, error) { +func (m *NotesMock) Get(ctx context.Context, id uuid.UUID) (*notes.Note, error) { args := m.Called(id) - return args.Get(0).(*dto.Note), args.Error(1) + return args.Get(0).(*notes.Note), args.Error(1) } func (m *NotesMock) Delete(ctx context.Context, req dto.NoteDeleteRequest, id uuid.UUID) error { args := m.Called(id) @@ -179,7 +180,7 @@ func (suite *NotesTestSuite) TestCreateNote() { User: userId, Text: testText, Column: colId, - }).Return(&dto.Note{ + }).Return(¬es.Note{ Text: testText, }, tt.err) @@ -238,7 +239,7 @@ func (suite *NotesTestSuite) TestGetNote() { noteID, _ := uuid.NewRandom() - mock.On("Get", noteID).Return(&dto.Note{ + mock.On("Get", noteID).Return(¬es.Note{ ID: noteID, }, tt.err) diff --git a/server/src/api/votings.go b/server/src/api/votings.go index e59c0c0981..c8ad4b5f49 100644 --- a/server/src/api/votings.go +++ b/server/src/api/votings.go @@ -4,9 +4,9 @@ import ( "fmt" "net/http" "scrumlr.io/server/common" - "scrumlr.io/server/common/dto" "scrumlr.io/server/identifiers" "scrumlr.io/server/logger" + "scrumlr.io/server/votes" "github.com/go-chi/render" "github.com/google/uuid" @@ -17,7 +17,7 @@ func (s *Server) createVoting(w http.ResponseWriter, r *http.Request) { log := logger.FromRequest(r) board := r.Context().Value(identifiers.BoardIdentifier).(uuid.UUID) - var body dto.VotingCreateRequest + var body votes.VotingCreateRequest if err := render.Decode(r, &body); err != nil { log.Errorw("Unable to decode body", "err", err) common.Throw(w, r, common.BadRequestError(err)) @@ -46,7 +46,7 @@ func (s *Server) updateVoting(w http.ResponseWriter, r *http.Request) { board := r.Context().Value(identifiers.BoardIdentifier).(uuid.UUID) id := r.Context().Value(identifiers.VotingIdentifier).(uuid.UUID) - var body dto.VotingUpdateRequest + var body votes.VotingUpdateRequest if err := render.Decode(r, &body); err != nil { log.Errorw("Unable to decode body", "err", err) common.Throw(w, r, common.BadRequestError(err)) diff --git a/server/src/api/votings_test.go b/server/src/api/votings_test.go index b53430f57c..52114a1ce7 100644 --- a/server/src/api/votings_test.go +++ b/server/src/api/votings_test.go @@ -11,6 +11,7 @@ import ( "scrumlr.io/server/identifiers" "scrumlr.io/server/logger" "scrumlr.io/server/services" + "scrumlr.io/server/votes" "strings" "testing" @@ -40,19 +41,19 @@ func (m *VotingMock) GetVotes(ctx context.Context, f filter.VoteFilter) ([]*dto. args := m.Called(f.Board, f.Voting) return args.Get(0).([]*dto.Vote), args.Error(1) } -func (m *VotingMock) Get(ctx context.Context, boardID, id uuid.UUID) (*dto.Voting, error) { +func (m *VotingMock) Get(ctx context.Context, boardID, id uuid.UUID) (*votes.Voting, error) { args := m.Called(boardID, id) - return args.Get(0).(*dto.Voting), args.Error(1) + return args.Get(0).(*votes.Voting), args.Error(1) } -func (m *VotingMock) Update(ctx context.Context, body dto.VotingUpdateRequest) (*dto.Voting, error) { +func (m *VotingMock) Update(ctx context.Context, body votes.VotingUpdateRequest) (*votes.Voting, error) { args := m.Called(body) - return args.Get(0).(*dto.Voting), args.Error(1) + return args.Get(0).(*votes.Voting), args.Error(1) } -func (m *VotingMock) Create(ctx context.Context, body dto.VotingCreateRequest) (*dto.Voting, error) { +func (m *VotingMock) Create(ctx context.Context, body votes.VotingCreateRequest) (*votes.Voting, error) { args := m.Called(body) - return args.Get(0).(*dto.Voting), args.Error(1) + return args.Get(0).(*votes.Voting), args.Error(1) } type VotingTestSuite struct { @@ -91,12 +92,12 @@ func (suite *VotingTestSuite) TestCreateVoting() { mock := new(VotingMock) boardId, _ := uuid.NewRandom() - mock.On("Create", dto.VotingCreateRequest{ + mock.On("Create", votes.VotingCreateRequest{ VoteLimit: 4, AllowMultipleVotes: false, ShowVotesOfOthers: false, Board: boardId, - }).Return(&dto.Voting{ + }).Return(&votes.Voting{ AllowMultipleVotes: false, ShowVotesOfOthers: false, }, tt.err) @@ -146,11 +147,11 @@ func (suite *VotingTestSuite) TestUpdateVoting() { boardId, _ := uuid.NewRandom() votingId, _ := uuid.NewRandom() - mock.On("Update", dto.VotingUpdateRequest{ + mock.On("Update", votes.VotingUpdateRequest{ Board: boardId, ID: votingId, Status: types.VotingStatusClosed, - }).Return(&dto.Voting{ + }).Return(&votes.Voting{ Status: types.VotingStatusClosed, }, tt.err) @@ -180,7 +181,7 @@ func (suite *VotingTestSuite) TestGetVoting() { boardId, _ := uuid.NewRandom() votingId, _ := uuid.NewRandom() - mock.On("Get", boardId, votingId).Return(&dto.Voting{ + mock.On("Get", boardId, votingId).Return(&votes.Voting{ ID: votingId, Status: types.VotingStatusClosed, }, nil) diff --git a/server/src/columns/service.go b/server/src/columns/service.go new file mode 100644 index 0000000000..a5db85858a --- /dev/null +++ b/server/src/columns/service.go @@ -0,0 +1,80 @@ +package columns + +import ( + "github.com/google/uuid" + "net/http" + "scrumlr.io/server/database" + "scrumlr.io/server/database/types" + "scrumlr.io/server/technical_helper" +) + +type ColumnSlice []*Column + +// Column is the response for all column requests. +type Column struct { + + // The column id. + ID uuid.UUID `json:"id"` + + // The column name. + Name string `json:"name"` + + // The column description. + Description string `json:"description"` + + // The column color. + Color types.Color `json:"color"` + + // The column visibility. + Visible bool `json:"visible"` + + // The column rank. + Index int `json:"index"` +} + +func (c ColumnSlice) FilterVisibleColumns() []*Column { + var visibleColumns = make([]*Column, 0, len(c)) + for _, column := range c { + if column.Visible { + visibleColumns = append(visibleColumns, column) + } + } + + return visibleColumns +} + +func UnmarshallColumnData(data interface{}) (ColumnSlice, error) { + columns, err := technical_helper.UnmarshalSlice[Column](data) + + if err != nil { + return nil, err + } + + return columns, nil +} + +func (c *Column) From(column database.Column) *Column { + c.ID = column.ID + c.Name = column.Name + c.Description = column.Description + c.Color = column.Color + c.Visible = column.Visible + c.Index = column.Index + return c +} + +func (*Column) Render(_ http.ResponseWriter, _ *http.Request) error { + return nil +} + +func Columns(columns []database.Column) []*Column { + if columns == nil { + return nil + } + + list := make([]*Column, len(columns)) + for index, column := range columns { + list[index] = new(Column).From(column) + } + return list +} diff --git a/server/src/common/dto/boards.go b/server/src/common/dto/boards.go index d5241861c2..ad6ddf020f 100644 --- a/server/src/common/dto/boards.go +++ b/server/src/common/dto/boards.go @@ -2,6 +2,9 @@ package dto import ( "net/http" + "scrumlr.io/server/columns" + "scrumlr.io/server/notes" + "scrumlr.io/server/votes" "time" "github.com/google/uuid" @@ -145,19 +148,19 @@ type BoardOverview struct { type ImportBoardRequest struct { Board *CreateBoardRequest `json:"board"` - Columns []Column `json:"columns"` - Notes []Note `json:"notes"` - Votings []Voting `json:"votings"` + Columns []columns.Column `json:"columns"` + Notes []notes.Note `json:"notes"` + Votings []votes.Voting `json:"votings"` } type FullBoard struct { Board *Board `json:"board"` BoardSessionRequests []*BoardSessionRequest `json:"requests"` BoardSessions []*BoardSession `json:"participants"` - Columns []*Column `json:"columns"` - Notes []*Note `json:"notes"` + Columns []*columns.Column `json:"columns"` + Notes []*notes.Note `json:"notes"` Reactions []*Reaction `json:"reactions"` - Votings []*Voting `json:"votings"` + Votings []*votes.Voting `json:"votings"` Votes []*Vote `json:"votes"` } @@ -165,10 +168,10 @@ func (dtoFullBoard *FullBoard) From(dbFullBoard database.FullBoard) *FullBoard { dtoFullBoard.Board = new(Board).From(dbFullBoard.Board) dtoFullBoard.BoardSessionRequests = BoardSessionRequests(dbFullBoard.BoardSessionRequests) dtoFullBoard.BoardSessions = BoardSessions(dbFullBoard.BoardSessions) - dtoFullBoard.Columns = Columns(dbFullBoard.Columns) - dtoFullBoard.Notes = Notes(dbFullBoard.Notes) + dtoFullBoard.Columns = columns.Columns(dbFullBoard.Columns) + dtoFullBoard.Notes = notes.Notes(dbFullBoard.Notes) dtoFullBoard.Reactions = Reactions(dbFullBoard.Reactions) - dtoFullBoard.Votings = Votings(dbFullBoard.Votings, dbFullBoard.Votes) + dtoFullBoard.Votings = votes.Votings(dbFullBoard.Votings, dbFullBoard.Votes) dtoFullBoard.Votes = Votes(dbFullBoard.Votes) return dtoFullBoard } diff --git a/server/src/common/dto/columns.go b/server/src/common/dto/columns.go index 10c1ed8531..3006fdc2f2 100644 --- a/server/src/common/dto/columns.go +++ b/server/src/common/dto/columns.go @@ -1,61 +1,10 @@ package dto import ( - "net/http" - "github.com/google/uuid" - "scrumlr.io/server/database" "scrumlr.io/server/database/types" ) -// Column is the response for all column requests. -type Column struct { - - // The column id. - ID uuid.UUID `json:"id"` - - // The column name. - Name string `json:"name"` - - // The column description. - Description string `json:"description"` - - // The column color. - Color types.Color `json:"color"` - - // The column visibility. - Visible bool `json:"visible"` - - // The column rank. - Index int `json:"index"` -} - -func (c *Column) From(column database.Column) *Column { - c.ID = column.ID - c.Name = column.Name - c.Description = column.Description - c.Color = column.Color - c.Visible = column.Visible - c.Index = column.Index - return c -} - -func (*Column) Render(_ http.ResponseWriter, _ *http.Request) error { - return nil -} - -func Columns(columns []database.Column) []*Column { - if columns == nil { - return nil - } - - list := make([]*Column, len(columns)) - for index, column := range columns { - list[index] = new(Column).From(column) - } - return list -} - // ColumnRequest represents the request to create a new column. type ColumnRequest struct { diff --git a/server/src/common/dto/notes.go b/server/src/common/dto/notes.go index f5c9b3cd0b..4239e7fc41 100644 --- a/server/src/common/dto/notes.go +++ b/server/src/common/dto/notes.go @@ -1,70 +1,10 @@ package dto import ( - "net/http" - "github.com/google/uuid" - "scrumlr.io/server/database" + "scrumlr.io/server/notes" ) -type NotePosition struct { - - // The column of the note. - Column uuid.UUID `json:"column"` - - // The parent note for this note in a stack. - Stack uuid.NullUUID `json:"stack"` - - // The note rank. - Rank int `json:"rank"` -} - -// Note is the response for all note requests. -type Note struct { - // The id of the note - ID uuid.UUID `json:"id"` - - // The author of the note. - Author uuid.UUID `json:"author"` - - // The text of the note. - Text string `json:"text"` - - Edited bool `json:"edited"` - - // The position of the note. - Position NotePosition `json:"position"` -} - -func (n *Note) From(note database.Note) *Note { - n.ID = note.ID - n.Author = note.Author - n.Text = note.Text - n.Position = NotePosition{ - Column: note.Column, - Stack: note.Stack, - Rank: note.Rank, - } - n.Edited = note.Edited - return n -} - -func (*Note) Render(_ http.ResponseWriter, _ *http.Request) error { - return nil -} - -func Notes(notes []database.Note) []*Note { - if notes == nil { - return nil - } - - list := make([]*Note, len(notes)) - for index, note := range notes { - list[index] = new(Note).From(note) - } - return list -} - // NoteCreateRequest represents the request to create a new note. type NoteCreateRequest struct { // The column of the note. @@ -79,8 +19,8 @@ type NoteCreateRequest struct { type NoteImportRequest struct { // The text of the note. - Text string `json:"text"` - Position NotePosition `json:"position"` + Text string `json:"text"` + Position notes.NotePosition `json:"position"` Board uuid.UUID `json:"-"` User uuid.UUID `json:"-"` @@ -93,7 +33,7 @@ type NoteUpdateRequest struct { Text *string `json:"text"` // The position of the note - Position *NotePosition `json:"position"` + Position *notes.NotePosition `json:"position"` Edited bool `json:"-"` ID uuid.UUID `json:"-"` diff --git a/server/src/notes/service.go b/server/src/notes/service.go new file mode 100644 index 0000000000..5c43c830a3 --- /dev/null +++ b/server/src/notes/service.go @@ -0,0 +1,108 @@ +package notes + +import ( + "github.com/google/uuid" + "net/http" + columnService "scrumlr.io/server/columns" + "scrumlr.io/server/database" + "scrumlr.io/server/technical_helper" +) + +type NoteSlice []*Note + +// Note is the response for all note requests. +type Note struct { + // The id of the note + ID uuid.UUID `json:"id"` + + // The author of the note. + Author uuid.UUID `json:"author"` + + // The text of the note. + Text string `json:"text"` + + Edited bool `json:"edited"` + + // The position of the note. + Position NotePosition `json:"position"` +} + +type NotePosition struct { + + // The column of the note. + Column uuid.UUID `json:"column"` + + // The parent note for this note in a stack. + Stack uuid.NullUUID `json:"stack"` + + // The note rank. + Rank int `json:"rank"` +} + +func (n NoteSlice) FilterNotesByBoardSettingsOrAuthorInformation(userID uuid.UUID, showNotesOfOtherUsers bool, showAuthors bool, columns columnService.ColumnSlice) NoteSlice { + + visibleNotes := technical_helper.Filter[*Note](n, func(note *Note) bool { + for _, column := range columns { + if (note.Position.Column == column.ID) && column.Visible { + // BoardSettings -> Remove other participant cards + if showNotesOfOtherUsers { + return true + } else if userID == note.Author { + return true + } + } + } + return false + }) + + n.hideOtherAuthors(userID, showAuthors, visibleNotes) + + return visibleNotes +} + +func UnmarshallNotaData(data interface{}) (NoteSlice, error) { + notes, err := technical_helper.UnmarshalSlice[Note](data) + + if err != nil { + return nil, err + } + + return notes, nil +} + +func (n *Note) From(note database.Note) *Note { + n.ID = note.ID + n.Author = note.Author + n.Text = note.Text + n.Position = NotePosition{ + Column: note.Column, + Stack: note.Stack, + Rank: note.Rank, + } + n.Edited = note.Edited + return n +} + +func (*Note) Render(_ http.ResponseWriter, _ *http.Request) error { + return nil +} + +func Notes(notes []database.Note) []*Note { + if notes == nil { + return nil + } + + list := make([]*Note, len(notes)) + for index, note := range notes { + list[index] = new(Note).From(note) + } + return list +} + +func (n NoteSlice) hideOtherAuthors(userID uuid.UUID, showAuthors bool, visibleNotes []*Note) { + for _, note := range visibleNotes { + if !showAuthors && note.Author != userID { + note.Author = uuid.Nil + } + } +} diff --git a/server/src/notes/service_test.go b/server/src/notes/service_test.go new file mode 100644 index 0000000000..713ae8d311 --- /dev/null +++ b/server/src/notes/service_test.go @@ -0,0 +1,66 @@ +package notes + +import ( + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + columnService "scrumlr.io/server/columns" + "testing" +) + +func TestShouldShowAllNotesBecauseBoardSettingIsSet(t *testing.T) { + userId := uuid.New() + columns := columnService.ColumnSlice{getTestColumn(true)} + notes := NoteSlice{getTestNote(uuid.New(), columns[0].ID)} + + filteredNotes := notes.FilterNotesByBoardSettingsOrAuthorInformation(userId, true, true, columns) + + assert.Equal(t, len(notes), len(filteredNotes)) +} + +func TestShouldShowNoNotesBecauseBoardSettingIsNotSetAndAuthorIdIsNotEqual(t *testing.T) { + userId := uuid.New() + columns := columnService.ColumnSlice{getTestColumn(true)} + notes := NoteSlice{getTestNote(uuid.New(), columns[0].ID)} + + filteredNotes := notes.FilterNotesByBoardSettingsOrAuthorInformation(userId, false, true, columns) + + assert.Equal(t, len(filteredNotes), 0) +} + +func TestShouldShowNotesBecauseAuthorIdIsEqual(t *testing.T) { + userId := uuid.New() + columns := columnService.ColumnSlice{getTestColumn(true)} + notes := NoteSlice{getTestNote(userId, columns[0].ID)} + + filteredNotes := notes.FilterNotesByBoardSettingsOrAuthorInformation(userId, false, true, columns) + + assert.Equal(t, len(filteredNotes), len(notes)) +} + +func getTestColumn(visible bool) *columnService.Column { + return &columnService.Column{ + ID: uuid.UUID{}, + Name: "", + Description: "", + Color: "", + Visible: visible, + Index: 0, + } +} + +func getTestNote(authorId uuid.UUID, columnId uuid.UUID) *Note { + return &Note{ + ID: uuid.New(), + Author: authorId, + Text: "lorem in ipsum", + Edited: false, + Position: NotePosition{ + Column: columnId, + Stack: uuid.NullUUID{ + UUID: uuid.New(), + Valid: true, + }, + Rank: 0, + }, + } +} diff --git a/server/src/services/boards/boards.go b/server/src/services/boards/boards.go index 0b45f0b795..37edb7646f 100644 --- a/server/src/services/boards/boards.go +++ b/server/src/services/boards/boards.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + notes2 "scrumlr.io/server/notes" "time" "github.com/google/uuid" @@ -298,7 +299,7 @@ func (s *BoardService) SyncBoardSettingChange(boardID uuid.UUID) (string, error) err = s.realtime.BroadcastToBoard(boardID, realtime.BoardEvent{ Type: realtime.BoardEventNotesSync, - Data: dto.Notes(notes), + Data: notes2.Notes(notes), }) if err != nil { err_msg = "unable to broadcast notes, following a updated board call" diff --git a/server/src/services/boards/columns.go b/server/src/services/boards/columns.go index f888f22664..e7c5dde41e 100644 --- a/server/src/services/boards/columns.go +++ b/server/src/services/boards/columns.go @@ -5,6 +5,8 @@ import ( "database/sql" "errors" "fmt" + columns2 "scrumlr.io/server/columns" + notes2 "scrumlr.io/server/notes" "github.com/google/uuid" "scrumlr.io/server/common" @@ -16,7 +18,7 @@ import ( "scrumlr.io/server/logger" ) -func (s *BoardService) CreateColumn(ctx context.Context, body dto.ColumnRequest) (*dto.Column, error) { +func (s *BoardService) CreateColumn(ctx context.Context, body dto.ColumnRequest) (*columns2.Column, error) { log := logger.FromContext(ctx) column, err := s.database.CreateColumn(database.ColumnInsert{Board: body.Board, Name: body.Name, Description: body.Description, Color: body.Color, Visible: body.Visible, Index: body.Index}) if err != nil { @@ -24,7 +26,7 @@ func (s *BoardService) CreateColumn(ctx context.Context, body dto.ColumnRequest) return nil, err } s.UpdatedColumns(body.Board) - return new(dto.Column).From(column), err + return new(columns2.Column).From(column), err } func (s *BoardService) DeleteColumn(ctx context.Context, board, column, user uuid.UUID) error { @@ -54,7 +56,7 @@ func (s *BoardService) DeleteColumn(ctx context.Context, board, column, user uui return err } -func (s *BoardService) UpdateColumn(ctx context.Context, body dto.ColumnUpdateRequest) (*dto.Column, error) { +func (s *BoardService) UpdateColumn(ctx context.Context, body dto.ColumnUpdateRequest) (*columns2.Column, error) { log := logger.FromContext(ctx) column, err := s.database.UpdateColumn(database.ColumnUpdate{ID: body.ID, Board: body.Board, Name: body.Name, Description: body.Description, Color: body.Color, Visible: body.Visible, Index: body.Index}) if err != nil { @@ -62,10 +64,10 @@ func (s *BoardService) UpdateColumn(ctx context.Context, body dto.ColumnUpdateRe return nil, err } s.UpdatedColumns(body.Board) - return new(dto.Column).From(column), err + return new(columns2.Column).From(column), err } -func (s *BoardService) GetColumn(ctx context.Context, boardID, columnID uuid.UUID) (*dto.Column, error) { +func (s *BoardService) GetColumn(ctx context.Context, boardID, columnID uuid.UUID) (*columns2.Column, error) { log := logger.FromContext(ctx) column, err := s.database.GetColumn(boardID, columnID) if err != nil { @@ -75,17 +77,17 @@ func (s *BoardService) GetColumn(ctx context.Context, boardID, columnID uuid.UUI log.Errorw("unable to get column", "board", boardID, "column", columnID, "error", err) return nil, fmt.Errorf("unable to get column: %w", err) } - return new(dto.Column).From(column), err + return new(columns2.Column).From(column), err } -func (s *BoardService) ListColumns(ctx context.Context, boardID uuid.UUID) ([]*dto.Column, error) { +func (s *BoardService) ListColumns(ctx context.Context, boardID uuid.UUID) ([]*columns2.Column, error) { log := logger.FromContext(ctx) columns, err := s.database.GetColumns(boardID) if err != nil { log.Errorw("unable to get columns", "board", boardID, "error", err) return nil, fmt.Errorf("unable to get columns: %w", err) } - return dto.Columns(columns), err + return columns2.Columns(columns), err } func (s *BoardService) UpdatedColumns(board uuid.UUID) { @@ -96,7 +98,7 @@ func (s *BoardService) UpdatedColumns(board uuid.UUID) { } _ = s.realtime.BroadcastToBoard(board, realtime.BoardEvent{ Type: realtime.BoardEventColumnsUpdated, - Data: dto.Columns(dbColumns), + Data: columns2.Columns(dbColumns), }) var err_msg string @@ -126,7 +128,7 @@ func (s *BoardService) SyncNotesOnColumnChange(boardID uuid.UUID) (string, error err = s.realtime.BroadcastToBoard(boardID, realtime.BoardEvent{ Type: realtime.BoardEventNotesSync, - Data: dto.Notes(notes), + Data: notes2.Notes(notes), }) if err != nil { err_msg = "unable to broadcast notes, following a updated columns call" @@ -146,9 +148,9 @@ func (s *BoardService) DeletedColumn(user, board, column uuid.UUID, toBeDeletedV logger.Get().Errorw("unable to retrieve notes in deleted column", "err", err) return } - eventNotes := make([]dto.Note, len(dbNotes)) + eventNotes := make([]notes2.Note, len(dbNotes)) for index, note := range dbNotes { - eventNotes[index] = *new(dto.Note).From(note) + eventNotes[index] = *new(notes2.Note).From(note) } _ = s.realtime.BroadcastToBoard(board, realtime.BoardEvent{ Type: realtime.BoardEventNotesUpdated, diff --git a/server/src/services/boards/sessions.go b/server/src/services/boards/sessions.go index e11bc1b3fa..5989b88dff 100644 --- a/server/src/services/boards/sessions.go +++ b/server/src/services/boards/sessions.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "github.com/google/uuid" + columns2 "scrumlr.io/server/columns" + notes2 "scrumlr.io/server/notes" "scrumlr.io/server/common" "scrumlr.io/server/common/dto" @@ -298,7 +300,7 @@ func (s *BoardSessionService) UpdatedSession(board uuid.UUID, session database.B } _ = s.realtime.BroadcastToBoard(board, realtime.BoardEvent{ Type: realtime.BoardEventColumnsUpdated, - Data: dto.Columns(columns), + Data: columns2.Columns(columns), }) // Sync notes @@ -308,7 +310,7 @@ func (s *BoardSessionService) UpdatedSession(board uuid.UUID, session database.B } _ = s.realtime.BroadcastToBoard(board, realtime.BoardEvent{ Type: realtime.BoardEventNotesSync, - Data: dto.Notes(notes), + Data: notes2.Notes(notes), }) } diff --git a/server/src/services/notes/notes.go b/server/src/services/notes/notes.go index e7dc85876d..53b34dadaf 100644 --- a/server/src/services/notes/notes.go +++ b/server/src/services/notes/notes.go @@ -5,6 +5,7 @@ import ( "database/sql" "scrumlr.io/server/common" "scrumlr.io/server/identifiers" + notes2 "scrumlr.io/server/notes" "scrumlr.io/server/services" "github.com/google/uuid" @@ -40,7 +41,7 @@ func NewNoteService(db DB, rt *realtime.Broker) services.Notes { return b } -func (s *NoteService) Create(ctx context.Context, body dto.NoteCreateRequest) (*dto.Note, error) { +func (s *NoteService) Create(ctx context.Context, body dto.NoteCreateRequest) (*notes2.Note, error) { log := logger.FromContext(ctx) note, err := s.database.CreateNote(database.NoteInsert{Author: body.User, Board: body.Board, Column: body.Column, Text: body.Text}) if err != nil { @@ -48,10 +49,10 @@ func (s *NoteService) Create(ctx context.Context, body dto.NoteCreateRequest) (* return nil, common.InternalServerError } s.UpdatedNotes(body.Board) - return new(dto.Note).From(note), err + return new(notes2.Note).From(note), err } -func (s *NoteService) Import(ctx context.Context, body dto.NoteImportRequest) (*dto.Note, error) { +func (s *NoteService) Import(ctx context.Context, body dto.NoteImportRequest) (*notes2.Note, error) { log := logger.FromContext(ctx) note, err := s.database.ImportNote(database.NoteImport{ @@ -68,10 +69,10 @@ func (s *NoteService) Import(ctx context.Context, body dto.NoteImportRequest) (* log.Errorw("Could not import notes", "err", err) return nil, err } - return new(dto.Note).From(note), err + return new(notes2.Note).From(note), err } -func (s *NoteService) Get(ctx context.Context, id uuid.UUID) (*dto.Note, error) { +func (s *NoteService) Get(ctx context.Context, id uuid.UUID) (*notes2.Note, error) { log := logger.FromContext(ctx) note, err := s.database.GetNote(id) if err != nil { @@ -81,10 +82,10 @@ func (s *NoteService) Get(ctx context.Context, id uuid.UUID) (*dto.Note, error) log.Errorw("unable to get note", "note", id, "error", err) return nil, common.InternalServerError } - return new(dto.Note).From(note), err + return new(notes2.Note).From(note), err } -func (s *NoteService) List(ctx context.Context, boardID uuid.UUID) ([]*dto.Note, error) { +func (s *NoteService) List(ctx context.Context, boardID uuid.UUID) ([]*notes2.Note, error) { log := logger.FromContext(ctx) notes, err := s.database.GetNotes(boardID) if err != nil { @@ -93,10 +94,10 @@ func (s *NoteService) List(ctx context.Context, boardID uuid.UUID) ([]*dto.Note, } log.Errorw("unable to get notes", "board", boardID, "error", err) } - return dto.Notes(notes), err + return notes2.Notes(notes), err } -func (s *NoteService) Update(ctx context.Context, body dto.NoteUpdateRequest) (*dto.Note, error) { +func (s *NoteService) Update(ctx context.Context, body dto.NoteUpdateRequest) (*notes2.Note, error) { log := logger.FromContext(ctx) var positionUpdate *database.NoteUpdatePosition edited := body.Text != nil @@ -120,7 +121,7 @@ func (s *NoteService) Update(ctx context.Context, body dto.NoteUpdateRequest) (* return nil, common.InternalServerError } s.UpdatedNotes(body.Board) - return new(dto.Note).From(note), err + return new(notes2.Note).From(note), err } func (s *NoteService) Delete(ctx context.Context, body dto.NoteDeleteRequest, id uuid.UUID) error { @@ -168,9 +169,9 @@ func (s *NoteService) UpdatedNotes(board uuid.UUID) { logger.Get().Errorw("unable to retrieve notes in UpdatedNotes call", "boardID", board, "err", err) } - eventNotes := make([]dto.Note, len(notes)) + eventNotes := make([]notes2.Note, len(notes)) for index, note := range notes { - eventNotes[index] = *new(dto.Note).From(note) + eventNotes[index] = *new(notes2.Note).From(note) } _ = s.realtime.BroadcastToBoard(board, realtime.BoardEvent{ diff --git a/server/src/services/notes/notes_test.go b/server/src/services/notes/notes_test.go index 8053112f82..bd8699e030 100644 --- a/server/src/services/notes/notes_test.go +++ b/server/src/services/notes/notes_test.go @@ -3,6 +3,7 @@ package notes import ( "context" "scrumlr.io/server/logger" + "scrumlr.io/server/notes" "scrumlr.io/server/identifiers" "scrumlr.io/server/realtime" @@ -107,7 +108,7 @@ func (suite *NoteServiceTestSuite) TestCreate() { publishSubject := "board." + boardID.String() publishEvent := realtime.BoardEvent{ Type: realtime.BoardEventNotesUpdated, - Data: []dto.Note{}, + Data: []notes.Note{}, } mock.On("CreateNote", database.NoteInsert{ @@ -180,7 +181,7 @@ func (suite *NoteServiceTestSuite) TestUpdateNote() { colID, _ := uuid.NewRandom() stackID := uuid.NullUUID{Valid: true, UUID: uuid.New()} txt := "Updated text" - pos := dto.NotePosition{ + pos := notes.NotePosition{ Column: colID, Rank: 0, Stack: stackID, @@ -194,7 +195,7 @@ func (suite *NoteServiceTestSuite) TestUpdateNote() { publishSubject := "board." + boardID.String() publishEvent := realtime.BoardEvent{ Type: realtime.BoardEventNotesUpdated, - Data: []dto.Note{}, + Data: []notes.Note{}, } clientMock.On("Publish", publishSubject, publishEvent).Return(nil) // Mock for the updatedNotes call, which internally calls GetNotes diff --git a/server/src/services/services.go b/server/src/services/services.go index 5e58d37299..1ac7546e8a 100644 --- a/server/src/services/services.go +++ b/server/src/services/services.go @@ -2,6 +2,9 @@ package services import ( "context" + "scrumlr.io/server/columns" + "scrumlr.io/server/notes" + "scrumlr.io/server/votes" "github.com/google/uuid" "scrumlr.io/server/common/dto" @@ -35,11 +38,11 @@ type Boards interface { DeleteTimer(ctx context.Context, id uuid.UUID) (*dto.Board, error) IncrementTimer(ctx context.Context, id uuid.UUID) (*dto.Board, error) - CreateColumn(ctx context.Context, body dto.ColumnRequest) (*dto.Column, error) + CreateColumn(ctx context.Context, body dto.ColumnRequest) (*columns.Column, error) DeleteColumn(ctx context.Context, board, column, user uuid.UUID) error - UpdateColumn(ctx context.Context, body dto.ColumnUpdateRequest) (*dto.Column, error) - GetColumn(ctx context.Context, boardID, columnID uuid.UUID) (*dto.Column, error) - ListColumns(ctx context.Context, boardID uuid.UUID) ([]*dto.Column, error) + UpdateColumn(ctx context.Context, body dto.ColumnUpdateRequest) (*columns.Column, error) + GetColumn(ctx context.Context, boardID, columnID uuid.UUID) (*columns.Column, error) + ListColumns(ctx context.Context, boardID uuid.UUID) ([]*columns.Column, error) FullBoard(ctx context.Context, boardID uuid.UUID) (*dto.FullBoard, error) BoardOverview(ctx context.Context, boardIDs []uuid.UUID, user uuid.UUID) ([]*dto.BoardOverview, error) @@ -67,11 +70,11 @@ type BoardSessions interface { } type Notes interface { - Create(ctx context.Context, body dto.NoteCreateRequest) (*dto.Note, error) - Import(ctx context.Context, body dto.NoteImportRequest) (*dto.Note, error) - Get(ctx context.Context, id uuid.UUID) (*dto.Note, error) - Update(ctx context.Context, body dto.NoteUpdateRequest) (*dto.Note, error) - List(ctx context.Context, id uuid.UUID) ([]*dto.Note, error) + Create(ctx context.Context, body dto.NoteCreateRequest) (*notes.Note, error) + Import(ctx context.Context, body dto.NoteImportRequest) (*notes.Note, error) + Get(ctx context.Context, id uuid.UUID) (*notes.Note, error) + Update(ctx context.Context, body dto.NoteUpdateRequest) (*notes.Note, error) + List(ctx context.Context, id uuid.UUID) ([]*notes.Note, error) Delete(ctx context.Context, body dto.NoteDeleteRequest, id uuid.UUID) error } @@ -84,10 +87,10 @@ type Reactions interface { } type Votings interface { - Create(ctx context.Context, body dto.VotingCreateRequest) (*dto.Voting, error) - Update(ctx context.Context, body dto.VotingUpdateRequest) (*dto.Voting, error) - Get(ctx context.Context, board, id uuid.UUID) (*dto.Voting, error) - List(ctx context.Context, board uuid.UUID) ([]*dto.Voting, error) + Create(ctx context.Context, body votes.VotingCreateRequest) (*votes.Voting, error) + Update(ctx context.Context, body votes.VotingUpdateRequest) (*votes.Voting, error) + Get(ctx context.Context, board, id uuid.UUID) (*votes.Voting, error) + List(ctx context.Context, board uuid.UUID) ([]*votes.Voting, error) AddVote(ctx context.Context, req dto.VoteRequest) (*dto.Vote, error) RemoveVote(ctx context.Context, req dto.VoteRequest) error diff --git a/server/src/services/votings/votings.go b/server/src/services/votings/votings.go index dfd1f92e80..7d1182f015 100644 --- a/server/src/services/votings/votings.go +++ b/server/src/services/votings/votings.go @@ -4,11 +4,12 @@ import ( "context" "database/sql" "errors" + notes2 "scrumlr.io/server/notes" + "scrumlr.io/server/votes" "github.com/google/uuid" "scrumlr.io/server/common" - "scrumlr.io/server/common/dto" "scrumlr.io/server/common/filter" "scrumlr.io/server/realtime" "scrumlr.io/server/services" @@ -42,7 +43,7 @@ func NewVotingService(db DB, rt *realtime.Broker) services.Votings { return b } -func (s *VotingService) Create(ctx context.Context, body dto.VotingCreateRequest) (*dto.Voting, error) { +func (s *VotingService) Create(ctx context.Context, body votes.VotingCreateRequest) (*votes.Voting, error) { log := logger.FromContext(ctx) voting, err := s.database.CreateVoting(database.VotingInsert{ Board: body.Board, @@ -61,10 +62,10 @@ func (s *VotingService) Create(ctx context.Context, body dto.VotingCreateRequest } s.CreatedVoting(body.Board, voting.ID) - return new(dto.Voting).From(voting, nil), err + return new(votes.Voting).From(voting, nil), err } -func (s *VotingService) Update(ctx context.Context, body dto.VotingUpdateRequest) (*dto.Voting, error) { +func (s *VotingService) Update(ctx context.Context, body votes.VotingUpdateRequest) (*votes.Voting, error) { log := logger.FromContext(ctx) if body.Status == types.VotingStatusOpen { return nil, common.BadRequestError(errors.New("not allowed ot change to open state")) @@ -84,19 +85,19 @@ func (s *VotingService) Update(ctx context.Context, body dto.VotingUpdateRequest } if voting.Status == types.VotingStatusClosed { - votes, err := s.getVotes(ctx, body.Board, body.ID) + receivedVotes, err := s.getVotes(ctx, body.Board, body.ID) if err != nil { log.Errorw("unable to get votes", "err", err) return nil, err } s.UpdatedVoting(body.Board, voting.ID) - return new(dto.Voting).From(voting, votes), err + return new(votes.Voting).From(voting, receivedVotes), err } s.UpdatedVoting(body.Board, voting.ID) - return new(dto.Voting).From(voting, nil), err + return new(votes.Voting).From(voting, nil), err } -func (s *VotingService) Get(ctx context.Context, boardID, id uuid.UUID) (*dto.Voting, error) { +func (s *VotingService) Get(ctx context.Context, boardID, id uuid.UUID) (*votes.Voting, error) { log := logger.FromContext(ctx) voting, _, err := s.database.GetVoting(boardID, id) if err != nil { @@ -108,24 +109,24 @@ func (s *VotingService) Get(ctx context.Context, boardID, id uuid.UUID) (*dto.Vo } if voting.Status == types.VotingStatusClosed { - votes, err := s.getVotes(ctx, boardID, id) + receivedVotes, err := s.getVotes(ctx, boardID, id) if err != nil { log.Errorw("unable to get votes", "voting", id, "error", err) return nil, err } - return new(dto.Voting).From(voting, votes), err + return new(votes.Voting).From(voting, receivedVotes), err } - return new(dto.Voting).From(voting, nil), err + return new(votes.Voting).From(voting, nil), err } -func (s *VotingService) List(ctx context.Context, boardID uuid.UUID) ([]*dto.Voting, error) { +func (s *VotingService) List(ctx context.Context, boardID uuid.UUID) ([]*votes.Voting, error) { log := logger.FromContext(ctx) - votings, votes, err := s.database.GetVotings(boardID) + votings, receivedVotes, err := s.database.GetVotings(boardID) if err != nil { log.Errorw("unable to get votings", "board", boardID, "error", err) return nil, err } - return dto.Votings(votings, votes), err + return votes.Votings(votings, receivedVotes), err } func (s *VotingService) getVotes(ctx context.Context, boardID, id uuid.UUID) ([]database.Vote, error) { @@ -147,7 +148,7 @@ func (s *VotingService) CreatedVoting(board, voting uuid.UUID) { _ = s.realtime.BroadcastToBoard(board, realtime.BoardEvent{ Type: realtime.BoardEventVotingCreated, - Data: new(dto.Voting).From(dbVoting, nil), + Data: new(votes.Voting).From(dbVoting, nil), }) } @@ -168,11 +169,11 @@ func (s *VotingService) UpdatedVoting(board, voting uuid.UUID) { _ = s.realtime.BroadcastToBoard(board, realtime.BoardEvent{ Type: realtime.BoardEventVotingUpdated, Data: struct { - Voting *dto.Voting `json:"voting"` - Notes []*dto.Note `json:"notes"` + Voting *votes.Voting `json:"voting"` + Notes []*notes2.Note `json:"notes"` }{ - Voting: new(dto.Voting).From(dbVoting, dbVotes), - Notes: dto.Notes(notes), + Voting: new(votes.Voting).From(dbVoting, dbVotes), + Notes: notes2.Notes(notes), }, }) diff --git a/server/src/services/votings/votings_test.go b/server/src/services/votings/votings_test.go index 047ef1b3ec..866def17e6 100644 --- a/server/src/services/votings/votings_test.go +++ b/server/src/services/votings/votings_test.go @@ -6,6 +6,8 @@ import ( "math/rand/v2" "scrumlr.io/server/common" "scrumlr.io/server/logger" + "scrumlr.io/server/notes" + "scrumlr.io/server/votes" "testing" "time" @@ -13,7 +15,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" - "scrumlr.io/server/common/dto" "scrumlr.io/server/common/filter" "scrumlr.io/server/database" "scrumlr.io/server/database/types" @@ -105,7 +106,7 @@ func (suite *votingServiceTestSuite) TestCreate() { var votingId uuid.UUID var boardId uuid.UUID - votingRequest := dto.VotingCreateRequest{ + votingRequest := votes.VotingCreateRequest{ Board: boardId, // boardId is nulled VoteLimit: 0, AllowMultipleVotes: false, @@ -116,7 +117,7 @@ func (suite *votingServiceTestSuite) TestCreate() { publishSubject := "board." + boardId.String() publishEvent := realtime.BoardEvent{ Type: realtime.BoardEventVotingCreated, - Data: &dto.Voting{}, + Data: &votes.Voting{}, } mock.On("CreateVoting", database.VotingInsert{ @@ -151,7 +152,7 @@ func (suite *votingServiceTestSuite) TestUpdateVoting() { err error votingStatus types.VotingStatus voting database.Voting - update *dto.Voting + update *votes.Voting }{ { name: "Voting status open", @@ -165,7 +166,7 @@ func (suite *votingServiceTestSuite) TestUpdateVoting() { err: nil, votingStatus: types.VotingStatusClosed, voting: voting, - update: new(dto.Voting).From(voting, nil), + update: new(votes.Voting).From(voting, nil), }, } @@ -182,7 +183,7 @@ func (suite *votingServiceTestSuite) TestUpdateVoting() { } s.realtime = rtMock - updateVotingRequest := dto.VotingUpdateRequest{ + updateVotingRequest := votes.VotingUpdateRequest{ ID: votingID, Board: boardId, Status: tt.votingStatus, @@ -193,10 +194,10 @@ func (suite *votingServiceTestSuite) TestUpdateVoting() { publishEvent := realtime.BoardEvent{ Type: realtime.BoardEventVotingUpdated, Data: struct { - Voting *dto.Voting `json:"voting"` - Notes []*dto.Note `json:"notes"` + Voting *votes.Voting `json:"voting"` + Notes []*notes.Note `json:"notes"` }{ - Voting: &dto.Voting{}, + Voting: &votes.Voting{}, Notes: nil, }, } diff --git a/server/src/session_helper/role_checker.go b/server/src/session_helper/role_checker.go new file mode 100644 index 0000000000..53ee11d05d --- /dev/null +++ b/server/src/session_helper/role_checker.go @@ -0,0 +1,19 @@ +package session_helper + +import ( + "github.com/google/uuid" + "scrumlr.io/server/common/dto" + "scrumlr.io/server/database/types" + "slices" +) + +func CheckSessionRole(clientID uuid.UUID, sessions []*dto.BoardSession, sessionsRoles []types.SessionRole) bool { + for _, session := range sessions { + if clientID == session.User.ID { + if slices.Contains(sessionsRoles, session.Role) { + return true + } + } + } + return false +} diff --git a/server/src/technical_helper/slice.go b/server/src/technical_helper/slice.go new file mode 100644 index 0000000000..4e8816c01f --- /dev/null +++ b/server/src/technical_helper/slice.go @@ -0,0 +1,19 @@ +package technical_helper + +func Filter[T any](ss []T, test func(T) bool) (ret []T) { + for _, s := range ss { + if test(s) { + ret = append(ret, s) + } + } + + return +} + +func Map[T, V any](ts []T, fn func(T) V) []V { + result := make([]V, len(ts)) + for i, t := range ts { + result[i] = fn(t) + } + return result +} diff --git a/server/src/technical_helper/slice_test.go b/server/src/technical_helper/slice_test.go new file mode 100644 index 0000000000..9efde3aca3 --- /dev/null +++ b/server/src/technical_helper/slice_test.go @@ -0,0 +1,33 @@ +package technical_helper + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestAllMatch(t *testing.T) { + ret := Filter[int]([]int{1, 2, 3}, func(i int) bool { + return i == 1 || i == 2 || i == 3 + }) + + assert.Equal(t, []int{1, 2, 3}, ret) +} + +func TestOneMatch(t *testing.T) { + ret := Filter[int]([]int{1, 2, 3}, func(i int) bool { + return i == 1 + }) + + assert.Equal(t, []int{1}, ret) +} + +func TestNoMatch(t *testing.T) { + + var nilSlice []int + + ret := Filter[int]([]int{1, 2, 3}, func(i int) bool { + return i == 4 + }) + + assert.Equal(t, nilSlice, ret) +} diff --git a/server/src/api/event_content_unmarshaller.go b/server/src/technical_helper/unmarshaller.go similarity index 73% rename from server/src/api/event_content_unmarshaller.go rename to server/src/technical_helper/unmarshaller.go index b5ddb8d665..7770b67795 100644 --- a/server/src/api/event_content_unmarshaller.go +++ b/server/src/technical_helper/unmarshaller.go @@ -1,8 +1,8 @@ -package api +package technical_helper import "encoding/json" -func unmarshalSlice[T any](data interface{}) ([]*T, error) { +func UnmarshalSlice[T any](data interface{}) ([]*T, error) { var result []*T b, err := json.Marshal(data) if err != nil { @@ -15,7 +15,7 @@ func unmarshalSlice[T any](data interface{}) ([]*T, error) { return result, nil } -func unmarshal[T any](data interface{}) (*T, error) { +func Unmarshal[T any](data interface{}) (*T, error) { var result *T b, err := json.Marshal(data) if err != nil { diff --git a/server/src/api/event_content_unmarshaller_test.go b/server/src/technical_helper/unmarshaller_test.go similarity index 74% rename from server/src/api/event_content_unmarshaller_test.go rename to server/src/technical_helper/unmarshaller_test.go index e98d87ea84..383a81ddd9 100644 --- a/server/src/api/event_content_unmarshaller_test.go +++ b/server/src/technical_helper/unmarshaller_test.go @@ -1,4 +1,4 @@ -package api +package technical_helper import ( "github.com/google/uuid" @@ -13,7 +13,7 @@ type TestStruct struct { func TestCorrectString(t *testing.T) { given := "TEST_STRING" - actual, err := unmarshal[string](given) + actual, err := Unmarshal[string](given) assert.NoError(t, err) assert.Equal(t, given, *actual) @@ -22,7 +22,7 @@ func TestCorrectString(t *testing.T) { func TestCorrectStringSlice(t *testing.T) { s := "TEST_STRING" given := []*string{&s} - actual, err := unmarshalSlice[string](given) + actual, err := UnmarshalSlice[string](given) assert.NoError(t, err) assert.Equal(t, given, actual) @@ -30,7 +30,7 @@ func TestCorrectStringSlice(t *testing.T) { func TestCorrectEmptySlice(t *testing.T) { var given []*string - actual, err := unmarshalSlice[string](given) + actual, err := UnmarshalSlice[string](given) assert.NoError(t, err) assert.Equal(t, given, actual) @@ -38,7 +38,7 @@ func TestCorrectEmptySlice(t *testing.T) { func TestCorrectUUID(t *testing.T) { given, _ := uuid.NewRandom() - actual, err := unmarshal[uuid.UUID](given) + actual, err := Unmarshal[uuid.UUID](given) assert.NoError(t, err) assert.Equal(t, given, *actual) @@ -46,14 +46,14 @@ func TestCorrectUUID(t *testing.T) { func TestCorrectInterfaceTypeStruct(t *testing.T) { given := "TEST_ID" - actual, err := unmarshal[TestStruct](reflect.ValueOf(TestStruct{ID: given}).Interface()) + actual, err := Unmarshal[TestStruct](reflect.ValueOf(TestStruct{ID: given}).Interface()) assert.NoError(t, err) assert.Equal(t, given, actual.ID) } func TestNil(t *testing.T) { - actual, err := unmarshal[TestStruct](nil) + actual, err := Unmarshal[TestStruct](nil) assert.NoError(t, err) assert.Nil(t, actual) diff --git a/server/src/votes/request_dto.go b/server/src/votes/request_dto.go new file mode 100644 index 0000000000..12a702e838 --- /dev/null +++ b/server/src/votes/request_dto.go @@ -0,0 +1,21 @@ +package votes + +import ( + "github.com/google/uuid" + "scrumlr.io/server/database/types" +) + +// VotingCreateRequest represents the request to create a new voting session. +type VotingCreateRequest struct { + Board uuid.UUID `json:"-"` + VoteLimit int `json:"voteLimit"` + AllowMultipleVotes bool `json:"allowMultipleVotes"` + ShowVotesOfOthers bool `json:"showVotesOfOthers"` +} + +// VotingUpdateRequest represents the request to u pdate a voting session. +type VotingUpdateRequest struct { + ID uuid.UUID `json:"-"` + Board uuid.UUID `json:"-"` + Status types.VotingStatus `json:"status"` +} diff --git a/server/src/votes/result_dto.go b/server/src/votes/result_dto.go new file mode 100644 index 0000000000..ef2d9d68e6 --- /dev/null +++ b/server/src/votes/result_dto.go @@ -0,0 +1,18 @@ +package votes + +import "github.com/google/uuid" + +type VotingResults struct { + Total int `json:"total"` + Votes map[uuid.UUID]VotingResultsPerNote `json:"votesPerNote"` +} + +type VotingResultsPerUser struct { + ID uuid.UUID `json:"id"` + Total int `json:"total"` +} + +type VotingResultsPerNote struct { + Total int `json:"total"` + Users *[]VotingResultsPerUser `json:"userVotes,omitempty"` +} diff --git a/server/src/common/dto/votings.go b/server/src/votes/service.go similarity index 67% rename from server/src/common/dto/votings.go rename to server/src/votes/service.go index c018963cf4..f3c8fc0ebe 100644 --- a/server/src/common/dto/votings.go +++ b/server/src/votes/service.go @@ -1,12 +1,19 @@ -package dto +package votes import ( "github.com/google/uuid" "net/http" "scrumlr.io/server/database" "scrumlr.io/server/database/types" + "scrumlr.io/server/notes" + "scrumlr.io/server/technical_helper" ) +type VotingUpdated struct { + Notes notes.NoteSlice `json:"notes"` + Voting *Voting `json:"voting"` +} + // Voting is the response for all voting requests. type Voting struct { ID uuid.UUID `json:"id"` @@ -17,21 +24,6 @@ type Voting struct { VotingResults *VotingResults `json:"votes,omitempty"` } -type VotingResultsPerUser struct { - ID uuid.UUID `json:"id"` - Total int `json:"total"` -} - -type VotingResultsPerNote struct { - Total int `json:"total"` - Users *[]VotingResultsPerUser `json:"userVotes,omitempty"` -} - -type VotingResults struct { - Total int `json:"total"` - Votes map[uuid.UUID]VotingResultsPerNote `json:"votesPerNote"` -} - func (v *Voting) From(voting database.Voting, votes []database.Vote) *Voting { v.ID = voting.ID v.VoteLimit = voting.VoteLimit @@ -58,19 +50,30 @@ func Votings(votings []database.Voting, votes []database.Vote) []*Voting { return list } -// VotingCreateRequest represents the request to create a new voting session. -type VotingCreateRequest struct { - Board uuid.UUID `json:"-"` - VoteLimit int `json:"voteLimit"` - AllowMultipleVotes bool `json:"allowMultipleVotes"` - ShowVotesOfOthers bool `json:"showVotesOfOthers"` +func (v *Voting) UpdateVoting(notes notes.NoteSlice) *VotingUpdated { + if v.hasNoResults() { + return &VotingUpdated{ + Notes: notes, + Voting: v, + } + } + + v.VotingResults = v.calculateVoteCounts(notes) + + return &VotingUpdated{ + Notes: notes, + Voting: v, + } } -// VotingUpdateRequest represents the request to u pdate a voting session. -type VotingUpdateRequest struct { - ID uuid.UUID `json:"-"` - Board uuid.UUID `json:"-"` - Status types.VotingStatus `json:"status"` +func UnmarshallVoteData(data interface{}) (*VotingUpdated, error) { + vote, err := technical_helper.Unmarshal[VotingUpdated](data) + + if err != nil { + return nil, err + } + + return vote, nil } func getVotingWithResults(voting database.Voting, votes []database.Vote) *VotingResults { @@ -78,7 +81,7 @@ func getVotingWithResults(voting database.Voting, votes []database.Vote) *Voting return nil } - votesForVoting := []database.Vote{} + var votesForVoting []database.Vote for _, vote := range votes { if vote.Voting == voting.ID { votesForVoting = append(votesForVoting, vote) @@ -130,3 +133,28 @@ func getVotingWithResults(voting database.Voting, votes []database.Vote) *Voting } return nil } + +func (v *Voting) calculateVoteCounts(notes notes.NoteSlice) *VotingResults { + totalVotingCount := 0 + votingResultsPerNode := &VotingResults{ + Votes: make(map[uuid.UUID]VotingResultsPerNote), + } + + for _, note := range notes { + if voteResults, ok := v.VotingResults.Votes[note.ID]; ok { // Check if note was voted on + votingResultsPerNode.Votes[note.ID] = VotingResultsPerNote{ + Total: voteResults.Total, + Users: voteResults.Users, + } + totalVotingCount += v.VotingResults.Votes[note.ID].Total + } + } + + votingResultsPerNode.Total = totalVotingCount + + return votingResultsPerNode +} + +func (v *Voting) hasNoResults() bool { + return v.VotingResults == nil +}