diff --git a/server/src/api/boards.go b/server/src/api/boards.go index 3a33156218..20ac5c03f9 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{} + visibleColumns := make([]*columns.Column, 0) for _, column := range fullBoard.Columns { if column.Visible { visibleColumns = append(visibleColumns, column) } } - visibleNotes := []*dto.Note{} + visibleNotes := make([]*notes.Note, 0) 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 a0dbf19ea8..c3ef7eba72 100644 --- a/server/src/api/event_filter.go +++ b/server/src/api/event_filter.go @@ -1,419 +1,204 @@ package api import ( - "encoding/json" "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 - } +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}) + + switch event.Type { + case realtime.BoardEventColumnsUpdated: + if updated, ok := bs.columnsUpdated(event, userID, isMod); ok { + return updated + } + case realtime.BoardEventNotesUpdated, realtime.BoardEventNotesSync: + if updated, ok := bs.notesUpdated(event, userID, isMod); ok { + return updated + } + 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 false + // returns, if no filter match occurred + return event } -func parseColumnUpdated(data interface{}) ([]*dto.Column, error) { - var ret []*dto.Column +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) - b, err := json.Marshal(data) - if err != nil { - return nil, err - } - err = json.Unmarshal(b, &ret) if err != nil { - return nil, err + logger.Get().Errorw("unable to parse columnUpdated in event filter", "board", bs.boardSettings.ID, "session", userID, "err", err) + return nil, false } - return ret, nil -} - -func parseNotesUpdated(data interface{}) ([]*dto.Note, error) { - var ret []*dto.Note - b, err := json.Marshal(data) - if err != nil { - return nil, err - } - err = json.Unmarshal(b, &ret) - if err != nil { - return nil, err + if isMod { + bs.boardColumns = columns + return event, true + } else { + return &realtime.BoardEvent{ + Type: event.Type, + Data: columns.FilterVisibleColumns(), + }, true } - return ret, nil } -func parseBoardUpdated(data interface{}) (*dto.Board, error) { - var ret *dto.Board - - b, err := json.Marshal(data) +func (bs *BoardSubscription) notesUpdated(event *realtime.BoardEvent, userID uuid.UUID, isMod bool) (*realtime.BoardEvent, bool) { + noteSlice, err := notes.UnmarshallNotaData(event.Data) if err != nil { - return nil, err + logger.Get().Errorw("unable to parse notesUpdated or eventNotesSync in event filter", "board", bs.boardSettings.ID, "session", userID, "err", err) + return nil, false } - err = json.Unmarshal(b, &ret) - if err != nil { - return nil, err - } - return ret, nil -} - -type VotingUpdated struct { - Notes []*dto.Note `json:"notes"` - Voting *dto.Voting `json:"voting"` -} - -func parseVotingUpdated(data interface{}) (*VotingUpdated, error) { - var ret *VotingUpdated - b, err := json.Marshal(data) - if err != nil { - return nil, err - } - err = json.Unmarshal(b, &ret) - if err != nil { - return nil, err + 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 } - return ret, nil } -func parseParticipantUpdated(data interface{}) (*dto.BoardSession, error) { - var ret *dto.BoardSession - - b, err := json.Marshal(data) +func (bs *BoardSubscription) boardUpdated(event *realtime.BoardEvent, isMod bool) (*realtime.BoardEvent, bool) { + boardSettings, err := technical_helper.Unmarshal[dto.Board](event.Data) if err != nil { - return nil, err + logger.Get().Errorw("unable to parse boardUpdated in event filter", "board", bs.boardSettings.ID, "err", err) + return nil, false } - err = json.Unmarshal(b, &ret) - if err != nil { - return nil, err + 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 } - return ret, nil } -func parseVotesDeleted(data interface{}) ([]*dto.Vote, error) { - var ret []*dto.Vote - - b, err := json.Marshal(data) - if err != nil { - return nil, err - } - err = json.Unmarshal(b, &ret) +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 { - return nil, err + logger.Get().Errorw("unable to parse deleteVotes in event filter", "board", bs.boardSettings.ID, "session", userID, "err", err) + return nil, false } - return ret, nil -} - -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) - } - } - - 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) - } - } - } - } - // Authors - for _, note := range visibleNotes { - if !boardSettings.ShowAuthors && note.Author != userID { - note.Author = uuid.Nil - } - } - - return visibleNotes -} - -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) - - // Safeguard if voting is terminated without any votes - if voting.Voting.VotingResults == nil { - ret := &VotingUpdated{ - Notes: filteredVotingNotes, - Voting: voting.Voting, - } - return ret - } - - // 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 - } - } - filteredVotingResult.Total = overallVoteCount - - filteredVoting.Notes = filteredVotingNotes - filteredVoting.Voting.VotingResults = filteredVotingResult - - return filteredVoting -} - -func filterVoting(voting *dto.Voting, filteredNotes []*dto.Note, userID uuid.UUID) *dto.Voting { - if voting.VotingResults == nil { - return voting - } - - 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 - } + ret := realtime.BoardEvent{ + Type: event.Type, + Data: technical_helper.Filter[*dto.Vote](votings, func(vote *dto.Vote) bool { + return vote.User == userID + }), } - filteredVotingResult.Total = overallVoteCount - filteredVoting.VotingResults = filteredVotingResult - - return filteredVoting + return &ret, true } -func (boardSubscription *BoardSubscription) eventFilter(event *realtime.BoardEvent, userID uuid.UUID) *realtime.BoardEvent { - isMod := isModerator(userID, boardSubscription.boardParticipants) - if event.Type == realtime.BoardEventColumnsUpdated { - columns, err := parseColumnUpdated(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 - } - - if event.Type == realtime.BoardEventNotesUpdated { - notes, err := parseNotesUpdated(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 event.Type == realtime.BoardEventBoardUpdated { - boardSettings, err := parseBoardUpdated(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 - } - - if event.Type == realtime.BoardEventVotingUpdated { - voting, err := parseVotingUpdated(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 +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 } - if event.Type == realtime.BoardEventNotesSync { - notes, err := parseNotesUpdated(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) + 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 := parseParticipantUpdated(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 := parseVotesDeleted(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.MapSlice(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) - activeNotes := make([]*dto.Vote, 0) + latestVoting := event.Data.Votings[0] - latestVoting = append(latestVoting, event.Data.Votings[0]) - - for _, v := range event.Data.Votes { - if v.Voting == latestVoting[0].ID { - if latestVoting[0].Status == types.VotingStatusOpen { - if v.User == clientID { - activeNotes = append(activeNotes, v) - } - } else { - activeNotes = append(activeNotes, v) - } - } - } - event.Data.Votings = latestVoting - event.Data.Votes = activeNotes + event.Data.Votings = []*votes.Voting{latestVoting} + event.Data.Votes = technical_helper.Filter[*dto.Vote](event.Data.Votes, func(vote *dto.Vote) bool { + return vote.Voting == latestVoting.ID && (latestVoting.Status != types.VotingStatusOpen || vote.User == clientID) + }) } if isMod { return event } - retEvent := InitEvent{ + 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 + } + + return InitEvent{ Type: event.Type, Data: dto.FullBoard{ Board: event.Data.Board, BoardSessions: event.Data.BoardSessions, BoardSessionRequests: event.Data.BoardSessionRequests, - Notes: nil, + Notes: filteredNotes, Reactions: event.Data.Reactions, - Columns: nil, - Votings: event.Data.Votings, - Votes: event.Data.Votes, + Columns: columnService.ColumnSlice(event.Data.Columns).FilterVisibleColumns(), + Votings: technical_helper.MapSlice[*votes.Voting, *votes.Voting](event.Data.Votings, func(voting *votes.Voting) *votes.Voting { + return voting.UpdateVoting(filteredNotes).Voting + }), + Votes: technical_helper.Filter[*dto.Vote](event.Data.Votes, func(vote *dto.Vote) bool { + _, exists := notesMap[vote.Note] + return exists + }), }, } - - // Columns - filteredColumns := filterColumns(event.Data.Columns) - // 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) - for _, n := range filteredNotes { - notesMap[n.ID] = n - } - // Votes - visibleVotes := make([]*dto.Vote, 0) - for _, vote := range event.Data.Votes { - if _, exists := notesMap[vote.Note]; exists { - visibleVotes = append(visibleVotes, vote) - } - } - // Votings - visibleVotings := make([]*dto.Voting, 0) - for _, v := range event.Data.Votings { - filteredVoting := filterVoting(v, filteredNotes, clientID) - visibleVotings = append(visibleVotings, filteredVoting) - } - - retEvent.Data.Columns = filteredColumns - retEvent.Data.Notes = filteredNotes - retEvent.Data.Votes = visibleVotes - retEvent.Data.Votings = visibleVotings - - return retEvent } diff --git a/server/src/api/event_filter_test.go b/server/src/api/event_filter_test.go index 1d599a555b..bb34c13a00 100644 --- a/server/src/api/event_filter_test.go +++ b/server/src/api/event_filter_test.go @@ -1,13 +1,18 @@ package api import ( - "testing" - "github.com/google/uuid" "github.com/stretchr/testify/assert" + "math/rand" + "scrumlr.io/server/columns" "scrumlr.io/server/common/dto" "scrumlr.io/server/database/types" + "scrumlr.io/server/notes" "scrumlr.io/server/realtime" + "scrumlr.io/server/session_helper" + "scrumlr.io/server/technical_helper" + "scrumlr.io/server/votes" + "testing" ) var ( @@ -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, @@ -135,9 +143,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 +174,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 +228,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 +236,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 +251,7 @@ func testIsUnknownUuidModerator(t *testing.T) { func testParseBoardSettingsData(t *testing.T) { expectedBoardSettings := boardSettings - actualBoardSettings, err := parseBoardUpdated(boardEvent.Data) + actualBoardSettings, err := technical_helper.Unmarshal[dto.Board](boardEvent.Data) assert.Nil(t, err) assert.NotNil(t, actualBoardSettings) @@ -209,8 +259,8 @@ func testParseBoardSettingsData(t *testing.T) { } func testParseColumnData(t *testing.T) { - expectedColumns := []*dto.Column{&aSeeableColumn, &aHiddenColumn} - actualColumns, err := parseColumnUpdated(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 +268,8 @@ func testParseColumnData(t *testing.T) { } func testParseNoteData(t *testing.T) { - expectedNotes := []*dto.Note{&aParticipantNote, &aModeratorNote, &aOwnerNote} - actualNotes, err := parseNotesUpdated(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 +278,7 @@ func testParseNoteData(t *testing.T) { func testParseVotingData(t *testing.T) { expectedVoting := votingData - actualVoting, err := parseVotingUpdated(votingEvent.Data) + actualVoting, err := technical_helper.Unmarshal[votes.VotingUpdated](votingEvent.Data) assert.Nil(t, err) assert.NotNil(t, actualVoting) @@ -238,7 +288,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 +298,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 +308,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 +319,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 +329,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 +339,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 +369,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 +413,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 +433,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{}, @@ -395,3 +445,303 @@ func testInitFilterAsParticipant(t *testing.T) { assert.Equal(t, expectedInitEvent, returnedInitEvent) } + +func TestShouldFailBecauseOfInvalidBordData(t *testing.T) { + + event := buildBoardEvent(buildBoardDto(nil, nil, "lorem ipsum", false), realtime.BoardEventBoardUpdated) + bordSubscription := buildBordSubscription(types.AccessPolicyPublic) + + _, success := bordSubscription.boardUpdated(event, false) + + assert.False(t, success) +} + +func TestShouldUpdateBordSubscriptionAsModerator(t *testing.T) { + + nameForUpdate := randSeq(10) + descriptionForUpdate := randSeq(10) + + event := buildBoardEvent(buildBoardDto(nameForUpdate, descriptionForUpdate, types.AccessPolicyPublic, false), realtime.BoardEventBoardUpdated) + bordSubscription := buildBordSubscription(types.AccessPolicyPublic) + + _, success := bordSubscription.boardUpdated(event, true) + + assert.Equal(t, nameForUpdate, bordSubscription.boardSettings.Name) + assert.Equal(t, descriptionForUpdate, bordSubscription.boardSettings.Description) + assert.True(t, success) +} + +func TestShouldNotUpdateBordSubscriptionWithoutModeratorRights(t *testing.T) { + + nameForUpdate := randSeq(10) + descriptionForUpdate := randSeq(10) + + event := buildBoardEvent(buildBoardDto(nameForUpdate, descriptionForUpdate, types.AccessPolicyPublic, false), realtime.BoardEventBoardUpdated) + bordSubscription := buildBordSubscription(types.AccessPolicyPublic) + + _, success := bordSubscription.boardUpdated(event, false) + + assert.Nil(t, bordSubscription.boardSettings.Name) + assert.Nil(t, bordSubscription.boardSettings.Description) + assert.True(t, success) +} + +func TestShouldOnlyInsertLatestVotingInInitEventStatusClosed(t *testing.T) { + + latestVotingId := uuid.New() + newestVotingId := uuid.New() + clientId := uuid.New() + + initEvent := InitEvent{ + Type: "", + Data: dto.FullBoard{ + BoardSessions: []*dto.BoardSession{ + { + Role: types.SessionRoleModerator, + User: dto.User{ID: clientId}, + }, + }, + Votings: []*votes.Voting{ + buildVoting(latestVotingId, types.VotingStatusClosed), + buildVoting(newestVotingId, types.VotingStatusClosed), + }, + Votes: []*dto.Vote{ + buildVote(latestVotingId, uuid.New(), uuid.New()), + buildVote(newestVotingId, uuid.New(), uuid.New()), + }, + }, + } + + updatedInitEvent := eventInitFilter(initEvent, clientId) + + assert.Equal(t, latestVotingId, updatedInitEvent.Data.Votes[0].Voting) + assert.Equal(t, latestVotingId, updatedInitEvent.Data.Votings[0].ID) +} + +func TestShouldOnlyInsertLatestVotingInInitEventStatusOpen(t *testing.T) { + + latestVotingId := uuid.New() + newestVotingId := uuid.New() + clientId := uuid.New() + + initEvent := InitEvent{ + Type: "", + Data: dto.FullBoard{ + BoardSessions: []*dto.BoardSession{ + { + Role: types.SessionRoleModerator, + User: dto.User{ID: clientId}, + }, + }, + Votings: []*votes.Voting{ + buildVoting(latestVotingId, types.VotingStatusOpen), + buildVoting(newestVotingId, types.VotingStatusClosed), + }, + Votes: []*dto.Vote{ + buildVote(latestVotingId, clientId, uuid.New()), + buildVote(newestVotingId, uuid.New(), uuid.New()), + }, + }, + } + + updatedInitEvent := eventInitFilter(initEvent, clientId) + + assert.Equal(t, latestVotingId, updatedInitEvent.Data.Votes[0].Voting) + assert.Equal(t, latestVotingId, updatedInitEvent.Data.Votings[0].ID) +} + +func TestShouldBeEmptyVotesInInitEventBecauseIdsDiffer(t *testing.T) { + + clientId := uuid.New() + latestVotingId := uuid.New() + + orgVoting := []*votes.Voting{ + buildVoting(latestVotingId, types.VotingStatusOpen), + buildVoting(uuid.New(), types.VotingStatusClosed), + } + orgVote := []*dto.Vote{ + buildVote(uuid.New(), uuid.New(), uuid.New()), + buildVote(uuid.New(), uuid.New(), uuid.New()), + } + + initEvent := InitEvent{ + Type: "", + Data: dto.FullBoard{ + BoardSessions: []*dto.BoardSession{ + { + Role: types.SessionRoleModerator, + User: dto.User{ID: clientId}, + }, + }, + Votings: orgVoting, + Votes: orgVote, + }, + } + + updatedInitEvent := eventInitFilter(initEvent, clientId) + + assert.Empty(t, updatedInitEvent.Data.Votes) + assert.Equal(t, latestVotingId, updatedInitEvent.Data.Votings[0].ID) +} + +func TestShouldCreateNewInitEventBecauseNoModeratorRightsWithVisibleVotes(t *testing.T) { + + latestVotingId := uuid.New() + newestVotingId := uuid.New() + noteId := uuid.New() + columnId := uuid.New() + clientId := uuid.New() + nameForUpdate := randSeq(10) + descriptionForUpdate := randSeq(10) + + initEvent := InitEvent{ + Type: "", + Data: dto.FullBoard{ + Columns: []*columns.Column{buildColumn(columnId, true)}, + Board: buildBoardDto(nameForUpdate, descriptionForUpdate, types.AccessPolicyPublic, true), + Notes: []*notes.Note{buildNote(noteId, columnId)}, + BoardSessions: []*dto.BoardSession{ + { + Role: types.SessionRoleParticipant, + User: dto.User{ID: clientId}, + }, + }, + Votings: []*votes.Voting{ + buildVoting(latestVotingId, types.VotingStatusOpen), + buildVoting(newestVotingId, types.VotingStatusClosed), + }, + Votes: []*dto.Vote{ + buildVote(latestVotingId, clientId, noteId), + buildVote(newestVotingId, uuid.New(), uuid.New()), + }, + }, + } + + updatedInitEvent := eventInitFilter(initEvent, clientId) + + assert.Equal(t, noteId, updatedInitEvent.Data.Votes[0].Note) + assert.Equal(t, clientId, updatedInitEvent.Data.Votes[0].User) +} + +func TestShouldFailBecauseOfInvalidVoteData(t *testing.T) { + + event := buildBoardEvent(*buildVote(uuid.New(), uuid.New(), uuid.New()), realtime.BoardEventVotesDeleted) + bordSubscription := buildBordSubscription(types.AccessPolicyPublic) + + _, success := bordSubscription.votesDeleted(event, uuid.New()) + + assert.False(t, success) +} + +func TestShouldReturnEmptyVotesBecauseUserIdNotMatched(t *testing.T) { + + event := buildBoardEvent([]dto.Vote{*buildVote(uuid.New(), uuid.New(), uuid.New())}, realtime.BoardEventVotesDeleted) + bordSubscription := buildBordSubscription(types.AccessPolicyPublic) + + updatedBordEvent, success := bordSubscription.votesDeleted(event, uuid.New()) + + assert.True(t, success) + assert.Equal(t, 0, len(updatedBordEvent.Data.([]*dto.Vote))) +} + +func TestVotesDeleted(t *testing.T) { + + userId := uuid.New() + event := buildBoardEvent([]dto.Vote{*buildVote(uuid.New(), userId, uuid.New())}, realtime.BoardEventVotesDeleted) + bordSubscription := buildBordSubscription(types.AccessPolicyPublic) + + updatedBordEvent, success := bordSubscription.votesDeleted(event, userId) + + assert.True(t, success) + assert.Equal(t, 1, len(updatedBordEvent.Data.([]*dto.Vote))) +} + +func buildNote(id uuid.UUID, columnId uuid.UUID) *notes.Note { + return ¬es.Note{ + ID: id, + Author: uuid.New(), + Text: "lorem in ipsum", + Edited: false, + Position: notes.NotePosition{ + Column: columnId, + Stack: uuid.NullUUID{ + UUID: uuid.New(), + Valid: true, + }, + Rank: 0, + }, + } +} + +func buildColumn(id uuid.UUID, visible bool) *columns.Column { + return &columns.Column{ + ID: id, + Visible: visible, + } +} + +func buildVote(votingId uuid.UUID, userId uuid.UUID, noteId uuid.UUID) *dto.Vote { + return &dto.Vote{ + Voting: votingId, + User: userId, + Note: noteId, + } +} + +func buildVoting(id uuid.UUID, status types.VotingStatus) *votes.Voting { + return &votes.Voting{ + ID: id, + Status: status, + } +} + +func buildBordSubscription(accessPolicy types.AccessPolicy) BoardSubscription { + return BoardSubscription{ + subscription: nil, + clients: nil, + boardParticipants: nil, + boardSettings: buildBoardDto(nil, nil, accessPolicy, false), + boardColumns: nil, + boardNotes: nil, + boardReactions: nil, + } +} + +func buildBoardEvent(data interface{}, eventType realtime.BoardEventType) *realtime.BoardEvent { + return &realtime.BoardEvent{ + Type: eventType, + Data: data, + } +} + +func buildBoardDto(name *string, description *string, accessPolicy types.AccessPolicy, showNotesOfOtherUsers bool) *dto.Board { + return &dto.Board{ + ID: uuid.UUID{}, + Name: name, + Description: description, + AccessPolicy: accessPolicy, + ShowAuthors: false, + ShowNotesOfOtherUsers: showNotesOfOtherUsers, + ShowNoteReactions: false, + AllowStacking: false, + IsLocked: false, + TimerStart: nil, + TimerEnd: nil, + SharedNote: uuid.NullUUID{}, + ShowVoting: uuid.NullUUID{}, + Passphrase: nil, + Salt: nil, + } +} + +func randSeq(n int) *string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + + s := string(b) + return &s +} 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..41ace4d1cc --- /dev/null +++ b/server/src/columns/service.go @@ -0,0 +1,73 @@ +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 { + return technical_helper.Filter[*Column](c, func(column *Column) bool { + return column.Visible + }) +} + +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 + } + + return technical_helper.MapSlice[database.Column, *Column](columns, func(column database.Column) *Column { + return new(Column).From(column) + }) +} diff --git a/server/src/columns/service_test.go b/server/src/columns/service_test.go new file mode 100644 index 0000000000..ca90056133 --- /dev/null +++ b/server/src/columns/service_test.go @@ -0,0 +1,118 @@ +package columns + +import ( + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/uptrace/bun" + "math/rand" + "scrumlr.io/server/database" + "scrumlr.io/server/database/types" + "testing" +) + +func TestShouldFilterVisibleColumns(t *testing.T) { + + visibleColumnId := uuid.New() + hiddenColumnId := uuid.New() + + visibleColumns := ColumnSlice{buildColumn(visibleColumnId, true), buildColumn(hiddenColumnId, false)} + + columns := visibleColumns.FilterVisibleColumns() + + assert.Equal(t, visibleColumnId, columns[0].ID) +} + +func TestFromMapping(t *testing.T) { + + columnId := uuid.New() + + databaseColumn := database.Column{ + BaseModel: bun.BaseModel{}, + ID: uuid.New(), + Board: uuid.New(), + Name: *randSeq(10), + Description: *randSeq(10), + Color: types.ColorBacklogBlue, + Visible: false, + Index: 1, + } + + mappedColumn := buildColumn(columnId, true).From(databaseColumn) + + assert.Equal(t, databaseColumn.ID, mappedColumn.ID) + assert.Equal(t, databaseColumn.Name, mappedColumn.Name) + assert.Equal(t, databaseColumn.Description, mappedColumn.Description) + assert.Equal(t, databaseColumn.Color, mappedColumn.Color) + assert.Equal(t, databaseColumn.Visible, mappedColumn.Visible) + assert.Equal(t, databaseColumn.Index, mappedColumn.Index) +} + +func TestColumnDatabaseMapping(t *testing.T) { + + databaseColumn := database.Column{ + BaseModel: bun.BaseModel{}, + ID: uuid.New(), + Board: uuid.New(), + Name: *randSeq(10), + Description: *randSeq(10), + Color: types.ColorBacklogBlue, + Visible: false, + Index: 1, + } + + mappedColumn := Columns([]database.Column{databaseColumn})[0] + + assert.Equal(t, databaseColumn.ID, mappedColumn.ID) + assert.Equal(t, databaseColumn.Name, mappedColumn.Name) + assert.Equal(t, databaseColumn.Description, mappedColumn.Description) + assert.Equal(t, databaseColumn.Color, mappedColumn.Color) + assert.Equal(t, databaseColumn.Visible, mappedColumn.Visible) + assert.Equal(t, databaseColumn.Index, mappedColumn.Index) +} + +func TestColumnDatabaseMappingNil(t *testing.T) { + + mappedColumns := Columns(nil) + + assert.Nil(t, mappedColumns) +} + +func TestUnmarshallColumnData(t *testing.T) { + + columns := ColumnSlice{buildColumn(uuid.New(), true)} + + columnSlice, err := UnmarshallColumnData(columns) + + assert.NoError(t, err) + assert.NotEmpty(t, columnSlice) +} + +func TestShouldReturnWithErrorUnmarshallColumnData(t *testing.T) { + + column := buildColumn(uuid.New(), true) + + columnSlice, err := UnmarshallColumnData(column) + + assert.Error(t, err) + assert.Empty(t, columnSlice) +} + +func buildColumn(id uuid.UUID, visible bool) *Column { + return &Column{ + ID: id, + Visible: visible, + Color: types.ColorBacklogBlue, + } +} + +func randSeq(n int) *string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + + s := string(b) + return &s +} 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/common/dto/votings.go b/server/src/common/dto/votings.go deleted file mode 100644 index c018963cf4..0000000000 --- a/server/src/common/dto/votings.go +++ /dev/null @@ -1,132 +0,0 @@ -package dto - -import ( - "github.com/google/uuid" - "net/http" - "scrumlr.io/server/database" - "scrumlr.io/server/database/types" -) - -// Voting is the response for all voting requests. -type Voting struct { - ID uuid.UUID `json:"id"` - VoteLimit int `json:"voteLimit"` - AllowMultipleVotes bool `json:"allowMultipleVotes"` - ShowVotesOfOthers bool `json:"showVotesOfOthers"` - Status types.VotingStatus `json:"status"` - 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 - v.AllowMultipleVotes = voting.AllowMultipleVotes - v.ShowVotesOfOthers = voting.ShowVotesOfOthers - v.Status = voting.Status - v.VotingResults = getVotingWithResults(voting, votes) - return v -} - -func (*Voting) Render(_ http.ResponseWriter, _ *http.Request) error { - return nil -} - -func Votings(votings []database.Voting, votes []database.Vote) []*Voting { - if votings == nil { - return nil - } - - list := make([]*Voting, len(votings)) - for index, voting := range votings { - list[index] = new(Voting).From(voting, votes) - } - 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"` -} - -// 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 getVotingWithResults(voting database.Voting, votes []database.Vote) *VotingResults { - if voting.Status != types.VotingStatusClosed { - return nil - } - - votesForVoting := []database.Vote{} - for _, vote := range votes { - if vote.Voting == voting.ID { - votesForVoting = append(votesForVoting, vote) - } - } - - if len(votesForVoting) > 0 { - votingResult := VotingResults{Total: len(votesForVoting), Votes: map[uuid.UUID]VotingResultsPerNote{}} - totalVotePerNote := map[uuid.UUID]int{} - votesPerUser := map[uuid.UUID][]uuid.UUID{} - for _, vote := range votesForVoting { - if _, ok := totalVotePerNote[vote.Note]; ok { - totalVotePerNote[vote.Note] = totalVotePerNote[vote.Note] + 1 - votesPerUser[vote.Note] = append(votesPerUser[vote.Note], vote.User) - } else { - totalVotePerNote[vote.Note] = 1 - votesPerUser[vote.Note] = []uuid.UUID{vote.User} - } - } - - for note, total := range totalVotePerNote { - result := VotingResultsPerNote{ - Total: total, - } - if voting.ShowVotesOfOthers { - userVotes := map[uuid.UUID]int{} - for _, user := range votesPerUser[note] { - if _, ok := userVotes[user]; ok { - userVotes[user] = userVotes[user] + 1 - } else { - userVotes[user] = 1 - } - } - - var votingResultsPerUser []VotingResultsPerUser - for user, total := range userVotes { - votingResultsPerUser = append(votingResultsPerUser, VotingResultsPerUser{ - ID: user, - Total: total, - }) - } - - result.Users = &votingResultsPerUser - } - - votingResult.Votes[note] = result - } - return &votingResult - } - return nil -} 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..2773172bd9 --- /dev/null +++ b/server/src/notes/service_test.go @@ -0,0 +1,124 @@ +package notes + +import ( + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/uptrace/bun" + columnService "scrumlr.io/server/columns" + "scrumlr.io/server/database" + "testing" + "time" +) + +func TestShouldShowAllNotesBecauseBoardSettingIsSet(t *testing.T) { + userId := uuid.New() + columns := columnService.ColumnSlice{buildColumn(true)} + notes := NoteSlice{buildNote(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{buildColumn(true)} + notes := NoteSlice{buildNote(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{buildColumn(true)} + notes := NoteSlice{buildNote(userId, columns[0].ID)} + + filteredNotes := notes.FilterNotesByBoardSettingsOrAuthorInformation(userId, false, true, columns) + + assert.Equal(t, len(filteredNotes), len(notes)) +} + +func TestMapping(t *testing.T) { + databaseNote := buildDatabaseNote() + + note := Notes([]database.Note{databaseNote})[0] + + assert.Equal(t, databaseNote.ID, note.ID) + assert.Equal(t, databaseNote.Author, note.Author) + assert.Equal(t, databaseNote.Text, note.Text) + assert.Equal(t, databaseNote.Edited, note.Edited) + assert.Equal(t, databaseNote.Column, note.Position.Column) + assert.Equal(t, databaseNote.Stack, note.Position.Stack) + assert.Equal(t, databaseNote.Rank, note.Position.Rank) +} + +func TestNilMapping(t *testing.T) { + note := Notes(nil) + + assert.Nil(t, note) +} + +func TestUnmarshallNoteData(t *testing.T) { + + notes := NoteSlice{buildNote(uuid.New(), uuid.New())} + + notesSlice, err := UnmarshallNotaData(notes) + + assert.NoError(t, err) + assert.NotEmpty(t, notesSlice) +} + +func TestShouldReturnWithErrorUnmarshallColumnData(t *testing.T) { + + note := buildNote(uuid.New(), uuid.New()) + + notesSlice, err := UnmarshallNotaData(note) + + assert.Error(t, err) + assert.Empty(t, notesSlice) +} + +func buildColumn(visible bool) *columnService.Column { + return &columnService.Column{ + ID: uuid.UUID{}, + Name: "", + Description: "", + Color: "", + Visible: visible, + Index: 0, + } +} + +func buildDatabaseNote() database.Note { + return database.Note{ + BaseModel: bun.BaseModel{}, + ID: uuid.UUID{}, + CreatedAt: time.Time{}, + Author: uuid.UUID{}, + Board: uuid.UUID{}, + Column: uuid.UUID{}, + Text: "", + Stack: uuid.NullUUID{}, + Rank: 0, + Edited: false, + } +} + +func buildNote(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..1d4abd281e --- /dev/null +++ b/server/src/technical_helper/slice.go @@ -0,0 +1,23 @@ +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) + } + } + + if ret == nil { + return make([]T, 0) + } + + return +} + +func MapSlice[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..0bc67fb469 --- /dev/null +++ b/server/src/technical_helper/slice_test.go @@ -0,0 +1,63 @@ +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) { + + emptySlice := make([]int, 0) + + ret := Filter[int]([]int{1, 2, 3}, func(i int) bool { + return i == 4 + }) + + assert.Equal(t, emptySlice, ret) +} + +func TestMapNilSliceShouldProduceEmptySlice(t *testing.T) { + var nilSlice []int + + ret := MapSlice[int, int](nilSlice, func(i int) int { + return i + }) + + assert.Empty(t, ret) +} + +func TestMapEmptySliceShouldProduceEmptySlice(t *testing.T) { + emptySlice := make([]int, 0) + + ret := MapSlice[int, int](emptySlice, func(i int) int { + return i + }) + + assert.Empty(t, ret) +} + +func TestMapSliceShouldProduceMappedSlice(t *testing.T) { + slice := []int{1, 2, 3} + + ret := MapSlice[int, int](slice, func(i int) int { + return i + }) + + assert.Equal(t, slice, ret) +} diff --git a/server/src/technical_helper/unmarshaller.go b/server/src/technical_helper/unmarshaller.go new file mode 100644 index 0000000000..7770b67795 --- /dev/null +++ b/server/src/technical_helper/unmarshaller.go @@ -0,0 +1,29 @@ +package technical_helper + +import "encoding/json" + +func UnmarshalSlice[T any](data interface{}) ([]*T, error) { + var result []*T + b, err := json.Marshal(data) + if err != nil { + return nil, err + } + err = json.Unmarshal(b, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func Unmarshal[T any](data interface{}) (*T, error) { + var result *T + b, err := json.Marshal(data) + if err != nil { + return nil, err + } + err = json.Unmarshal(b, &result) + if err != nil { + return nil, err + } + return result, nil +} diff --git a/server/src/technical_helper/unmarshaller_test.go b/server/src/technical_helper/unmarshaller_test.go new file mode 100644 index 0000000000..1c8ed56658 --- /dev/null +++ b/server/src/technical_helper/unmarshaller_test.go @@ -0,0 +1,67 @@ +package technical_helper + +import ( + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +type TestStruct struct { + ID string `json:"id"` +} + +func TestCorrectString(t *testing.T) { + given := "TEST_STRING" + actual, err := Unmarshal[string](given) + + assert.NoError(t, err) + assert.Equal(t, given, *actual) +} + +func TestCorrectStringSlice(t *testing.T) { + s := "TEST_STRING" + given := []*string{&s} + actual, err := UnmarshalSlice[string](given) + + assert.NoError(t, err) + assert.Equal(t, given, actual) +} + +func TestCorrectEmptySlice(t *testing.T) { + var given []*string + actual, err := UnmarshalSlice[string](given) + + assert.NoError(t, err) + assert.Equal(t, given, actual) +} + +func TestCorrectUUID(t *testing.T) { + given, _ := uuid.NewRandom() + actual, err := Unmarshal[uuid.UUID](given) + + assert.NoError(t, err) + assert.Equal(t, given, *actual) +} + +func TestCorrectInterfaceTypeStruct(t *testing.T) { + given := "TEST_ID" + 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) + + assert.NoError(t, err) + assert.Nil(t, actual) +} + +func TestErrorWithMarshalling(t *testing.T) { + actual, err := Unmarshal[TestStruct]("lorem ipsum") + + assert.Error(t, err) + assert.Nil(t, actual) +} diff --git a/server/src/votes/dto.go b/server/src/votes/dto.go new file mode 100644 index 0000000000..b7f70e6722 --- /dev/null +++ b/server/src/votes/dto.go @@ -0,0 +1,52 @@ +package votes + +import ( + "github.com/google/uuid" + "scrumlr.io/server/database/types" + "scrumlr.io/server/notes" +) + +// 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 update a voting session. +type VotingUpdateRequest struct { + ID uuid.UUID `json:"-"` + Board uuid.UUID `json:"-"` + Status types.VotingStatus `json:"status"` +} + +// Voting is the response for all voting requests. +type Voting struct { + ID uuid.UUID `json:"id"` + VoteLimit int `json:"voteLimit"` + AllowMultipleVotes bool `json:"allowMultipleVotes"` + ShowVotesOfOthers bool `json:"showVotesOfOthers"` + Status types.VotingStatus `json:"status"` + VotingResults *VotingResults `json:"votes,omitempty"` +} + +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"` +} + +type VotingUpdated struct { + Notes notes.NoteSlice `json:"notes"` + Voting *Voting `json:"voting"` +} diff --git a/server/src/votes/service.go b/server/src/votes/service.go new file mode 100644 index 0000000000..f8478bff09 --- /dev/null +++ b/server/src/votes/service.go @@ -0,0 +1,143 @@ +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" +) + +func (v *Voting) From(voting database.Voting, votes []database.Vote) *Voting { + v.ID = voting.ID + v.VoteLimit = voting.VoteLimit + v.AllowMultipleVotes = voting.AllowMultipleVotes + v.ShowVotesOfOthers = voting.ShowVotesOfOthers + v.Status = voting.Status + v.VotingResults = getVotingWithResults(voting, votes) + return v +} + +func (*Voting) Render(_ http.ResponseWriter, _ *http.Request) error { + return nil +} + +func Votings(votings []database.Voting, votes []database.Vote) []*Voting { + if votings == nil { + return nil + } + + list := make([]*Voting, len(votings)) + for index, voting := range votings { + list[index] = new(Voting).From(voting, votes) + } + return list +} + +func (v *Voting) UpdateVoting(notes notes.NoteSlice) *VotingUpdated { + if v.hasNoResults() { + return &VotingUpdated{ + Notes: notes, + Voting: v, + } + } + + v.VotingResults = v.calculateTotalVoteCount(notes) + + return &VotingUpdated{ + Notes: notes, + Voting: v, + } +} + +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 { + if voting.Status != types.VotingStatusClosed { + return nil + } + + relevantVoting := technical_helper.Filter[database.Vote](votes, func(vote database.Vote) bool { + return vote.Voting == voting.ID + }) + + if len(relevantVoting) <= 0 { + return nil + } + + votingResult := VotingResults{Total: len(relevantVoting), Votes: map[uuid.UUID]VotingResultsPerNote{}} + totalVotePerNote := map[uuid.UUID]int{} + votesPerUser := map[uuid.UUID][]uuid.UUID{} + for _, vote := range relevantVoting { + if _, ok := totalVotePerNote[vote.Note]; ok { + totalVotePerNote[vote.Note] = totalVotePerNote[vote.Note] + 1 + votesPerUser[vote.Note] = append(votesPerUser[vote.Note], vote.User) + } else { + totalVotePerNote[vote.Note] = 1 + votesPerUser[vote.Note] = []uuid.UUID{vote.User} + } + } + + for note, total := range totalVotePerNote { + result := VotingResultsPerNote{ + Total: total, + } + if voting.ShowVotesOfOthers { + userVotes := map[uuid.UUID]int{} + for _, user := range votesPerUser[note] { + if _, ok := userVotes[user]; ok { + userVotes[user] = userVotes[user] + 1 + } else { + userVotes[user] = 1 + } + } + + var votingResultsPerUser []VotingResultsPerUser + for user, total := range userVotes { + votingResultsPerUser = append(votingResultsPerUser, VotingResultsPerUser{ + ID: user, + Total: total, + }) + } + + result.Users = &votingResultsPerUser + } + + votingResult.Votes[note] = result + } + return &votingResult +} + +func (v *Voting) calculateTotalVoteCount(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 +} diff --git a/server/src/votes/service_test.go b/server/src/votes/service_test.go new file mode 100644 index 0000000000..7eda9d31d5 --- /dev/null +++ b/server/src/votes/service_test.go @@ -0,0 +1,232 @@ +package votes + +import ( + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/uptrace/bun" + "scrumlr.io/server/database" + "scrumlr.io/server/database/types" + "scrumlr.io/server/notes" + "testing" + "time" +) + +func TestVotingWithResultsWithEmptyStructs(t *testing.T) { + + var votes []database.Vote + var voting database.Voting + + res := getVotingWithResults(voting, votes) + + assert.Nil(t, res) +} + +func TestVotingNotClosed(t *testing.T) { + var votes []database.Vote + voting := buildVoting(uuid.New(), types.VotingStatusOpen, false) + + res := getVotingWithResults(*voting, votes) + + assert.Nil(t, res) +} + +func TestVotingAndVotesIdDiffer(t *testing.T) { + + voting := buildVoting(uuid.New(), types.VotingStatusClosed, false) + votes := []database.Vote{*buildVote(uuid.New(), uuid.New(), uuid.New())} + + res := getVotingWithResults(*voting, votes) + + assert.Nil(t, res) +} + +func TestVotingAndVotesIdEqualNoUserDefined(t *testing.T) { + + voteId := uuid.New() + noteId := uuid.New() + + voting := buildVoting(voteId, types.VotingStatusClosed, false) + votes := []database.Vote{*buildVote(voteId, noteId, uuid.New())} + + res := getVotingWithResults(*voting, votes) + + assert.Equal(t, 1, res.Total) + assert.Equal(t, 1, res.Votes[noteId].Total) + assert.Nil(t, res.Votes[noteId].Users) +} + +func TestShowVotesOfOthers(t *testing.T) { + + voteId := uuid.New() + noteId := uuid.New() + userId := uuid.New() + + voting := buildVoting(voteId, types.VotingStatusClosed, true) + votes := []database.Vote{*buildVote(voteId, noteId, userId)} + + res := getVotingWithResults(*voting, votes) + + users := *res.Votes[noteId].Users + + assert.Equal(t, 1, res.Total) + assert.Equal(t, 1, res.Votes[noteId].Total) + assert.Equal(t, 1, users[0].Total) + assert.Equal(t, userId, users[0].ID) +} + +func TestMultipleVotesForDifferentNotesFromOneUser(t *testing.T) { + + voteId := uuid.New() + note1Id := uuid.New() + note2Id := uuid.New() + userId := uuid.New() + + voting := buildVoting(voteId, types.VotingStatusClosed, true) + votes := []database.Vote{*buildVote(voteId, note1Id, userId), *buildVote(voteId, note2Id, userId)} + + res := getVotingWithResults(*voting, votes) + + usersNote1 := *res.Votes[note1Id].Users + usersNote2 := *res.Votes[note2Id].Users + + assert.Equal(t, 2, res.Total) + assert.Equal(t, 1, res.Votes[note1Id].Total) + assert.Equal(t, 1, res.Votes[note2Id].Total) + assert.Equal(t, 1, usersNote1[0].Total) + assert.Equal(t, 1, usersNote2[0].Total) + assert.Equal(t, userId, usersNote1[0].ID) +} + +func TestMultipleVotesForOneNoteFromOneUser(t *testing.T) { + + voteId := uuid.New() + noteId := uuid.New() + userId := uuid.New() + + voting := buildVoting(voteId, types.VotingStatusClosed, true) + votes := []database.Vote{*buildVote(voteId, noteId, userId), *buildVote(voteId, noteId, userId)} + + res := getVotingWithResults(*voting, votes) + + users := *res.Votes[noteId].Users + + assert.Equal(t, 2, res.Total) + assert.Equal(t, 2, res.Votes[noteId].Total) + assert.Equal(t, 2, users[0].Total) + assert.Equal(t, userId, users[0].ID) +} + +func TestCalculateVoteCountsWithEmptySlice(t *testing.T) { + + var noteSlice notes.NoteSlice + var voting Voting + + votingCountResult := voting.calculateTotalVoteCount(noteSlice) + + assert.Equal(t, 0, votingCountResult.Total) + assert.Empty(t, votingCountResult.Votes) +} + +func TestCalculateVoteCountForSpecificNote(t *testing.T) { + + voteId := uuid.New() + noteId := uuid.New() + userId := uuid.New() + + noteSlice := notes.NoteSlice{buildNote(noteId)} + voting := Votings([]database.Voting{*buildVoting(voteId, types.VotingStatusClosed, true)}, []database.Vote{*buildVote(voteId, noteId, userId), *buildVote(voteId, noteId, userId)})[0] + + votingCountResult := voting.calculateTotalVoteCount(noteSlice) + + assert.Equal(t, voting.VotingResults.Total, votingCountResult.Total) + assert.Equal(t, voting.VotingResults.Votes[noteId].Total, votingCountResult.Votes[noteId].Total) + assert.Equal(t, (*voting.VotingResults.Votes[noteId].Users)[0].Total, (*votingCountResult.Votes[noteId].Users)[0].Total) +} + +func TestShouldReturnNoVotingResultsBecauseVotingIsStillOpen(t *testing.T) { + + voteId := uuid.New() + noteId := uuid.New() + + voting := Votings([]database.Voting{*buildVoting(voteId, types.VotingStatusOpen, true)}, []database.Vote{})[0] + noteSlice := notes.NoteSlice{buildNote(noteId)} + + updatedVoting := voting.UpdateVoting(noteSlice) + + assert.Nil(t, updatedVoting.Voting.VotingResults) + + assert.Equal(t, noteSlice, updatedVoting.Notes) + assert.Equal(t, voting, updatedVoting.Voting) +} + +func TestShouldReturnVotingResults(t *testing.T) { + + voteId := uuid.New() + noteId := uuid.New() + userId := uuid.New() + + voting := Votings([]database.Voting{*buildVoting(voteId, types.VotingStatusClosed, true)}, []database.Vote{*buildVote(voteId, noteId, userId), *buildVote(voteId, noteId, userId)})[0] + noteSlice := notes.NoteSlice{buildNote(noteId)} + + updatedVoting := voting.UpdateVoting(noteSlice) + + assert.NotNil(t, updatedVoting.Voting.VotingResults) + + assert.Equal(t, noteSlice, updatedVoting.Notes) + assert.Equal(t, voting, updatedVoting.Voting) +} + +func TestShouldUnmarshallVoteData(t *testing.T) { + + voteId := uuid.New() + noteId := uuid.New() + userId := uuid.New() + + voting := Votings([]database.Voting{*buildVoting(voteId, types.VotingStatusClosed, true)}, []database.Vote{*buildVote(voteId, noteId, userId), *buildVote(voteId, noteId, userId)})[0] + noteSlice := notes.NoteSlice{buildNote(noteId)} + + updatedVoting := voting.UpdateVoting(noteSlice) + _, err := UnmarshallVoteData(updatedVoting) + + assert.NoError(t, err) +} + +func TestShouldFailUnmarshallingVoteData(t *testing.T) { + + _, err := UnmarshallVoteData("lorem ipsum") + + assert.Error(t, err) +} + +func buildNote(noteId uuid.UUID) *notes.Note { + return ¬es.Note{ + ID: noteId, + Author: uuid.UUID{}, + Text: "", + Edited: false, + Position: notes.NotePosition{}, + } +} + +func buildVote(votingId uuid.UUID, noteId uuid.UUID, userId uuid.UUID) *database.Vote { + return &database.Vote{ + Voting: votingId, + BaseModel: bun.BaseModel{}, + Board: uuid.UUID{}, + User: userId, + Note: noteId, + } +} + +func buildVoting(id uuid.UUID, status types.VotingStatus, showVotesOfOthers bool) *database.Voting { + return &database.Voting{ + ID: id, + BaseModel: bun.BaseModel{}, + Board: uuid.UUID{}, + CreatedAt: time.Time{}, + VoteLimit: 0, + AllowMultipleVotes: false, + ShowVotesOfOthers: showVotesOfOthers, + Status: status, + } +}