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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 58 additions & 10 deletions backend/main/leaderboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package main
import (
"encoding/json"
"net/http"
"sort"
"testing"
"time"

"github.com/DapperCollectives/CAST/backend/main/test_utils"
"github.com/stretchr/testify/assert"
)

func TestGetCommunityLeaderboard(t *testing.T) {
func TestGetLeaderboard(t *testing.T) {
clearTable("communities")
clearTable("community_users")
clearTable("user_achievements")
Expand Down Expand Up @@ -43,7 +45,7 @@ func TestGetCommunityLeaderboard(t *testing.T) {
assert.Equal(t, expectedScore, receivedUser2Score)
}

func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) {
func TestGetLeaderboardWithEarlyVotes(t *testing.T) {
clearTable("communities")
clearTable("community_users")
clearTable("user_achievements")
Expand Down Expand Up @@ -72,7 +74,7 @@ func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) {
assert.Equal(t, expectedScore, receivedScore)
}

func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) {
func TestGetLeaderboardWithSingleStreak(t *testing.T) {
clearTable("communities")
clearTable("community_users")
clearTable("user_achievements")
Expand All @@ -83,8 +85,8 @@ func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) {
streaks := []int{3, 4}
streakBonus := 1
expectedUsers := 2
expectedUser1Score := 3 + (1 * streakBonus)
expectedUser2Score := 4 + (1 * streakBonus)
expectedScoreA := streaks[0] + (1 * streakBonus)
expectedScoreB := streaks[1] + (1 * streakBonus)

otu.GenerateSingleStreakAchievements(communityId, streaks)

Expand All @@ -94,15 +96,20 @@ func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) {
var p test_utils.PaginatedResponseWithLeaderboardUser
json.Unmarshal(response.Body.Bytes(), &p)

receivedUser1Score := p.Data[0].Score
receivedUser2Score := p.Data[1].Score
// ensure scores ordered for assert
sort.Slice(p.Data, func(i, j int) bool {
return p.Data[i].Score < p.Data[j].Score
})

receivedScoreA := p.Data[0].Score
receivedScoreB := p.Data[1].Score

assert.Equal(t, expectedUsers, len(p.Data))
assert.Equal(t, expectedUser1Score, receivedUser1Score)
assert.Equal(t, expectedUser2Score, receivedUser2Score)
assert.Equal(t, expectedScoreA, receivedScoreA)
assert.Equal(t, expectedScoreB, receivedScoreB)
}

func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) {
func TestGetLeaderboardWithMultiStreaks(t *testing.T) {
clearTable("communities")
clearTable("community_users")
clearTable("user_achievements")
Expand All @@ -129,3 +136,44 @@ func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) {
assert.Equal(t, expectedUsers, len(p.Data))
assert.Equal(t, expectedUser1Score, receivedUser1Score)
}

func TestGetLeaderboardWithWinningVote(t *testing.T) {
clearTable("communities")
clearTable("community_users")
clearTable("user_achievements")
clearTable("proposals")
clearTable("proposal_results")
clearTable("votes")
communityId := otu.AddCommunities(1)[0]
winningVoteBonus := 1

proposalId := otu.GenerateWinningVoteAchievement(communityId, "one-address-one-vote")
otu.UpdateProposalEndTime(proposalId, time.Now().UTC())
otu.GetProposalResultsAPI(proposalId)

response := otu.GetCommunityLeaderboardAPI(communityId)
checkResponseCode(t, http.StatusOK, response.Code)

var p test_utils.PaginatedResponseWithLeaderboardUser
json.Unmarshal(response.Body.Bytes(), &p)

winningUserScore := 1 + 1*winningVoteBonus
losingUserScore := 1

receivedWinners := 0
receivedLosers := 0

for _, user := range p.Data {
if user.Score == winningUserScore {
receivedWinners += 1
} else if user.Score == losingUserScore {
receivedLosers += 1
}
}

expectedWinners := 3
expectedLosers := 1

assert.Equal(t, expectedWinners, receivedWinners)
assert.Equal(t, expectedLosers, receivedLosers)
}
60 changes: 40 additions & 20 deletions backend/main/models/community_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ type CommunityUserPayload struct {
}

type UserAchievements = []struct {
Address string
NumVotes int
EarlyVote int
Streak int
Address string
NumVotes int
EarlyVote int
Streak int
WinningVote int
}

type LeaderboardUserPayload struct {
Expand Down Expand Up @@ -111,6 +112,7 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L
var leaderboardUsers = []LeaderboardUserPayload{}
var defaultEarlyVoteWeight = 1
var defaultStreakWeight = 1
var defaultWinningVoteWeight = 1

userAchievements, err := getUserAchievements(db, communityId, start, count)

Expand All @@ -121,7 +123,7 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L
for _, user := range userAchievements {
var leaderboardUser = LeaderboardUserPayload{}
leaderboardUser.Addr = user.Address
leaderboardUser.Score = user.NumVotes + (user.EarlyVote * defaultEarlyVoteWeight) + (user.Streak * defaultStreakWeight)
leaderboardUser.Score = user.NumVotes + (user.EarlyVote * defaultEarlyVoteWeight) + (user.Streak * defaultStreakWeight) + (user.WinningVote * defaultWinningVoteWeight)
leaderboardUsers = append(leaderboardUsers, leaderboardUser)
}

Expand Down Expand Up @@ -272,26 +274,44 @@ func getUserAchievements(db *s.Database, communityId int, start int, count int)
// their votes and achievements (e.g. early votes, streaks and winning choices)
// Note 1: crosstab is a postgres extension that creates a pivot table.
// Achievements are joined as columns for each user.
// Note 2: Subselect community_id not replaced properly by $1, so has been
// Note 2: Subselects community_id not replaced properly by $1, so has been
// substituted in string first.
sql := fmt.Sprintf(
`
SELECT v.addr as address, count(*) as num_votes,
CASE WHEN a.early_vote is NULL THEN 0 ELSE a.early_vote END as early_vote
FROM votes v
LEFT OUTER JOIN proposals p ON p.id = v.proposal_id
LEFT OUTER JOIN (
SELECT * FROM crosstab(
$$SELECT addr, achievement_type, count(*) FROM user_achievements
WHERE community_id = %d
GROUP BY addr, achievement_type
ORDER BY 1,2$$
) AS ct(address varchar(18), early_vote bigint)
) a ON v.addr = a.address
SELECT v.addr as address, count(*) as num_votes,
COALESCE(a.early_vote, 0) as early_vote,
COALESCE(b.streak, 0) as streak,
COALESCE(c.winning_vote, 0) as winning_vote
FROM votes v
LEFT OUTER JOIN proposals p ON p.id = v.proposal_id
LEFT OUTER JOIN (
SELECT * FROM crosstab(
$$SELECT addr, achievement_type, count(*) FROM user_achievements
WHERE community_id = %d and achievement_type = 'earlyVote'
GROUP BY addr, achievement_type
ORDER BY 1,2$$
) AS ct(address varchar(18), early_vote bigint)
) a ON v.addr = a.address
LEFT OUTER JOIN (
SELECT * FROM crosstab(
$$SELECT addr, achievement_type, count(*) FROM user_achievements
WHERE community_id = %d and achievement_type = 'streak'
GROUP BY addr, achievement_type
ORDER BY 1,2$$
) AS ct(address varchar(18), streak bigint)
) b ON v.addr = b.address
LEFT OUTER JOIN (
SELECT * FROM crosstab(
$$SELECT addr, achievement_type, count(*) FROM user_achievements
WHERE community_id = %d and achievement_type = 'winningVote'
GROUP BY addr, achievement_type
ORDER BY 1,2$$
) AS ct(address varchar(18), winning_vote bigint)
) c ON v.addr = c.address
WHERE p.community_id = $1
GROUP BY v.addr, a.early_vote
GROUP BY v.addr, a.early_vote, b.streak, c.winning_vote
LIMIT $2 OFFSET $3
`, communityId)
`, communityId, communityId, communityId)

err := pgxscan.Select(db.Context, db.Conn, &userAchievements, sql, communityId, count, start)

Expand Down
34 changes: 31 additions & 3 deletions backend/main/models/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,41 @@ func (p *Proposal) GetProposalById(db *s.Database) error {
}

func (p *Proposal) CreateProposal(db *s.Database) error {
// signaturesJSON := json.Marshal(p.Composite_signatures)
err := db.Conn.QueryRow(db.Context,
`
INSERT INTO proposals(community_id, name, choices, strategy, min_balance, max_weight, creator_addr, start_time, end_time, status, body, block_height, cid, composite_signatures)
INSERT INTO proposals(community_id,
name,
choices,
strategy,
min_balance,
max_weight,
creator_addr,
start_time,
end_time,
status,
body,
block_height,
cid,
composite_signatures
)
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at
`, p.Community_id, p.Name, p.Choices, p.Strategy, p.Min_balance, p.Max_weight, p.Creator_addr, p.Start_time, p.End_time, p.Status, p.Body, p.Block_height, p.Cid, p.Composite_signatures).Scan(&p.ID, &p.Created_at)
`,
p.Community_id,
p.Name,
p.Choices,
p.Strategy,
p.Min_balance,
p.Max_weight,
p.Creator_addr,
p.Start_time,
p.End_time,
p.Status,
p.Body,
p.Block_height,
p.Cid,
p.Composite_signatures,
).Scan(&p.ID, &p.Created_at)

return err // will be nil unless something went wrong
}
Expand Down
51 changes: 46 additions & 5 deletions backend/main/models/vote.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -206,7 +207,7 @@ func (v *Vote) GetVoteById(db *s.Database) error {
}

func (v *Vote) CreateVote(db *s.Database) error {
var defaultEarlyVoteLength = 1
var defaultEarlyVoteLength = 2 // in hours

err := createVote(db, v)
if err != nil {
Expand All @@ -221,13 +222,13 @@ func (v *Vote) CreateVote(db *s.Database) error {
isEarlyVote := v.Created_at.Before(proposal.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength)))

if isEarlyVote {
err = addEarlyVoteAchievement(db, v, proposal)
err = AddEarlyVoteAchievement(db, v, proposal)
if err != nil {
return err
}
}

err = addStreakAchievement(db, v, proposal)
err = AddStreakAchievement(db, v, proposal)
return err
}

Expand Down Expand Up @@ -358,7 +359,7 @@ func getProposal(db *s.Database, proposalId int) (Proposal, error) {
return proposal, err
}

func addEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error {
func AddEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error {

//Unique identifier ensuring there are no duplicate early vote achievements
earlyVoteDetails := fmt.Sprintf("%s:%s:%d:%d", EarlyVote, v.Addr, p.Community_id, v.Proposal_id)
Expand All @@ -378,7 +379,7 @@ func addEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error {
return nil
}

func addStreakAchievement(db *s.Database, v *Vote, p Proposal) error {
func AddStreakAchievement(db *s.Database, v *Vote, p Proposal) error {
var defaultStreakLength = 3

votingStreak, err := getUserVotingStreak(db, v.Addr, p.Community_id)
Expand All @@ -392,7 +393,14 @@ func addStreakAchievement(db *s.Database, v *Vote, p Proposal) error {
if vote.Addr != "" {
proposals = append(proposals, vote.Proposal_id)
}


if len(proposals) >= defaultStreakLength && (i == len(votingStreak)-1 || (i < len(votingStreak)-1 && votingStreak[i+1].Addr == "")) {
// ensure proposals always ordered to guarantee no duplicates
sort.Slice(proposals, func(i, j int) bool {
return proposals[i] < proposals[j]
})

//Unique identifier for current streak
currentStreakDetails := fmt.Sprintf("%s:%s:%d:%s", Streak, v.Addr, p.Community_id, strings.Trim(strings.Join(strings.Fields(fmt.Sprint(proposals)), ","), "[]"))

Expand Down Expand Up @@ -420,6 +428,39 @@ func addStreakAchievement(db *s.Database, v *Vote, p Proposal) error {
return nil
}

func AddWinningVoteAchievement(db *s.Database, votes []*VoteWithBalance, p ProposalResults, communityId int) error {
maxVotes := 0
winningChoice := ""
for k, v := range p.Results {
if v > maxVotes {
maxVotes = v
winningChoice = k
}
}
for _, v := range votes {
if v.Choice == winningChoice {
details := fmt.Sprintf("%s:%s:%d:%d", WinningVote, v.Addr, communityId, v.Proposal_id)
err := db.Conn.QueryRow(db.Context,
`
INSERT INTO user_achievements(addr, achievement_type, community_id, proposals, details)
VALUES($1, $2, $3, $4, $5)
ON CONFLICT (details)
DO NOTHING
RETURNING id
`, v.Addr, WinningVote, communityId, []int{v.Proposal_id}, details).Scan(&v.ID)

if checkErrorIgnoreNoRows(err) {
// Row already exists, so we have previously calculated
// winning achievements for this proposal
return nil
} else if err != nil {
return err
}
}
}
return nil
}

func getUserVotingStreak(db *s.Database, addr string, communityId int) ([]VotingStreak, error) {
// Determine if this vote is part of a streak
// Proposals with the user address count as a vote for that proposal
Expand Down
2 changes: 1 addition & 1 deletion backend/main/proposal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func TestUpdateProposal(t *testing.T) {
var created models.Proposal
json.Unmarshal(response.Body.Bytes(), &created)

assert.Equal(t, "active", *created.Computed_status)
assert.Equal(t, "pending", *created.Computed_status)

cancelPayload := otu.GenerateCancelProposalStruct(authorName, communityId)
response = otu.UpdateProposalAPI(p.ID, cancelPayload)
Expand Down
Loading