From 0b49f4a9ccf7fd3414044728dc6534894670dd83 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 22 Jun 2022 16:25:51 -0400 Subject: [PATCH 01/11] CAS-135: Add Leaderboard Endpoint --- backend/main/community_user_test.go | 28 ++++++++++++++++++ backend/main/models/community_users.go | 33 ++++++++++++++++++++++ backend/main/server/app.go | 30 ++++++++++++++++++++ backend/main/test_utils/community_utils.go | 14 +++++++++ 4 files changed, 105 insertions(+) diff --git a/backend/main/community_user_test.go b/backend/main/community_user_test.go index 05e8a29ea..1530424bd 100644 --- a/backend/main/community_user_test.go +++ b/backend/main/community_user_test.go @@ -127,6 +127,34 @@ func TestGetCommunityUsersByInvalidType(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, response.Code) } +func TestGetCommunityLeaderboard(t *testing.T) { + clearTable("communities") + clearTable("community_users") + clearTable("proposals") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + proposalIdA := otu.AddActiveProposals(communityId, 1)[0] + proposalIdB := otu.AddActiveProposals(communityId, 2)[0] + proposalIdC := otu.AddActiveProposals(communityId, 3)[0] + voteChoice := "a" + + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user1", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user1", proposalIdB, voteChoice)) + otu.CreateVoteAPI(proposalIdC, otu.GenerateValidVotePayload("user1", proposalIdC, voteChoice)) + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + + response := otu.GetCommunityLeaderboardAPI(communityId) + checkResponseCode(t, http.StatusOK, response.Code) + + var p test_utils.PaginatedResponseWithLeaderboardUser + json.Unmarshal(response.Body.Bytes(), &p) + + assert.Equal(t, 2, len(p.Data)) + assert.Equal(t, 3, p.Data[0].Score) + assert.Equal(t, 2, p.Data[1].Score) +} + func TestGetUserCommunities(t *testing.T) { clearTable("communities") clearTable("community_users") diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 69117f3ea..6dd52d70d 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -21,6 +21,11 @@ type CommunityUserType struct { Is_member bool `json:"isMember" validate:"required"` } +type LeaderboardUser struct { + Addr string `json:"addr" validate:"required"` + Score int `json:"score" validate:"required"` +} + type UserTypes []string var USER_TYPES = UserTypes{"member", "author", "admin"} @@ -99,6 +104,34 @@ func GetUsersForCommunityByType(db *s.Database, communityId, start, count int, u return users, totalRecords, nil } +func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUser, int, error) { + var users = []LeaderboardUser{} + err := pgxscan.Select(db.Context, db.Conn, &users, + ` + SELECT v.addr, count(*) AS score FROM votes v + JOIN proposals p ON p.id = v.proposal_id + WHERE p.community_id = $1 + GROUP BY v.addr + ORDER BY score DESC + LIMIT $2 OFFSET $3 + `, communityId, count, start) + + // If we get pgx.ErrNoRows, just return an empty array + // and obfuscate error + if err != nil && err.Error() != pgx.ErrNoRows.Error() { + return nil, 0, err + } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { + return []LeaderboardUser{}, 0, nil + } + + // Get total number of users + var totalRecords int + countSql := `SELECT COUNT(*) FROM community_users WHERE community_id = $1` + _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalRecords) + + return users, totalRecords, nil +} + func GetCommunitiesForUser(db *s.Database, addr string, start, count int) ([]UserCommunity, int, error) { var communities = []UserCommunity{} err := pgxscan.Select(db.Context, db.Conn, &communities, diff --git a/backend/main/server/app.go b/backend/main/server/app.go index 1157a95c0..01cd76ce0 100644 --- a/backend/main/server/app.go +++ b/backend/main/server/app.go @@ -218,6 +218,7 @@ func (a *App) initializeRoutes() { Methods("GET") a.Router.HandleFunc("/communities/{communityId:[0-9]+}/users/{addr:0x[a-zA-Z0-9]{16}}/{userType:[a-zA-Z]+}", a.handleRemoveUserRole). Methods("DELETE", "OPTIONS") + a.Router.HandleFunc("/communities/{communityId:[0-9]+}/leaderboard", a.handleGetCommunityLeaderboard).Methods("GET") // Utilities a.Router.HandleFunc("/accounts/admin", a.getAdminList).Methods("GET") a.Router.HandleFunc("/accounts/blocklist", a.getCommunityBlocklist).Methods("GET") @@ -1654,6 +1655,35 @@ func (a *App) handleGetCommunityUsersByType(w http.ResponseWriter, r *http.Reque respondWithJSON(w, http.StatusOK, response) } +func (a *App) handleGetCommunityLeaderboard(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + communityId, err := strconv.Atoi(vars["communityId"]) + + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid Community ID") + return + } + + count, _ := strconv.Atoi(r.FormValue("count")) + start, _ := strconv.Atoi(r.FormValue("start")) + if count > 100 || count < 1 { + count = 100 + } + if start < 0 { + start = 0 + } + + users, totalRecords, err := models.GetCommunityLeaderboard(a.DB, communityId, start, count) + if err != nil { + respondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + + response := shared.GetPaginatedResponseWithPayload(users, start, count, totalRecords) + respondWithJSON(w, http.StatusOK, response) + +} + func (a *App) handleGetUserCommunities(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) addr := vars["addr"] diff --git a/backend/main/test_utils/community_utils.go b/backend/main/test_utils/community_utils.go index 913ca0590..e472066d6 100644 --- a/backend/main/test_utils/community_utils.go +++ b/backend/main/test_utils/community_utils.go @@ -28,6 +28,14 @@ type PaginatedResponseWithUserType struct { Next int `json:"next"` } +type PaginatedResponseWithLeaderboardUser struct { + Data []models.LeaderboardUser `json:"data"` + Start int `json:"start"` + Count int `json:"count"` + TotalRecords int `json:"totalRecords"` + Next int `json:"next"` +} + var ( AdminAddr = "0xf8d6e0586b0a20c7" UserOneAddr = "0x01cf0e2f2f715450" @@ -175,6 +183,12 @@ func (otu *OverflowTestUtils) GetCommunityAPI(id int) *httptest.ResponseRecorder return response } +func (otu *OverflowTestUtils) GetCommunityLeaderboardAPI(id int) *httptest.ResponseRecorder { + req, _ := http.NewRequest("GET", "/communities/"+strconv.Itoa(id)+"/leaderboard", nil) + response := otu.ExecuteRequest(req) + return response +} + func (otu *OverflowTestUtils) GetCommunityUsersAPI(id int) *httptest.ResponseRecorder { req, _ := http.NewRequest("GET", "/communities/"+strconv.Itoa(id)+"/users", nil) response := otu.ExecuteRequest(req) From c13794746d6d0b283e9fcc55c7d757dfcdc6123d Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 28 Jun 2022 10:11:46 -0500 Subject: [PATCH 02/11] CAS-135: Add leaderboard test file and remove unnecessary comments --- backend/main/community_user_test.go | 28 --------------- backend/main/leaderboard_test.go | 37 +++++++++++++++++++ backend/main/models/community_users.go | 49 ++++++++------------------ 3 files changed, 51 insertions(+), 63 deletions(-) create mode 100644 backend/main/leaderboard_test.go diff --git a/backend/main/community_user_test.go b/backend/main/community_user_test.go index 1530424bd..05e8a29ea 100644 --- a/backend/main/community_user_test.go +++ b/backend/main/community_user_test.go @@ -127,34 +127,6 @@ func TestGetCommunityUsersByInvalidType(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, response.Code) } -func TestGetCommunityLeaderboard(t *testing.T) { - clearTable("communities") - clearTable("community_users") - clearTable("proposals") - clearTable("votes") - communityId := otu.AddCommunities(1)[0] - proposalIdA := otu.AddActiveProposals(communityId, 1)[0] - proposalIdB := otu.AddActiveProposals(communityId, 2)[0] - proposalIdC := otu.AddActiveProposals(communityId, 3)[0] - voteChoice := "a" - - otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user1", proposalIdA, voteChoice)) - otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user1", proposalIdB, voteChoice)) - otu.CreateVoteAPI(proposalIdC, otu.GenerateValidVotePayload("user1", proposalIdC, voteChoice)) - otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) - otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) - - response := otu.GetCommunityLeaderboardAPI(communityId) - checkResponseCode(t, http.StatusOK, response.Code) - - var p test_utils.PaginatedResponseWithLeaderboardUser - json.Unmarshal(response.Body.Bytes(), &p) - - assert.Equal(t, 2, len(p.Data)) - assert.Equal(t, 3, p.Data[0].Score) - assert.Equal(t, 2, p.Data[1].Score) -} - func TestGetUserCommunities(t *testing.T) { clearTable("communities") clearTable("community_users") diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go new file mode 100644 index 000000000..c5d6e4487 --- /dev/null +++ b/backend/main/leaderboard_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/DapperCollectives/CAST/backend/main/test_utils" + "github.com/stretchr/testify/assert" +) +func TestGetCommunityLeaderboard(t *testing.T) { + clearTable("communities") + clearTable("community_users") + clearTable("proposals") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + proposalIdA := otu.AddActiveProposals(communityId, 1)[0] + proposalIdB := otu.AddActiveProposals(communityId, 2)[0] + proposalIdC := otu.AddActiveProposals(communityId, 3)[0] + voteChoice := "a" + + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user1", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user1", proposalIdB, voteChoice)) + otu.CreateVoteAPI(proposalIdC, otu.GenerateValidVotePayload("user1", proposalIdC, voteChoice)) + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + + response := otu.GetCommunityLeaderboardAPI(communityId) + checkResponseCode(t, http.StatusOK, response.Code) + + var p test_utils.PaginatedResponseWithLeaderboardUser + json.Unmarshal(response.Body.Bytes(), &p) + + assert.Equal(t, 2, len(p.Data)) + assert.Equal(t, 3, p.Data[0].Score) + assert.Equal(t, 2, p.Data[1].Score) +} \ No newline at end of file diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 6dd52d70d..9517db1ec 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -64,20 +64,17 @@ func GetUsersForCommunity(db *s.Database, communityId, start, count int) ([]Comm LIMIT $2 OFFSET $3 `, communityId, count, start) - // If we get pgx.ErrNoRows, just return an empty array - // and obfuscate error if err != nil && err.Error() != pgx.ErrNoRows.Error() { return nil, 0, err } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { return []CommunityUserType{}, 0, nil } - // Get total number of users - var totalRecords int + var totalUsers int countSql := `SELECT COUNT(*) FROM (SELECT addr FROM community_users WHERE community_id = $1 group BY community_users.addr) as temp_users_addr` - _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalRecords) + _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalUsers) - return users, totalRecords, nil + return users, totalUsers, nil } func GetUsersForCommunityByType(db *s.Database, communityId, start, count int, user_type string) ([]CommunityUser, int, error) { @@ -88,20 +85,17 @@ func GetUsersForCommunityByType(db *s.Database, communityId, start, count int, u LIMIT $3 OFFSET $4 `, communityId, user_type, count, start) - // If we get pgx.ErrNoRows, just return an empty array - // and obfuscate error if err != nil && err.Error() != pgx.ErrNoRows.Error() { return nil, 0, err } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { return []CommunityUser{}, 0, nil } - // Get total number of users by type - var totalRecords int + var totalUsers int countSql := `SELECT COUNT(*) FROM community_users WHERE community_id = $1 AND user_type = $2` - _ = db.Conn.QueryRow(db.Context, countSql, communityId, user_type).Scan(&totalRecords) + _ = db.Conn.QueryRow(db.Context, countSql, communityId, user_type).Scan(&totalUsers) - return users, totalRecords, nil + return users, totalUsers, nil } func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUser, int, error) { @@ -116,20 +110,17 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L LIMIT $2 OFFSET $3 `, communityId, count, start) - // If we get pgx.ErrNoRows, just return an empty array - // and obfuscate error if err != nil && err.Error() != pgx.ErrNoRows.Error() { return nil, 0, err } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { return []LeaderboardUser{}, 0, nil } - // Get total number of users - var totalRecords int + var totalUsers int countSql := `SELECT COUNT(*) FROM community_users WHERE community_id = $1` - _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalRecords) + _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalUsers) - return users, totalRecords, nil + return users, totalUsers, nil } func GetCommunitiesForUser(db *s.Database, addr string, start, count int) ([]UserCommunity, int, error) { @@ -145,16 +136,13 @@ func GetCommunitiesForUser(db *s.Database, addr string, start, count int) ([]Use LIMIT $2 OFFSET $3 `, addr, count, start) - // If we get pgx.ErrNoRows, just return an empty array - // and obfuscate error if err != nil && err.Error() != pgx.ErrNoRows.Error() { return nil, 0, err } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { return []UserCommunity{}, 0, nil } - // Get total number of communities by user - var totalRecords int + var totalCommunities int countSql := ` SELECT COUNT(communities.id) @@ -162,9 +150,9 @@ func GetCommunitiesForUser(db *s.Database, addr string, start, count int) ([]Use LEFT JOIN community_users ON community_users.community_id = communities.id WHERE community_users.addr = $1 ` - _ = db.Conn.QueryRow(db.Context, countSql, addr).Scan(&totalRecords) + _ = db.Conn.QueryRow(db.Context, countSql, addr).Scan(&totalCommunities) - return communities, totalRecords, nil + return communities, totalCommunities, nil } func (u *CommunityUser) GetCommunityUser(db *s.Database) error { @@ -182,8 +170,6 @@ func GetAllRolesForUserInCommunity(db *s.Database, addr string, communityId int) SELECT * FROM community_users WHERE community_id = $1 AND addr = $2 `, communityId, addr) - // If we get pgx.ErrNoRows, just return an empty array - // and obfuscate error if err != nil && err.Error() != pgx.ErrNoRows.Error() { return nil, err } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { @@ -199,15 +185,13 @@ func (u *CommunityUser) Remove(db *s.Database) error { WHERE community_id = $1 AND addr = $2 AND user_type = $3 `, u.Community_id, u.Addr, u.User_type) - return err // will be nil unless something went wrong + return err } -// Create any of the 3 roles for the user that they dont have already func GrantAdminRolesToAddress(db *s.Database, communityId int, addr string) error { userTypes := UserTypes{"admin", "author", "member"} for _, role := range userTypes { userRole := CommunityUser{Addr: addr, Community_id: communityId, User_type: role} - // Check if role exists. If we throw a ErrNoRows err, create the role if err := userRole.GetCommunityUser(db); err != nil { if err := userRole.CreateCommunityUser(db); err != nil { log.Error().Err(err).Msgf("db error creating role %s for addr %s for communityId %d", role, addr, communityId) @@ -218,12 +202,10 @@ func GrantAdminRolesToAddress(db *s.Database, communityId int, addr string) erro return nil } -// Create either author or member role for the user that they dont have already func GrantAuthorRolesToAddress(db *s.Database, communityId int, addr string) error { userTypes := UserTypes{"author", "member"} for _, role := range userTypes { userRole := CommunityUser{Addr: addr, Community_id: communityId, User_type: role} - // Check if role exists. If we throw a ErrNoRows err, create the role if err := userRole.GetCommunityUser(db); err != nil { if err := userRole.CreateCommunityUser(db); err != nil { log.Error().Err(err).Msgf("db error creating role %s for addr %s for communityId %d", role, addr, communityId) @@ -242,11 +224,9 @@ func (u *CommunityUser) CreateCommunityUser(db *s.Database) error { RETURNING community_id, addr, user_type `, u.Community_id, u.Addr, u.User_type).Scan(&u.Community_id, &u.Addr, &u.User_type) - return err // will be nil unless something went wrong + return err } -// when a user creates a community, they are automatically assigned -// all roles func GrantRolesToCommunityCreator(db *s.Database, addr string, communityId int) error { for _, userType := range USER_TYPES { communityUser := CommunityUser{Addr: addr, Community_id: communityId, User_type: userType} @@ -258,7 +238,6 @@ func GrantRolesToCommunityCreator(db *s.Database, addr string, communityId int) return nil } -// Validate account's role func EnsureRoleForCommunity(db *s.Database, addr string, communityId int, userType string) error { user := CommunityUser{Addr: addr, Community_id: communityId, User_type: userType} return user.GetCommunityUser(db) From 93777c34733d90ee44ad2239113ae27c80478a48 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 28 Jun 2022 11:24:39 -0500 Subject: [PATCH 03/11] CAS-139: Add EarlyVote scoring --- backend/main/leaderboard_test.go | 31 +++++++++ backend/main/models/community_users.go | 65 ++++++++++++++----- backend/main/models/vote.go | 29 +++++++++ backend/main/test_utils/community_utils.go | 10 +-- .../000018_add_achievements_table.down.sql | 1 + .../000018_add_achievements_table.up.sql | 13 ++++ 6 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 backend/migrations/000018_add_achievements_table.down.sql create mode 100644 backend/migrations/000018_add_achievements_table.up.sql diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index c5d6e4487..cc58a216e 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -25,6 +25,8 @@ func TestGetCommunityLeaderboard(t *testing.T) { otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + clearTable("community_users_achievements") + response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -34,4 +36,33 @@ func TestGetCommunityLeaderboard(t *testing.T) { assert.Equal(t, 2, len(p.Data)) assert.Equal(t, 3, p.Data[0].Score) assert.Equal(t, 2, p.Data[1].Score) +} + +func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { + clearTable("communities") + clearTable("community_users") + clearTable("proposals") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + proposalIdA := otu.AddActiveProposals(communityId, 1)[0] + proposalIdB := otu.AddActiveProposals(communityId, 2)[0] + proposalIdC := otu.AddActiveProposals(communityId, 3)[0] + voteChoice := "a" + earlyVoteBonus := 1 + + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user1", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user1", proposalIdB, voteChoice)) + otu.CreateVoteAPI(proposalIdC, otu.GenerateValidVotePayload("user1", proposalIdC, voteChoice)) + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + + response := otu.GetCommunityLeaderboardAPI(communityId) + checkResponseCode(t, http.StatusOK, response.Code) + + var p test_utils.PaginatedResponseWithLeaderboardUser + json.Unmarshal(response.Body.Bytes(), &p) + + assert.Equal(t, 2, len(p.Data)) + assert.Equal(t, 3+3*earlyVoteBonus, p.Data[0].Score) + assert.Equal(t, 2+2*earlyVoteBonus, p.Data[1].Score) } \ No newline at end of file diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 9517db1ec..b0bf5f94d 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -1,6 +1,8 @@ package models import ( + "fmt" + s "github.com/DapperCollectives/CAST/backend/main/shared" "github.com/georgysavva/scany/pgxscan" "github.com/jackc/pgx/v4" @@ -21,9 +23,10 @@ type CommunityUserType struct { Is_member bool `json:"isMember" validate:"required"` } -type LeaderboardUser struct { - Addr string `json:"addr" validate:"required"` - Score int `json:"score" validate:"required"` +type UserAchievements struct { + Address string + NumVotes int + EarlyVote int } type UserTypes []string @@ -42,6 +45,17 @@ type CommunityUserPayload struct { Composite_signatures *[]s.CompositeSignature `json:"compositeSignatures" validate:"required"` } +type AchievementPayload struct { + Addr string `json:"addr" validate:"required"` + Achievement_type string `json:"string" validate:"required"` + Total int `json:"total,omitempty"` +} + +type LeaderboardUserPayload struct { + Addr string `json:"addr" validate:"required"` + Score int `json:"score,omitempty"` +} + func GetUsersForCommunity(db *s.Database, communityId, start, count int) ([]CommunityUserType, int, error) { var users = []CommunityUserType{} err := pgxscan.Select(db.Context, db.Conn, &users, @@ -98,29 +112,50 @@ func GetUsersForCommunityByType(db *s.Database, communityId, start, count int, u return users, totalUsers, nil } -func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUser, int, error) { - var users = []LeaderboardUser{} - err := pgxscan.Select(db.Context, db.Conn, &users, +func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUserPayload, int, error) { + var userAchievements = []UserAchievements{} + var leaderboardUsers = []LeaderboardUserPayload{} + var defaultEarlyVoteWeight = 1 + + sql := fmt.Sprintf( ` - SELECT v.addr, count(*) AS score FROM votes v - JOIN proposals p ON p.id = v.proposal_id - WHERE p.community_id = $1 - GROUP BY v.addr - ORDER BY score DESC - LIMIT $2 OFFSET $3 - `, communityId, count, start) + 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 community_users_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 + WHERE p.community_id = $1 + GROUP BY v.addr, a.early_vote + LIMIT $2 OFFSET $3 + `, communityId) + + err := pgxscan.Select(db.Context, db.Conn, &userAchievements, sql, communityId, count, start) if err != nil && err.Error() != pgx.ErrNoRows.Error() { return nil, 0, err } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { - return []LeaderboardUser{}, 0, nil + return []LeaderboardUserPayload{}, 0, nil + } + + for _, user := range userAchievements { + var leaderboardUser = LeaderboardUserPayload{} + leaderboardUser.Addr = user.Address + leaderboardUser.Score = user.NumVotes + (user.EarlyVote * defaultEarlyVoteWeight) + leaderboardUsers = append(leaderboardUsers, leaderboardUser) } var totalUsers int countSql := `SELECT COUNT(*) FROM community_users WHERE community_id = $1` _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalUsers) - return users, totalUsers, nil + return leaderboardUsers, totalUsers, nil } func GetCommunitiesForUser(db *s.Database, addr string, start, count int) ([]UserCommunity, int, error) { diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index d31ac3bd7..48eece64c 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -49,6 +49,12 @@ const ( timestampExpiry = 60 ) +const ( + EarlyVote string = "earlyVote" + Streak = "streak" + WinningVote = "winningVote" +) + /////////// // Votes // /////////// @@ -194,6 +200,9 @@ func (v *Vote) GetVoteById(db *s.Database) error { } func (v *Vote) CreateVote(db *s.Database) error { + var defaultEarlyVoteLength = 1 + + // Create Vote err := db.Conn.QueryRow(db.Context, ` INSERT INTO votes(proposal_id, addr, choice, composite_signatures, cid, message) @@ -201,6 +210,26 @@ func (v *Vote) CreateVote(db *s.Database) error { RETURNING id, created_at `, v.Proposal_id, v.Addr, v.Choice, v.Composite_signatures, v.Cid, v.Message).Scan(&v.ID, &v.Created_at) + // Get Proposal Start time to check for early vote + var proposal Proposal + pgxscan.Get(db.Context, db.Conn, &proposal, + `SELECT start_time, community_id from proposals + WHERE id = $1`, + v.Proposal_id) + + isEarlyVote := proposal.Start_time.Before(proposal.Start_time.Add(time.Hour + time.Duration(defaultEarlyVoteLength))) + + if isEarlyVote { + err = db.Conn.QueryRow(db.Context, + ` + INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals) + VALUES($1, $2, $3, $4) + RETURNING id + `, v.Addr, EarlyVote, proposal.Community_id, []int{v.Proposal_id}).Scan(&v.ID) + + return err + } + return err // will be nil unless something went wrong } diff --git a/backend/main/test_utils/community_utils.go b/backend/main/test_utils/community_utils.go index e472066d6..45abc028f 100644 --- a/backend/main/test_utils/community_utils.go +++ b/backend/main/test_utils/community_utils.go @@ -29,11 +29,11 @@ type PaginatedResponseWithUserType struct { } type PaginatedResponseWithLeaderboardUser struct { - Data []models.LeaderboardUser `json:"data"` - Start int `json:"start"` - Count int `json:"count"` - TotalRecords int `json:"totalRecords"` - Next int `json:"next"` + Data []models.LeaderboardUserPayload `json:"data"` + Start int `json:"start"` + Count int `json:"count"` + TotalRecords int `json:"totalRecords"` + Next int `json:"next"` } var ( diff --git a/backend/migrations/000018_add_achievements_table.down.sql b/backend/migrations/000018_add_achievements_table.down.sql new file mode 100644 index 000000000..a7cfe9f1b --- /dev/null +++ b/backend/migrations/000018_add_achievements_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS community_users_achievements; diff --git a/backend/migrations/000018_add_achievements_table.up.sql b/backend/migrations/000018_add_achievements_table.up.sql new file mode 100644 index 000000000..9bf2897da --- /dev/null +++ b/backend/migrations/000018_add_achievements_table.up.sql @@ -0,0 +1,13 @@ +CREATE TYPE achievement_types AS enum ('earlyVote', 'streak', 'winningVote'); + +CREATE EXTENSION IF NOT EXISTS tablefunc; + +CREATE TABLE community_users_achievements ( + id BIGSERIAL primary key, + addr VARCHAR(18) NOT NULL, + achievement_type achievement_types, + community_id INT not null references communities(id), + proposals BIGINT array, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now() +); From 5ac3803e791fd7cbfe296be4cd8828b33a20dbfc Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 28 Jun 2022 11:24:39 -0500 Subject: [PATCH 04/11] CAS-139: Add EarlyVote scoring --- backend/main/leaderboard_test.go | 31 ++++++++++ backend/main/models/community_users.go | 61 +++++++++++++------ backend/main/models/vote.go | 29 +++++++++ backend/main/test_utils/community_utils.go | 10 +-- .../000018_add_achievements_table.down.sql | 1 + .../000018_add_achievements_table.up.sql | 13 ++++ 6 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 backend/migrations/000018_add_achievements_table.down.sql create mode 100644 backend/migrations/000018_add_achievements_table.up.sql diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index c5d6e4487..cc58a216e 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -25,6 +25,8 @@ func TestGetCommunityLeaderboard(t *testing.T) { otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + clearTable("community_users_achievements") + response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -34,4 +36,33 @@ func TestGetCommunityLeaderboard(t *testing.T) { assert.Equal(t, 2, len(p.Data)) assert.Equal(t, 3, p.Data[0].Score) assert.Equal(t, 2, p.Data[1].Score) +} + +func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { + clearTable("communities") + clearTable("community_users") + clearTable("proposals") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + proposalIdA := otu.AddActiveProposals(communityId, 1)[0] + proposalIdB := otu.AddActiveProposals(communityId, 2)[0] + proposalIdC := otu.AddActiveProposals(communityId, 3)[0] + voteChoice := "a" + earlyVoteBonus := 1 + + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user1", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user1", proposalIdB, voteChoice)) + otu.CreateVoteAPI(proposalIdC, otu.GenerateValidVotePayload("user1", proposalIdC, voteChoice)) + otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) + otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + + response := otu.GetCommunityLeaderboardAPI(communityId) + checkResponseCode(t, http.StatusOK, response.Code) + + var p test_utils.PaginatedResponseWithLeaderboardUser + json.Unmarshal(response.Body.Bytes(), &p) + + assert.Equal(t, 2, len(p.Data)) + assert.Equal(t, 3+3*earlyVoteBonus, p.Data[0].Score) + assert.Equal(t, 2+2*earlyVoteBonus, p.Data[1].Score) } \ No newline at end of file diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 9517db1ec..4b2692dae 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -1,6 +1,8 @@ package models import ( + "fmt" + s "github.com/DapperCollectives/CAST/backend/main/shared" "github.com/georgysavva/scany/pgxscan" "github.com/jackc/pgx/v4" @@ -21,11 +23,6 @@ type CommunityUserType struct { Is_member bool `json:"isMember" validate:"required"` } -type LeaderboardUser struct { - Addr string `json:"addr" validate:"required"` - Score int `json:"score" validate:"required"` -} - type UserTypes []string var USER_TYPES = UserTypes{"member", "author", "admin"} @@ -42,6 +39,11 @@ type CommunityUserPayload struct { Composite_signatures *[]s.CompositeSignature `json:"compositeSignatures" validate:"required"` } +type LeaderboardUserPayload struct { + Addr string `json:"addr" validate:"required"` + Score int `json:"score,omitempty"` +} + func GetUsersForCommunity(db *s.Database, communityId, start, count int) ([]CommunityUserType, int, error) { var users = []CommunityUserType{} err := pgxscan.Select(db.Context, db.Conn, &users, @@ -98,29 +100,54 @@ func GetUsersForCommunityByType(db *s.Database, communityId, start, count int, u return users, totalUsers, nil } -func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUser, int, error) { - var users = []LeaderboardUser{} - err := pgxscan.Select(db.Context, db.Conn, &users, +func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUserPayload, int, error) { + var userAchievements = [] struct { + Address string + NumVotes int + EarlyVote int + }{} + var leaderboardUsers = []LeaderboardUserPayload{} + var defaultEarlyVoteWeight = 1 + + sql := fmt.Sprintf( ` - SELECT v.addr, count(*) AS score FROM votes v - JOIN proposals p ON p.id = v.proposal_id - WHERE p.community_id = $1 - GROUP BY v.addr - ORDER BY score DESC - LIMIT $2 OFFSET $3 - `, communityId, count, start) + 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 community_users_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 + WHERE p.community_id = $1 + GROUP BY v.addr, a.early_vote + LIMIT $2 OFFSET $3 + `, communityId) + + err := pgxscan.Select(db.Context, db.Conn, &userAchievements, sql, communityId, count, start) if err != nil && err.Error() != pgx.ErrNoRows.Error() { return nil, 0, err } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { - return []LeaderboardUser{}, 0, nil + return []LeaderboardUserPayload{}, 0, nil + } + + for _, user := range userAchievements { + var leaderboardUser = LeaderboardUserPayload{} + leaderboardUser.Addr = user.Address + leaderboardUser.Score = user.NumVotes + (user.EarlyVote * defaultEarlyVoteWeight) + leaderboardUsers = append(leaderboardUsers, leaderboardUser) } var totalUsers int countSql := `SELECT COUNT(*) FROM community_users WHERE community_id = $1` _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalUsers) - return users, totalUsers, nil + return leaderboardUsers, totalUsers, nil } func GetCommunitiesForUser(db *s.Database, addr string, start, count int) ([]UserCommunity, int, error) { diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index d31ac3bd7..48eece64c 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -49,6 +49,12 @@ const ( timestampExpiry = 60 ) +const ( + EarlyVote string = "earlyVote" + Streak = "streak" + WinningVote = "winningVote" +) + /////////// // Votes // /////////// @@ -194,6 +200,9 @@ func (v *Vote) GetVoteById(db *s.Database) error { } func (v *Vote) CreateVote(db *s.Database) error { + var defaultEarlyVoteLength = 1 + + // Create Vote err := db.Conn.QueryRow(db.Context, ` INSERT INTO votes(proposal_id, addr, choice, composite_signatures, cid, message) @@ -201,6 +210,26 @@ func (v *Vote) CreateVote(db *s.Database) error { RETURNING id, created_at `, v.Proposal_id, v.Addr, v.Choice, v.Composite_signatures, v.Cid, v.Message).Scan(&v.ID, &v.Created_at) + // Get Proposal Start time to check for early vote + var proposal Proposal + pgxscan.Get(db.Context, db.Conn, &proposal, + `SELECT start_time, community_id from proposals + WHERE id = $1`, + v.Proposal_id) + + isEarlyVote := proposal.Start_time.Before(proposal.Start_time.Add(time.Hour + time.Duration(defaultEarlyVoteLength))) + + if isEarlyVote { + err = db.Conn.QueryRow(db.Context, + ` + INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals) + VALUES($1, $2, $3, $4) + RETURNING id + `, v.Addr, EarlyVote, proposal.Community_id, []int{v.Proposal_id}).Scan(&v.ID) + + return err + } + return err // will be nil unless something went wrong } diff --git a/backend/main/test_utils/community_utils.go b/backend/main/test_utils/community_utils.go index e472066d6..45abc028f 100644 --- a/backend/main/test_utils/community_utils.go +++ b/backend/main/test_utils/community_utils.go @@ -29,11 +29,11 @@ type PaginatedResponseWithUserType struct { } type PaginatedResponseWithLeaderboardUser struct { - Data []models.LeaderboardUser `json:"data"` - Start int `json:"start"` - Count int `json:"count"` - TotalRecords int `json:"totalRecords"` - Next int `json:"next"` + Data []models.LeaderboardUserPayload `json:"data"` + Start int `json:"start"` + Count int `json:"count"` + TotalRecords int `json:"totalRecords"` + Next int `json:"next"` } var ( diff --git a/backend/migrations/000018_add_achievements_table.down.sql b/backend/migrations/000018_add_achievements_table.down.sql new file mode 100644 index 000000000..a7cfe9f1b --- /dev/null +++ b/backend/migrations/000018_add_achievements_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS community_users_achievements; diff --git a/backend/migrations/000018_add_achievements_table.up.sql b/backend/migrations/000018_add_achievements_table.up.sql new file mode 100644 index 000000000..9bf2897da --- /dev/null +++ b/backend/migrations/000018_add_achievements_table.up.sql @@ -0,0 +1,13 @@ +CREATE TYPE achievement_types AS enum ('earlyVote', 'streak', 'winningVote'); + +CREATE EXTENSION IF NOT EXISTS tablefunc; + +CREATE TABLE community_users_achievements ( + id BIGSERIAL primary key, + addr VARCHAR(18) NOT NULL, + achievement_type achievement_types, + community_id INT not null references communities(id), + proposals BIGINT array, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now() +); From f2461ffbd1f48daced9b9a4eaa3a61d85acf63a1 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 29 Jun 2022 13:09:34 -0500 Subject: [PATCH 05/11] CAS-139: Updates based on PR Feedback --- backend/main/leaderboard_test.go | 36 ++++++++++--------- backend/main/models/community_users.go | 6 +++- backend/main/models/vote.go | 2 +- backend/main/test_utils/factory.go | 19 +++++++++- .../000018_add_achievements_table.down.sql | 2 ++ 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index cc58a216e..3ab2383cc 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -8,23 +8,25 @@ import ( "github.com/DapperCollectives/CAST/backend/main/test_utils" "github.com/stretchr/testify/assert" ) + func TestGetCommunityLeaderboard(t *testing.T) { clearTable("communities") clearTable("community_users") + clearTable("community_users_achievements") clearTable("proposals") clearTable("votes") + communityId := otu.AddCommunities(1)[0] - proposalIdA := otu.AddActiveProposals(communityId, 1)[0] - proposalIdB := otu.AddActiveProposals(communityId, 2)[0] - proposalIdC := otu.AddActiveProposals(communityId, 3)[0] + proposalIds := otu.AddActiveProposals(communityId, 3) voteChoice := "a" - otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user1", proposalIdA, voteChoice)) - otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user1", proposalIdB, voteChoice)) - otu.CreateVoteAPI(proposalIdC, otu.GenerateValidVotePayload("user1", proposalIdC, voteChoice)) - otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) - otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) + // Remove all achievements to test base case for scoring clearTable("community_users_achievements") response := otu.GetCommunityLeaderboardAPI(communityId) @@ -41,20 +43,20 @@ func TestGetCommunityLeaderboard(t *testing.T) { func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { clearTable("communities") clearTable("community_users") + clearTable("community_users_achievements") clearTable("proposals") clearTable("votes") + communityId := otu.AddCommunities(1)[0] - proposalIdA := otu.AddActiveProposals(communityId, 1)[0] - proposalIdB := otu.AddActiveProposals(communityId, 2)[0] - proposalIdC := otu.AddActiveProposals(communityId, 3)[0] + proposalIds := otu.AddActiveProposalsWithStartTimeNow(communityId, 3) voteChoice := "a" earlyVoteBonus := 1 - otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user1", proposalIdA, voteChoice)) - otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user1", proposalIdB, voteChoice)) - otu.CreateVoteAPI(proposalIdC, otu.GenerateValidVotePayload("user1", proposalIdC, voteChoice)) - otu.CreateVoteAPI(proposalIdA, otu.GenerateValidVotePayload("user2", proposalIdA, voteChoice)) - otu.CreateVoteAPI(proposalIdB, otu.GenerateValidVotePayload("user2", proposalIdB, voteChoice)) + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -65,4 +67,4 @@ func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { assert.Equal(t, 2, len(p.Data)) assert.Equal(t, 3+3*earlyVoteBonus, p.Data[0].Score) assert.Equal(t, 2+2*earlyVoteBonus, p.Data[1].Score) -} \ No newline at end of file +} diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 4b2692dae..d93e16375 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -101,7 +101,7 @@ func GetUsersForCommunityByType(db *s.Database, communityId, start, count int, u } func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUserPayload, int, error) { - var userAchievements = [] struct { + var userAchievements = []struct { Address string NumVotes int EarlyVote int @@ -109,6 +109,10 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L var leaderboardUsers = []LeaderboardUserPayload{} var defaultEarlyVoteWeight = 1 + // Retrieve each user in the community with totals for + // their votes and achievements (e.g. early votes, streaks and winning choices) + // Note: crosstab is a postgres extension that creates a pivot table. + // Achievements are joined as columns for each user. sql := fmt.Sprintf( ` SELECT v.addr as address, count(*) as num_votes, diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index 48eece64c..af245df9a 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -217,7 +217,7 @@ func (v *Vote) CreateVote(db *s.Database) error { WHERE id = $1`, v.Proposal_id) - isEarlyVote := proposal.Start_time.Before(proposal.Start_time.Add(time.Hour + time.Duration(defaultEarlyVoteLength))) + isEarlyVote := v.Created_at.Before(proposal.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength))) if isEarlyVote { err = db.Conn.QueryRow(db.Context, diff --git a/backend/main/test_utils/factory.go b/backend/main/test_utils/factory.go index 9750b950a..1ca69e4df 100644 --- a/backend/main/test_utils/factory.go +++ b/backend/main/test_utils/factory.go @@ -232,7 +232,24 @@ func (otu *OverflowTestUtils) AddActiveProposals(cId int, count int) []int { retIds := []int{} for i := 0; i < count; i++ { proposal := otu.GenerateProposalStruct("account", cId) - proposal.Start_time = time.Now().AddDate(0, -1, 0) + proposal.Start_time = time.Now().UTC().AddDate(0, -1, 0) + if err := proposal.CreateProposal(otu.A.DB); err != nil { + fmt.Printf("error in otu.AddActiveProposals") + } + + retIds = append(retIds, proposal.ID) + } + return retIds +} + +func (otu *OverflowTestUtils) AddActiveProposalsWithStartTimeNow(cId int, count int) []int { + if count < 1 { + count = 1 + } + retIds := []int{} + for i := 0; i < count; i++ { + proposal := otu.GenerateProposalStruct("account", cId) + proposal.Start_time = time.Now().UTC() if err := proposal.CreateProposal(otu.A.DB); err != nil { fmt.Printf("error in otu.AddActiveProposals") } diff --git a/backend/migrations/000018_add_achievements_table.down.sql b/backend/migrations/000018_add_achievements_table.down.sql index a7cfe9f1b..2936bc76a 100644 --- a/backend/migrations/000018_add_achievements_table.down.sql +++ b/backend/migrations/000018_add_achievements_table.down.sql @@ -1 +1,3 @@ DROP TABLE IF EXISTS community_users_achievements; +DROP TYPE IF EXISTS achievement_types; +DROP EXTENSION IF EXISTS tablefunc; From cc5479d3a3c3a8b28e53e07e8d49cd94a42c101a Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 5 Jul 2022 11:48:00 -0500 Subject: [PATCH 06/11] CAS-140: Add Streak Bonus --- backend/main/leaderboard_test.go | 68 ++++++- backend/main/models/community_users.go | 11 +- backend/main/models/vote.go | 179 +++++++++++++++--- .../000018_add_achievements_table.down.sql | 1 - .../000018_add_achievements_table.up.sql | 13 -- ...0_add_achievements_details_column.down.sql | 2 + ...020_add_achievements_details_column.up.sql | 2 + 7 files changed, 232 insertions(+), 44 deletions(-) delete mode 100644 backend/migrations/000018_add_achievements_table.down.sql delete mode 100644 backend/migrations/000018_add_achievements_table.up.sql create mode 100644 backend/migrations/000020_add_achievements_details_column.down.sql create mode 100644 backend/migrations/000020_add_achievements_details_column.up.sql diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index 8763b283a..b21820388 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -50,15 +50,46 @@ func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { clearTable("votes") communityId := otu.AddCommunities(1)[0] - proposalIds := otu.AddActiveProposalsWithStartTimeNow(communityId, 3) + proposalIds := otu.AddActiveProposalsWithStartTimeNow(communityId, 2) voteChoice := "a" earlyVoteBonus := 1 + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) + + response := otu.GetCommunityLeaderboardAPI(communityId) + checkResponseCode(t, http.StatusOK, response.Code) + + var p test_utils.PaginatedResponseWithLeaderboardUser + json.Unmarshal(response.Body.Bytes(), &p) + + assert.Equal(t, 2, len(p.Data)) + assert.Equal(t, 2+2*earlyVoteBonus, p.Data[0].Score) + assert.Equal(t, 2, p.Data[1].Score) +} + +func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { + clearTable("communities") + clearTable("community_users") + clearTable("community_users_achievements") + clearTable("proposals") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + proposalIds := otu.AddActiveProposals(communityId, 4) + voteChoice := "a" + streakBonus := 1 + + // streak length of 3 otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + + // streak length of 4 otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user2", proposalIds[2], voteChoice)) + otu.CreateVoteAPI(proposalIds[3], otu.GenerateValidVotePayload("user2", proposalIds[3], voteChoice)) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -67,6 +98,37 @@ func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { json.Unmarshal(response.Body.Bytes(), &p) assert.Equal(t, 2, len(p.Data)) - assert.Equal(t, 3+3*earlyVoteBonus, p.Data[0].Score) - assert.Equal(t, 2+2*earlyVoteBonus, p.Data[1].Score) + assert.Equal(t, 3+1*streakBonus, p.Data[0].Score) + assert.Equal(t, 4+1*streakBonus, p.Data[1].Score) +} + +func TestGetCommunityLeaderboardWithMultiStreak(t *testing.T) { + clearTable("communities") + clearTable("community_users") + clearTable("community_users_achievements") + clearTable("proposals") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + proposalIds := otu.AddActiveProposals(communityId, 8) + voteChoice := "a" + streakBonus := 1 + + // First Streak + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + + // Second Streak + otu.CreateVoteAPI(proposalIds[5], otu.GenerateValidVotePayload("user1", proposalIds[5], voteChoice)) + otu.CreateVoteAPI(proposalIds[6], otu.GenerateValidVotePayload("user1", proposalIds[6], voteChoice)) + otu.CreateVoteAPI(proposalIds[7], otu.GenerateValidVotePayload("user1", proposalIds[7], voteChoice)) + + response := otu.GetCommunityLeaderboardAPI(communityId) + checkResponseCode(t, http.StatusOK, response.Code) + + var p test_utils.PaginatedResponseWithLeaderboardUser + json.Unmarshal(response.Body.Bytes(), &p) + + assert.Equal(t, 1, len(p.Data)) + assert.Equal(t, 6+2*streakBonus, p.Data[0].Score) } diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index d93e16375..c1e3478d7 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -105,9 +105,11 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L Address string NumVotes int EarlyVote int + Streak int }{} var leaderboardUsers = []LeaderboardUserPayload{} var defaultEarlyVoteWeight = 1 + var defaultStreakWeight = 1 // Retrieve each user in the community with totals for // their votes and achievements (e.g. early votes, streaks and winning choices) @@ -116,7 +118,8 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L 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 + CASE WHEN a.early_vote is NULL THEN 0 ELSE a.early_vote END as early_vote, + CASE WHEN a.streak is NULL THEN 0 ELSE a.streak END as streak FROM votes v LEFT OUTER JOIN proposals p ON p.id = v.proposal_id LEFT OUTER JOIN ( @@ -125,10 +128,10 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L WHERE community_id = %d GROUP BY addr, achievement_type ORDER BY 1,2$$ - ) AS ct(address varchar(18), early_vote bigint) + ) AS ct(address varchar(18), early_vote bigint, streak bigint) ) a ON v.addr = a.address WHERE p.community_id = $1 - GROUP BY v.addr, a.early_vote + GROUP BY v.addr, a.early_vote, a.streak LIMIT $2 OFFSET $3 `, communityId) @@ -143,7 +146,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) + leaderboardUser.Score = user.NumVotes + (user.EarlyVote * defaultEarlyVoteWeight) + (user.Streak * defaultStreakWeight) leaderboardUsers = append(leaderboardUsers, leaderboardUser) } diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index af245df9a..54fc87b4e 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -3,6 +3,7 @@ package models import ( "encoding/hex" "errors" + "fmt" "strconv" "strings" "time" @@ -200,37 +201,27 @@ func (v *Vote) GetVoteById(db *s.Database) error { } func (v *Vote) CreateVote(db *s.Database) error { - var defaultEarlyVoteLength = 1 + err := createVote(db, v) - // Create Vote - err := db.Conn.QueryRow(db.Context, - ` - INSERT INTO votes(proposal_id, addr, choice, composite_signatures, cid, message) - VALUES($1, $2, $3, $4, $5, $6) - RETURNING id, created_at - `, v.Proposal_id, v.Addr, v.Choice, v.Composite_signatures, v.Cid, v.Message).Scan(&v.ID, &v.Created_at) + if checkError(err) { + return err + } - // Get Proposal Start time to check for early vote - var proposal Proposal - pgxscan.Get(db.Context, db.Conn, &proposal, - `SELECT start_time, community_id from proposals - WHERE id = $1`, - v.Proposal_id) + proposal, err := getProposal(db, v.Proposal_id) - isEarlyVote := v.Created_at.Before(proposal.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength))) + if checkError(err) { + return err + } - if isEarlyVote { - err = db.Conn.QueryRow(db.Context, - ` - INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals) - VALUES($1, $2, $3, $4) - RETURNING id - `, v.Addr, EarlyVote, proposal.Community_id, []int{v.Proposal_id}).Scan(&v.ID) + err = checkForEarlyVoteAchievement(db, v, proposal) + if checkError(err) { return err } - return err // will be nil unless something went wrong + err = checkForStreakAchievement(db, v, proposal) + + return err } func (v *Vote) ValidateMessage(proposal Proposal) error { @@ -337,3 +328,145 @@ func DoesNFTExist(db *s.Database, v *VoteWithBalance) (bool, error) { return true, nil } + +func createVote(db *s.Database, v *Vote) error { + // Create Vote + err := db.Conn.QueryRow(db.Context, + ` + INSERT INTO votes(proposal_id, addr, choice, composite_signatures, cid, message) + VALUES($1, $2, $3, $4, $5, $6) + RETURNING id, created_at + `, v.Proposal_id, v.Addr, v.Choice, v.Composite_signatures, v.Cid, v.Message).Scan(&v.ID, &v.Created_at) + + return err +} + +func getProposal(db *s.Database, proposalId int) (Proposal, error) { + var proposal Proposal + err := pgxscan.Get(db.Context, db.Conn, &proposal, + `SELECT start_time, community_id from proposals + WHERE id = $1`, + proposalId) + + return proposal, err +} + +func checkForEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error { + var defaultEarlyVoteLength = 1 + + isEarlyVote := v.Created_at.Before(p.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength))) + + if isEarlyVote { + //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) + err := db.Conn.QueryRow(db.Context, + ` + INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals, details) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (details) + DO NOTHING + RETURNING id + `, v.Addr, EarlyVote, p.Community_id, []int{v.Proposal_id}, earlyVoteDetails).Scan(&v.ID) + + if checkErrorIgnoreNoRows(err) { + return err + } + } + + return nil +} + +func checkForStreakAchievement(db *s.Database, v *Vote, p Proposal) error { + var defaultStreakLength = 3 + + // Determine if this vote is part of a streak + // Proposals with the user address count as a vote for that proposal + // NULL means the user did not vote + sql := fmt.Sprintf(` + SELECT p.id as id, + CASE WHEN v.addr is NULL THEN '' ELSE v.addr END as addr + FROM proposals p + LEFT OUTER JOIN ( + SELECT * FROM votes where addr = '%s' + ) v ON v.proposal_id = p.id + where p.community_id = $1 + ORDER BY start_time ASC + `, v.Addr) + + var votingStreak []struct { + ID uint64 + Addr string + } + err := pgxscan.Select(db.Context, db.Conn, &votingStreak, sql, p.Community_id) + + if checkError(err) { + return err + } + + if len(votingStreak) >= defaultStreakLength { + var proposals []uint64 + i := 0 + for i < len(votingStreak) { + v := votingStreak[i] + if v.Addr != "" { + proposals = append(proposals, v.ID) + } + if len(proposals) >= defaultStreakLength && (i == len(votingStreak)-1 || (i < len(votingStreak)-1 && votingStreak[i+1].Addr == "")) { + + //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)), ","), "[]")) + + var sql string + if len(proposals) == defaultStreakLength { + // Add default streak, or do nothing if streak already exists + sql = ` + INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals, details) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (details) + DO NOTHING + ` + err = db.Conn.QueryRow(db.Context, sql, v.Addr, Streak, p.Community_id, proposals, currentStreakDetails).Scan(&v.ID) + } else { + // If previous streak exists, then update with new streak details + // e.g. If previous streak = 1,2,3 and current streak = 1,2,3,4 then update row with current streak details + previousStreakDetails := fmt.Sprintf("%s:%s:%d:%s", Streak, v.Addr, p.Community_id, strings.Trim(strings.Join(strings.Fields(fmt.Sprint(proposals[:len(proposals)-1])), ","), "[]")) + sql = ` + INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals, details) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (details) + DO UPDATE SET proposals = $4, details = $6 + RETURNING id + ` + err = db.Conn.QueryRow(db.Context, sql, v.Addr, Streak, p.Community_id, proposals, previousStreakDetails, currentStreakDetails).Scan(&v.ID) + } + + if checkErrorIgnoreNoRows(err) { + return err + } else { + err = nil + } + + // streak inserted, reset to check for other streaks + proposals = nil + } + if v.Addr == "" { + proposals = nil + } + + i += 1 + } + } + + return nil +} + +func checkError(err error) bool { + return err != nil +} + +func checkErrorIgnoreNoRows(err error) bool { + if err != nil && fmt.Sprintf("%s", err) != pgx.ErrNoRows.Error() { + return true + } + return false +} diff --git a/backend/migrations/000018_add_achievements_table.down.sql b/backend/migrations/000018_add_achievements_table.down.sql deleted file mode 100644 index a7cfe9f1b..000000000 --- a/backend/migrations/000018_add_achievements_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS community_users_achievements; diff --git a/backend/migrations/000018_add_achievements_table.up.sql b/backend/migrations/000018_add_achievements_table.up.sql deleted file mode 100644 index 9bf2897da..000000000 --- a/backend/migrations/000018_add_achievements_table.up.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TYPE achievement_types AS enum ('earlyVote', 'streak', 'winningVote'); - -CREATE EXTENSION IF NOT EXISTS tablefunc; - -CREATE TABLE community_users_achievements ( - id BIGSERIAL primary key, - addr VARCHAR(18) NOT NULL, - achievement_type achievement_types, - community_id INT not null references communities(id), - proposals BIGINT array, - created_at timestamp with time zone NOT NULL DEFAULT now(), - updated_at timestamp with time zone NOT NULL DEFAULT now() -); diff --git a/backend/migrations/000020_add_achievements_details_column.down.sql b/backend/migrations/000020_add_achievements_details_column.down.sql new file mode 100644 index 000000000..e0820cdf1 --- /dev/null +++ b/backend/migrations/000020_add_achievements_details_column.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE community_users_achievements +DROP COLUMN details; diff --git a/backend/migrations/000020_add_achievements_details_column.up.sql b/backend/migrations/000020_add_achievements_details_column.up.sql new file mode 100644 index 000000000..859570fc1 --- /dev/null +++ b/backend/migrations/000020_add_achievements_details_column.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE community_users_achievements ADD details VARCHAR NOT NULL; +ALTER TABLE community_users_achievements ADD UNIQUE (details); \ No newline at end of file From f7879e6d381b8c22148b45d21b539fbddf6f3dd6 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 5 Jul 2022 16:22:26 -0500 Subject: [PATCH 07/11] CAS-140: Add helper functions for leaderboard test --- backend/main/leaderboard_test.go | 44 ++------- backend/main/models/community_users.go | 94 ++++++++++++-------- backend/main/test_utils/leaderboard_utils.go | 52 +++++++++++ 3 files changed, 114 insertions(+), 76 deletions(-) create mode 100644 backend/main/test_utils/leaderboard_utils.go diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index b21820388..f4b0326c8 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -17,20 +17,12 @@ func TestGetCommunityLeaderboard(t *testing.T) { clearTable("votes") communityId := otu.AddCommunities(1)[0] - proposalIds := otu.AddActiveProposals(communityId, 3) - voteChoice := "a" - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) + otu.GenerateLeaderboardBaseCase(communityId) // Remove all achievements to test base case for scoring clearTable("community_users_achievements") - clearTable("community_users_achievements") - response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -50,13 +42,9 @@ func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { clearTable("votes") communityId := otu.AddCommunities(1)[0] - proposalIds := otu.AddActiveProposalsWithStartTimeNow(communityId, 2) - voteChoice := "a" earlyVoteBonus := 1 - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) + otu.GenerateLeaderboardWithEarlyVotes(communityId) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -75,21 +63,11 @@ func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { clearTable("community_users_achievements") clearTable("proposals") clearTable("votes") + communityId := otu.AddCommunities(1)[0] - proposalIds := otu.AddActiveProposals(communityId, 4) - voteChoice := "a" streakBonus := 1 - // streak length of 3 - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) - - // streak length of 4 - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user2", proposalIds[2], voteChoice)) - otu.CreateVoteAPI(proposalIds[3], otu.GenerateValidVotePayload("user2", proposalIds[3], voteChoice)) + otu.GenerateLeaderboardWithSingleStreaks(communityId) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -102,26 +80,16 @@ func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { assert.Equal(t, 4+1*streakBonus, p.Data[1].Score) } -func TestGetCommunityLeaderboardWithMultiStreak(t *testing.T) { +func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) { clearTable("communities") clearTable("community_users") clearTable("community_users_achievements") clearTable("proposals") clearTable("votes") communityId := otu.AddCommunities(1)[0] - proposalIds := otu.AddActiveProposals(communityId, 8) - voteChoice := "a" streakBonus := 1 - // First Streak - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) - - // Second Streak - otu.CreateVoteAPI(proposalIds[5], otu.GenerateValidVotePayload("user1", proposalIds[5], voteChoice)) - otu.CreateVoteAPI(proposalIds[6], otu.GenerateValidVotePayload("user1", proposalIds[6], voteChoice)) - otu.CreateVoteAPI(proposalIds[7], otu.GenerateValidVotePayload("user1", proposalIds[7], voteChoice)) + otu.GenerateLeaderboardWithMultiStreaks(communityId) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index c1e3478d7..3becf26d6 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -39,6 +39,13 @@ type CommunityUserPayload struct { Composite_signatures *[]s.CompositeSignature `json:"compositeSignatures" validate:"required"` } +type UserAchievements = []struct { + Address string + NumVotes int + EarlyVote int + Streak int +} + type LeaderboardUserPayload struct { Addr string `json:"addr" validate:"required"` Score int `json:"score,omitempty"` @@ -101,46 +108,14 @@ func GetUsersForCommunityByType(db *s.Database, communityId, start, count int, u } func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]LeaderboardUserPayload, int, error) { - var userAchievements = []struct { - Address string - NumVotes int - EarlyVote int - Streak int - }{} var leaderboardUsers = []LeaderboardUserPayload{} var defaultEarlyVoteWeight = 1 var defaultStreakWeight = 1 - // Retrieve each user in the community with totals for - // their votes and achievements (e.g. early votes, streaks and winning choices) - // Note: crosstab is a postgres extension that creates a pivot table. - // Achievements are joined as columns for each user. - 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, - CASE WHEN a.streak is NULL THEN 0 ELSE a.streak END as streak - 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 community_users_achievements - WHERE community_id = %d - GROUP BY addr, achievement_type - ORDER BY 1,2$$ - ) AS ct(address varchar(18), early_vote bigint, streak bigint) - ) a ON v.addr = a.address - WHERE p.community_id = $1 - GROUP BY v.addr, a.early_vote, a.streak - LIMIT $2 OFFSET $3 - `, communityId) + userAchievements, err := getUserAchievements(db, communityId, start, count) - err := pgxscan.Select(db.Context, db.Conn, &userAchievements, sql, communityId, count, start) - - if err != nil && err.Error() != pgx.ErrNoRows.Error() { - return nil, 0, err - } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { - return []LeaderboardUserPayload{}, 0, nil + if err != nil { + return leaderboardUsers, 0, err } for _, user := range userAchievements { @@ -150,9 +125,7 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L leaderboardUsers = append(leaderboardUsers, leaderboardUser) } - var totalUsers int - countSql := `SELECT COUNT(*) FROM community_users WHERE community_id = $1` - _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalUsers) + totalUsers := getTotalUsersForCommunity(db, communityId) return leaderboardUsers, totalUsers, nil } @@ -285,3 +258,48 @@ func EnsureValidRole(userType string) bool { } return false } + +func getTotalUsersForCommunity(db *s.Database, communityId int) int { + var totalUsers int + countSql := `SELECT COUNT(*) FROM community_users WHERE community_id = $1` + _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalUsers) + return totalUsers +} + +func getUserAchievements(db *s.Database, communityId int, start int, count int) (UserAchievements, error) { + var userAchievements UserAchievements + // Retrieve each user in the community with totals for + // 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 + // 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 community_users_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 + WHERE p.community_id = $1 + GROUP BY v.addr, a.early_vote + LIMIT $2 OFFSET $3 + `, communityId) + + err := pgxscan.Select(db.Context, db.Conn, &userAchievements, sql, communityId, count, start) + + if err != nil && err.Error() != pgx.ErrNoRows.Error() { + return nil, err + } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { + return UserAchievements{}, nil + } + + return userAchievements, nil +} diff --git a/backend/main/test_utils/leaderboard_utils.go b/backend/main/test_utils/leaderboard_utils.go new file mode 100644 index 000000000..390f27757 --- /dev/null +++ b/backend/main/test_utils/leaderboard_utils.go @@ -0,0 +1,52 @@ +package test_utils + +func (otu *OverflowTestUtils) GenerateLeaderboardBaseCase(communityId int) { + proposalIds := otu.AddActiveProposals(communityId, 3) + voteChoice := "a" + + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) +} + +func (otu *OverflowTestUtils) GenerateLeaderboardWithEarlyVotes(communityId int) { + proposalIds := otu.AddActiveProposalsWithStartTimeNow(communityId, 2) + voteChoice := "a" + + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) +} + +func (otu *OverflowTestUtils) GenerateLeaderboardWithSingleStreaks(communityId int) { + proposalIds := otu.AddActiveProposals(communityId, 4) + voteChoice := "a" + + // single streak length of 3 + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + + // single streak length of 4 + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user2", proposalIds[2], voteChoice)) + otu.CreateVoteAPI(proposalIds[3], otu.GenerateValidVotePayload("user2", proposalIds[3], voteChoice)) +} + +func (otu *OverflowTestUtils) GenerateLeaderboardWithMultiStreaks(communityId int) { + proposalIds := otu.AddActiveProposals(communityId, 8) + voteChoice := "a" + + // First Streak + otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) + otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) + otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + + // Second Streak + otu.CreateVoteAPI(proposalIds[5], otu.GenerateValidVotePayload("user1", proposalIds[5], voteChoice)) + otu.CreateVoteAPI(proposalIds[6], otu.GenerateValidVotePayload("user1", proposalIds[6], voteChoice)) + otu.CreateVoteAPI(proposalIds[7], otu.GenerateValidVotePayload("user1", proposalIds[7], voteChoice)) +} From c718a01836d6f0609467e75cb08597d655aaf8e1 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 6 Jul 2022 12:47:50 -0500 Subject: [PATCH 08/11] CAS-140: Refactor based on PR feedback --- backend/main/leaderboard_test.go | 69 ++++++--- backend/main/models/community_users.go | 2 +- backend/main/models/vote.go | 143 +++++++++--------- backend/main/test_utils/leaderboard_utils.go | 100 +++++++----- ...0_add_achievements_details_column.down.sql | 5 +- ...020_add_achievements_details_column.up.sql | 3 +- 6 files changed, 195 insertions(+), 127 deletions(-) diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index f4b0326c8..310ff47f2 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -12,16 +12,22 @@ import ( func TestGetCommunityLeaderboard(t *testing.T) { clearTable("communities") clearTable("community_users") - clearTable("community_users_achievements") + clearTable("user_achievements") clearTable("proposals") clearTable("votes") communityId := otu.AddCommunities(1)[0] - otu.GenerateLeaderboardBaseCase(communityId) + expectedUsers := 2 + expectedProposals := 2 + + // users get single vote for each proposal they voted on + expectedScore := expectedProposals + + otu.GenerateVotes(communityId, expectedProposals, expectedUsers) // Remove all achievements to test base case for scoring - clearTable("community_users_achievements") + clearTable("user_achievements") response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -29,22 +35,30 @@ func TestGetCommunityLeaderboard(t *testing.T) { var p test_utils.PaginatedResponseWithLeaderboardUser json.Unmarshal(response.Body.Bytes(), &p) - assert.Equal(t, 2, len(p.Data)) - assert.Equal(t, 3, p.Data[0].Score) - assert.Equal(t, 2, p.Data[1].Score) + receivedUser1Score := p.Data[0].Score + receivedUser2Score := p.Data[0].Score + + assert.Equal(t, expectedUsers, len(p.Data)) + assert.Equal(t, expectedScore, receivedUser1Score) + assert.Equal(t, expectedScore, receivedUser2Score) } func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { clearTable("communities") clearTable("community_users") - clearTable("community_users_achievements") + clearTable("user_achievements") clearTable("proposals") clearTable("votes") communityId := otu.AddCommunities(1)[0] earlyVoteBonus := 1 + expectedUsers := 1 + expectedProposals := 2 + + // user gets single vote for each proposal they voted on + expectedScore := expectedProposals + (expectedProposals * earlyVoteBonus) - otu.GenerateLeaderboardWithEarlyVotes(communityId) + otu.GenerateEarlyVoteAchievements(communityId, expectedProposals, expectedUsers) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -52,22 +66,27 @@ func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { var p test_utils.PaginatedResponseWithLeaderboardUser json.Unmarshal(response.Body.Bytes(), &p) - assert.Equal(t, 2, len(p.Data)) - assert.Equal(t, 2+2*earlyVoteBonus, p.Data[0].Score) - assert.Equal(t, 2, p.Data[1].Score) + receivedScore := p.Data[0].Score + + assert.Equal(t, expectedUsers, len(p.Data)) + assert.Equal(t, expectedScore, receivedScore) } func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { clearTable("communities") clearTable("community_users") - clearTable("community_users_achievements") + clearTable("user_achievements") clearTable("proposals") clearTable("votes") communityId := otu.AddCommunities(1)[0] + streaks := []int{3, 4} streakBonus := 1 + expectedUsers := 2 + expectedUser1Score := 3 + (1 * streakBonus) + expectedUser2Score := 4 + (1 * streakBonus) - otu.GenerateLeaderboardWithSingleStreaks(communityId) + otu.GenerateSingleStreakAchievements(communityId, streaks) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -75,21 +94,29 @@ func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { var p test_utils.PaginatedResponseWithLeaderboardUser json.Unmarshal(response.Body.Bytes(), &p) - assert.Equal(t, 2, len(p.Data)) - assert.Equal(t, 3+1*streakBonus, p.Data[0].Score) - assert.Equal(t, 4+1*streakBonus, p.Data[1].Score) + receivedUser1Score := p.Data[0].Score + receivedUser2Score := p.Data[1].Score + + assert.Equal(t, expectedUsers, len(p.Data)) + assert.Equal(t, expectedUser1Score, receivedUser1Score) + assert.Equal(t, expectedUser2Score, receivedUser2Score) } func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) { clearTable("communities") clearTable("community_users") - clearTable("community_users_achievements") + clearTable("user_achievements") clearTable("proposals") clearTable("votes") communityId := otu.AddCommunities(1)[0] + streaks := []int{3, 4} streakBonus := 1 + expectedUsers := 1 - otu.GenerateLeaderboardWithMultiStreaks(communityId) + // user with 7 votes and 2 streaks + expectedUser1Score := 7 + (2 * streakBonus) + + otu.GenerateMultiStreakAchievements(communityId, streaks) response := otu.GetCommunityLeaderboardAPI(communityId) checkResponseCode(t, http.StatusOK, response.Code) @@ -97,6 +124,8 @@ func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) { var p test_utils.PaginatedResponseWithLeaderboardUser json.Unmarshal(response.Body.Bytes(), &p) - assert.Equal(t, 1, len(p.Data)) - assert.Equal(t, 6+2*streakBonus, p.Data[0].Score) + receivedUser1Score := p.Data[0].Score + + assert.Equal(t, expectedUsers, len(p.Data)) + assert.Equal(t, expectedUser1Score, receivedUser1Score) } diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 3becf26d6..2d493ded9 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -282,7 +282,7 @@ func getUserAchievements(db *s.Database, communityId int, start int, count int) LEFT OUTER JOIN proposals p ON p.id = v.proposal_id LEFT OUTER JOIN ( SELECT * FROM crosstab( - $$SELECT addr, achievement_type, count(*) FROM community_users_achievements + $$SELECT addr, achievement_type, count(*) FROM user_achievements WHERE community_id = %d GROUP BY addr, achievement_type ORDER BY 1,2$$ diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index 54fc87b4e..94b5c54db 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -46,6 +46,11 @@ type NFT struct { Created_at time.Time `json:"created_at"` } +type VotingStreak struct { + Proposal_id uint64 + Addr string +} + const ( timestampExpiry = 60 ) @@ -201,26 +206,28 @@ func (v *Vote) GetVoteById(db *s.Database) error { } func (v *Vote) CreateVote(db *s.Database) error { - err := createVote(db, v) + var defaultEarlyVoteLength = 1 + err := createVote(db, v) if checkError(err) { return err } proposal, err := getProposal(db, v.Proposal_id) - if checkError(err) { return err } - err = checkForEarlyVoteAchievement(db, v, proposal) + isEarlyVote := v.Created_at.Before(proposal.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength))) - if checkError(err) { - return err + if isEarlyVote { + err = addEarlyVoteAchievement(db, v, proposal) + if checkError(err) { + return err + } } - err = checkForStreakAchievement(db, v, proposal) - + err = addStreakAchievement(db, v, proposal) return err } @@ -351,93 +358,48 @@ func getProposal(db *s.Database, proposalId int) (Proposal, error) { return proposal, err } -func checkForEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error { - var defaultEarlyVoteLength = 1 - - isEarlyVote := v.Created_at.Before(p.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength))) +func addEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error { - if isEarlyVote { - //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) - err := db.Conn.QueryRow(db.Context, - ` - INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals, details) + //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) + 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, EarlyVote, p.Community_id, []int{v.Proposal_id}, earlyVoteDetails).Scan(&v.ID) - if checkErrorIgnoreNoRows(err) { - return err - } + if checkErrorIgnoreNoRows(err) { + return err } return nil } -func checkForStreakAchievement(db *s.Database, v *Vote, p Proposal) error { +func addStreakAchievement(db *s.Database, v *Vote, p Proposal) error { var defaultStreakLength = 3 - // Determine if this vote is part of a streak - // Proposals with the user address count as a vote for that proposal - // NULL means the user did not vote - sql := fmt.Sprintf(` - SELECT p.id as id, - CASE WHEN v.addr is NULL THEN '' ELSE v.addr END as addr - FROM proposals p - LEFT OUTER JOIN ( - SELECT * FROM votes where addr = '%s' - ) v ON v.proposal_id = p.id - where p.community_id = $1 - ORDER BY start_time ASC - `, v.Addr) - - var votingStreak []struct { - ID uint64 - Addr string - } - err := pgxscan.Select(db.Context, db.Conn, &votingStreak, sql, p.Community_id) - + votingStreak, err := getUserVotingStreak(db, v.Addr, p.Community_id) if checkError(err) { return err } if len(votingStreak) >= defaultStreakLength { var proposals []uint64 - i := 0 - for i < len(votingStreak) { - v := votingStreak[i] - if v.Addr != "" { - proposals = append(proposals, v.ID) + for i, vote := range votingStreak { + 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 == "")) { - //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)), ","), "[]")) - var sql string if len(proposals) == defaultStreakLength { - // Add default streak, or do nothing if streak already exists - sql = ` - INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals, details) - VALUES($1, $2, $3, $4, $5) - ON CONFLICT (details) - DO NOTHING - ` - err = db.Conn.QueryRow(db.Context, sql, v.Addr, Streak, p.Community_id, proposals, currentStreakDetails).Scan(&v.ID) + err = addDefaultStreak(db, vote.Addr, p.Community_id, proposals, currentStreakDetails) } else { - // If previous streak exists, then update with new streak details - // e.g. If previous streak = 1,2,3 and current streak = 1,2,3,4 then update row with current streak details - previousStreakDetails := fmt.Sprintf("%s:%s:%d:%s", Streak, v.Addr, p.Community_id, strings.Trim(strings.Join(strings.Fields(fmt.Sprint(proposals[:len(proposals)-1])), ","), "[]")) - sql = ` - INSERT INTO community_users_achievements(addr, achievement_type, community_id, proposals, details) - VALUES($1, $2, $3, $4, $5) - ON CONFLICT (details) - DO UPDATE SET proposals = $4, details = $6 - RETURNING id - ` - err = db.Conn.QueryRow(db.Context, sql, v.Addr, Streak, p.Community_id, proposals, previousStreakDetails, currentStreakDetails).Scan(&v.ID) + err = addOrUpdateStreak(db, vote.Addr, p.Community_id, proposals, currentStreakDetails) } if checkErrorIgnoreNoRows(err) { @@ -452,14 +414,57 @@ func checkForStreakAchievement(db *s.Database, v *Vote, p Proposal) error { if v.Addr == "" { proposals = nil } - - i += 1 } } 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 + // NULL means the user did not vote + sql := fmt.Sprintf(` + SELECT p.id as proposal_id, + CASE WHEN v.addr is NULL THEN '' ELSE v.addr END as addr + FROM proposals p + LEFT OUTER JOIN ( + SELECT * FROM votes where addr = '%s' + ) v ON v.proposal_id = p.id + where p.community_id = $1 + ORDER BY start_time ASC + `, addr) + var votingStreak []VotingStreak + err := pgxscan.Select(db.Context, db.Conn, &votingStreak, sql, communityId) + return votingStreak, err +} + +func addDefaultStreak(db *s.Database, addr string, communityId int, proposals []uint64, details string) error { + v := new(Vote) + sql := ` + INSERT INTO user_achievements(addr, achievement_type, community_id, proposals, details) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (details) + DO NOTHING + ` + return db.Conn.QueryRow(db.Context, sql, addr, Streak, communityId, proposals, details).Scan(&v.ID) +} + +func addOrUpdateStreak(db *s.Database, addr string, communityId int, proposals []uint64, details string) error { + v := new(Vote) + // If previous streak exists, then update with new streak details, or insert new streak + // e.g. If previous streak = 1,2,3 and current streak = 1,2,3,4 then update row with current streak details + previousStreakDetails := fmt.Sprintf("%s:%s:%d:%s", Streak, addr, communityId, strings.Trim(strings.Join(strings.Fields(fmt.Sprint(proposals[:len(proposals)-1])), ","), "[]")) + sql := ` + INSERT INTO user_achievements(addr, achievement_type, community_id, proposals, details) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (details) + DO UPDATE SET proposals = $4, details = $6 + RETURNING id + ` + return db.Conn.QueryRow(db.Context, sql, addr, Streak, communityId, proposals, previousStreakDetails, details).Scan(&v.ID) +} + func checkError(err error) bool { return err != nil } diff --git a/backend/main/test_utils/leaderboard_utils.go b/backend/main/test_utils/leaderboard_utils.go index 390f27757..2a031c77f 100644 --- a/backend/main/test_utils/leaderboard_utils.go +++ b/backend/main/test_utils/leaderboard_utils.go @@ -1,52 +1,84 @@ package test_utils -func (otu *OverflowTestUtils) GenerateLeaderboardBaseCase(communityId int) { - proposalIds := otu.AddActiveProposals(communityId, 3) +import "strconv" + +func (otu *OverflowTestUtils) GenerateVotes(communityId int, numProposals int, numUsers int) { + if numProposals == 0 { + panic("0 invalid value for numProposals") + } + if numUsers == 0 { + panic("0 invalid value for numUsers") + } + + proposalIds := otu.AddActiveProposals(communityId, numProposals) voteChoice := "a" - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) + for _, id := range proposalIds { + for i := 1; i <= numUsers; i++ { + otu.CreateVoteAPI(id, otu.GenerateValidVotePayload("user"+strconv.Itoa(i), id, voteChoice)) + } + } } -func (otu *OverflowTestUtils) GenerateLeaderboardWithEarlyVotes(communityId int) { - proposalIds := otu.AddActiveProposalsWithStartTimeNow(communityId, 2) +func (otu *OverflowTestUtils) GenerateEarlyVoteAchievements(communityId int, numProposals int, numUsers int) { + if numProposals == 0 { + panic("0 invalid value for numProposals") + } + if numUsers == 0 { + panic("0 invalid value for numUsers") + } + + proposalIds := otu.AddActiveProposalsWithStartTimeNow(communityId, numProposals) voteChoice := "a" - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) + for _, id := range proposalIds { + for i := 1; i <= numUsers; i++ { + otu.CreateVoteAPI(id, otu.GenerateValidVotePayload("user"+strconv.Itoa(i), id, voteChoice)) + } + } } -func (otu *OverflowTestUtils) GenerateLeaderboardWithSingleStreaks(communityId int) { - proposalIds := otu.AddActiveProposals(communityId, 4) - voteChoice := "a" +func (otu *OverflowTestUtils) GenerateSingleStreakAchievements(communityId int, streakLengths []int) { + if len(streakLengths) == 0 { + panic("Must have at least one streak length") + } - // single streak length of 3 - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + proposalIds := otu.AddActiveProposals(communityId, max(streakLengths)) + voteChoice := "a" - // single streak length of 4 - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user2", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user2", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user2", proposalIds[2], voteChoice)) - otu.CreateVoteAPI(proposalIds[3], otu.GenerateValidVotePayload("user2", proposalIds[3], voteChoice)) + for i, l := range streakLengths { + for j := 0; j < l; j++ { + otu.CreateVoteAPI(proposalIds[j], otu.GenerateValidVotePayload("user"+strconv.Itoa(i+1), proposalIds[j], voteChoice)) + } + } } -func (otu *OverflowTestUtils) GenerateLeaderboardWithMultiStreaks(communityId int) { - proposalIds := otu.AddActiveProposals(communityId, 8) +func (otu *OverflowTestUtils) GenerateMultiStreakAchievements(communityId int, streakLengths []int) { + if len(streakLengths) < 2 { + panic("Must have at least two streak lengths") + } + + // create enough proposals for streak lengths and gaps to create multiple separate streaks + numProposals := max(streakLengths)*len(streakLengths) + len(streakLengths) + proposalIds := otu.AddActiveProposals(communityId, numProposals) voteChoice := "a" - // First Streak - otu.CreateVoteAPI(proposalIds[0], otu.GenerateValidVotePayload("user1", proposalIds[0], voteChoice)) - otu.CreateVoteAPI(proposalIds[1], otu.GenerateValidVotePayload("user1", proposalIds[1], voteChoice)) - otu.CreateVoteAPI(proposalIds[2], otu.GenerateValidVotePayload("user1", proposalIds[2], voteChoice)) + i := 0 + for _, l := range streakLengths { + for j := 0; j < l; j++ { + otu.CreateVoteAPI(proposalIds[i], otu.GenerateValidVotePayload("user1", proposalIds[i], voteChoice)) + i++ + } + i++ // skip a proposal to start next streak + } +} - // Second Streak - otu.CreateVoteAPI(proposalIds[5], otu.GenerateValidVotePayload("user1", proposalIds[5], voteChoice)) - otu.CreateVoteAPI(proposalIds[6], otu.GenerateValidVotePayload("user1", proposalIds[6], voteChoice)) - otu.CreateVoteAPI(proposalIds[7], otu.GenerateValidVotePayload("user1", proposalIds[7], voteChoice)) +func max(s []int) int { + var m int + for i, v := range s { + if i == 0 || v > m { + m = v + } + } + return m } diff --git a/backend/migrations/000020_add_achievements_details_column.down.sql b/backend/migrations/000020_add_achievements_details_column.down.sql index e0820cdf1..eee75d540 100644 --- a/backend/migrations/000020_add_achievements_details_column.down.sql +++ b/backend/migrations/000020_add_achievements_details_column.down.sql @@ -1,2 +1,3 @@ -ALTER TABLE community_users_achievements -DROP COLUMN details; +ALTER TABLE user_achievements RENAME TO community_users_achievements; +ALTER TABLE community_users_achievements DROP COLUMN details; + diff --git a/backend/migrations/000020_add_achievements_details_column.up.sql b/backend/migrations/000020_add_achievements_details_column.up.sql index 859570fc1..800ce37e4 100644 --- a/backend/migrations/000020_add_achievements_details_column.up.sql +++ b/backend/migrations/000020_add_achievements_details_column.up.sql @@ -1,2 +1,3 @@ ALTER TABLE community_users_achievements ADD details VARCHAR NOT NULL; -ALTER TABLE community_users_achievements ADD UNIQUE (details); \ No newline at end of file +ALTER TABLE community_users_achievements ADD UNIQUE (details); +ALTER TABLE community_users_achievements RENAME TO user_achievements; \ No newline at end of file From 672d068dffe8a5d3178335818cf6cb8dcfea1c7e Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 6 Jul 2022 17:40:55 -0500 Subject: [PATCH 09/11] CAS-141: Add WinningVote Bonus --- backend/main/leaderboard_test.go | 53 +++++++++++++++-- backend/main/models/community_users.go | 58 +++++++++++++------ backend/main/models/vote.go | 37 ++++++++++-- backend/main/server/app.go | 4 ++ .../main/strategies/token_weighted_default.go | 1 - backend/main/test_utils/factory.go | 10 ++++ backend/main/test_utils/leaderboard_utils.go | 18 +++++- backend/main/test_utils/proposal_utils.go | 15 +++++ 8 files changed, 165 insertions(+), 31 deletions(-) diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index 310ff47f2..0d68cd1c5 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetCommunityLeaderboard(t *testing.T) { +func TestGetLeaderboard(t *testing.T) { clearTable("communities") clearTable("community_users") clearTable("user_achievements") @@ -43,7 +43,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") @@ -72,7 +72,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") @@ -94,15 +94,15 @@ 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 + receivedUser1Score := p.Data[1].Score + receivedUser2Score := p.Data[0].Score assert.Equal(t, expectedUsers, len(p.Data)) assert.Equal(t, expectedUser1Score, receivedUser1Score) assert.Equal(t, expectedUser2Score, receivedUser2Score) } -func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) { +func TestGetLeaderboardWithMultiStreaks(t *testing.T) { clearTable("communities") clearTable("community_users") clearTable("user_achievements") @@ -129,3 +129,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("community_users_achievements") + clearTable("proposals") + clearTable("proposal_results") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + winningVoteBonus := 1 + + proposalId := otu.GenerateWinningVoteAchievement(communityId, "one-address-one-vote") + otu.UpdateProposalStatus(proposalId, "closed") + 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) +} diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 2d493ded9..27e5d56b0 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -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 { @@ -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) @@ -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) } @@ -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 + CASE WHEN a.early_vote is NULL THEN 0 ELSE a.early_vote END as early_vote, + CASE WHEN b.streak is NULL THEN 0 ELSE b.streak END as streak, + CASE WHEN c.winning_vote is NULL THEN 0 ELSE c.winning_vote END 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) diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index 94b5c54db..c9a42d56e 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -221,13 +221,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 checkError(err) { return err } } - err = addStreakAchievement(db, v, proposal) + err = AddStreakAchievement(db, v, proposal) return err } @@ -358,7 +358,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) @@ -378,7 +378,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) @@ -420,6 +420,35 @@ 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) { + 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 diff --git a/backend/main/server/app.go b/backend/main/server/app.go index 9f3aaf44e..5754fc024 100644 --- a/backend/main/server/app.go +++ b/backend/main/server/app.go @@ -325,6 +325,10 @@ func (a *App) getResultsForProposal(w http.ResponseWriter, r *http.Request) { return } + if *p.Status == "closed" { + models.AddWinningVoteAchievement(a.DB, votes, proposalResults, p.Community_id) + } + // Send Proposal Results respondWithJSON(w, http.StatusOK, proposalResults) } diff --git a/backend/main/strategies/token_weighted_default.go b/backend/main/strategies/token_weighted_default.go index 67671057a..46bcc6579 100644 --- a/backend/main/strategies/token_weighted_default.go +++ b/backend/main/strategies/token_weighted_default.go @@ -46,7 +46,6 @@ func (s *TokenWeightedDefault) TallyVotes( votes []*models.VoteWithBalance, p *models.ProposalResults, ) (models.ProposalResults, error) { - //tally votes for _, vote := range votes { if vote.PrimaryAccountBalance != nil { diff --git a/backend/main/test_utils/factory.go b/backend/main/test_utils/factory.go index 1ca69e4df..88775fe4d 100644 --- a/backend/main/test_utils/factory.go +++ b/backend/main/test_utils/factory.go @@ -259,6 +259,16 @@ func (otu *OverflowTestUtils) AddActiveProposalsWithStartTimeNow(cId int, count return retIds } +func (otu *OverflowTestUtils) UpdateProposalStatus(pId int, status string) { + _, err := otu.A.DB.Conn.Exec(otu.A.DB.Context, + ` + UPDATE proposals SET status = $2 WHERE id = $1 + `, pId, status) + if err != nil { + log.Error().Err(err).Msg("update proposal status DB err") + } +} + func (otu *OverflowTestUtils) AddLists(cId int, count int) []int { if count < 1 { count = 1 diff --git a/backend/main/test_utils/leaderboard_utils.go b/backend/main/test_utils/leaderboard_utils.go index 2a031c77f..625dea1f9 100644 --- a/backend/main/test_utils/leaderboard_utils.go +++ b/backend/main/test_utils/leaderboard_utils.go @@ -1,6 +1,8 @@ package test_utils -import "strconv" +import ( + "strconv" +) func (otu *OverflowTestUtils) GenerateVotes(communityId int, numProposals int, numUsers int) { if numProposals == 0 { @@ -73,6 +75,20 @@ func (otu *OverflowTestUtils) GenerateMultiStreakAchievements(communityId int, s } } +func (otu *OverflowTestUtils) GenerateWinningVoteAchievement(communityId int, strategy string) int { + proposalIds, _ := otu.AddProposalsForStrategy(communityId, strategy, 1) + proposalId := proposalIds[0] + winningChoice := "a" + losingChoice := "b" + + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user1", proposalId, losingChoice)) + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user2", proposalId, winningChoice)) + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user3", proposalId, winningChoice)) + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user4", proposalId, winningChoice)) + + return proposalId +} + func max(s []int) int { var m int for i, v := range s { diff --git a/backend/main/test_utils/proposal_utils.go b/backend/main/test_utils/proposal_utils.go index dd5c84e3c..3ab6a9d97 100644 --- a/backend/main/test_utils/proposal_utils.go +++ b/backend/main/test_utils/proposal_utils.go @@ -98,6 +98,21 @@ func (otu *OverflowTestUtils) GenerateCancelProposalStruct( return &payload } +func (otu *OverflowTestUtils) GenerateClosedProposalStruct( + signer string, + proposalId int, +) *models.UpdateProposalRequestPayload { + payload := models.UpdateProposalRequestPayload{Status: "closed"} + timestamp := fmt.Sprint(time.Now().UnixNano() / int64(time.Millisecond)) + compositeSignatures := otu.GenerateCompositeSignatures(signer, timestamp) + account, _ := otu.O.State.Accounts().ByName(fmt.Sprintf("emulator-%s", signer)) + payload.Signing_addr = fmt.Sprintf("0x%s", account.Address().String()) + payload.Timestamp = timestamp + payload.Composite_signatures = compositeSignatures + + return &payload +} + func (otu *OverflowTestUtils) GenerateProposalPayload(signer string, proposal *models.Proposal) *models.Proposal { timestamp := fmt.Sprint(time.Now().UnixNano() / int64(time.Millisecond)) compositeSignatures := otu.GenerateCompositeSignatures(signer, timestamp) From 57221c3b5cfa8732a810491b15ef9078871289e4 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 7 Jul 2022 14:03:21 -0500 Subject: [PATCH 10/11] CAS-141: Update achievements query to use COALESCE --- backend/main/leaderboard_test.go | 20 ++++++++++++------- backend/main/models/community_users.go | 6 +++--- backend/main/models/vote.go | 10 +++++++++- ..._add_achievements_details_column.down.sql} | 0 ...21_add_achievements_details_column.up.sql} | 0 5 files changed, 25 insertions(+), 11 deletions(-) rename backend/migrations/{000020_add_achievements_details_column.down.sql => 000021_add_achievements_details_column.down.sql} (100%) rename backend/migrations/{000020_add_achievements_details_column.up.sql => 000021_add_achievements_details_column.up.sql} (100%) diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index 0d68cd1c5..e0663baf7 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "net/http" + "sort" "testing" "github.com/DapperCollectives/CAST/backend/main/test_utils" @@ -83,8 +84,8 @@ func TestGetLeaderboardWithSingleStreak(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) @@ -94,12 +95,17 @@ func TestGetLeaderboardWithSingleStreak(t *testing.T) { var p test_utils.PaginatedResponseWithLeaderboardUser json.Unmarshal(response.Body.Bytes(), &p) - receivedUser1Score := p.Data[1].Score - receivedUser2Score := p.Data[0].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 TestGetLeaderboardWithMultiStreaks(t *testing.T) { @@ -133,7 +139,7 @@ func TestGetLeaderboardWithMultiStreaks(t *testing.T) { func TestGetLeaderboardWithWinningVote(t *testing.T) { clearTable("communities") clearTable("community_users") - clearTable("community_users_achievements") + clearTable("user_achievements") clearTable("proposals") clearTable("proposal_results") clearTable("votes") diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 27e5d56b0..39bb7e9ce 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -279,9 +279,9 @@ func getUserAchievements(db *s.Database, communityId int, start int, count int) 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, - CASE WHEN b.streak is NULL THEN 0 ELSE b.streak END as streak, - CASE WHEN c.winning_vote is NULL THEN 0 ELSE c.winning_vote END as winning_vote + 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 ( diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index 5d2914fe0..4f6bc5d01 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "errors" "fmt" + "sort" "strconv" "strings" "time" @@ -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 { @@ -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)), ","), "[]")) diff --git a/backend/migrations/000020_add_achievements_details_column.down.sql b/backend/migrations/000021_add_achievements_details_column.down.sql similarity index 100% rename from backend/migrations/000020_add_achievements_details_column.down.sql rename to backend/migrations/000021_add_achievements_details_column.down.sql diff --git a/backend/migrations/000020_add_achievements_details_column.up.sql b/backend/migrations/000021_add_achievements_details_column.up.sql similarity index 100% rename from backend/migrations/000020_add_achievements_details_column.up.sql rename to backend/migrations/000021_add_achievements_details_column.up.sql From 4cef23eba21893499573eb546602b204f1c85f4f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 12 Jul 2022 13:58:56 -0500 Subject: [PATCH 11/11] CAS-141: Update proposal status check to use Computed_status --- backend/main/leaderboard_test.go | 5 +++-- backend/main/models/community_users.go | 12 ++++++------ backend/main/server/app.go | 2 +- backend/main/test_utils/factory.go | 8 ++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index e0663baf7..f66226c37 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -5,6 +5,7 @@ import ( "net/http" "sort" "testing" + "time" "github.com/DapperCollectives/CAST/backend/main/test_utils" "github.com/stretchr/testify/assert" @@ -96,7 +97,7 @@ func TestGetLeaderboardWithSingleStreak(t *testing.T) { json.Unmarshal(response.Body.Bytes(), &p) // ensure scores ordered for assert - sort.Slice(p.Data, func(i,j int) bool { + sort.Slice(p.Data, func(i, j int) bool { return p.Data[i].Score < p.Data[j].Score }) @@ -147,7 +148,7 @@ func TestGetLeaderboardWithWinningVote(t *testing.T) { winningVoteBonus := 1 proposalId := otu.GenerateWinningVoteAchievement(communityId, "one-address-one-vote") - otu.UpdateProposalStatus(proposalId, "closed") + otu.UpdateProposalEndTime(proposalId, time.Now().UTC()) otu.GetProposalResultsAPI(proposalId) response := otu.GetCommunityLeaderboardAPI(communityId) diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 39bb7e9ce..a713d8603 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -278,15 +278,15 @@ func getUserAchievements(db *s.Database, communityId int, start int, count int) // substituted in string first. sql := fmt.Sprintf( ` - SELECT v.addr as address, count(*) as num_votes, + 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 + 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 + $$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$$ @@ -294,7 +294,7 @@ func getUserAchievements(db *s.Database, communityId int, start int, count int) ) a ON v.addr = a.address LEFT OUTER JOIN ( SELECT * FROM crosstab( - $$SELECT addr, achievement_type, count(*) FROM user_achievements + $$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$$ @@ -302,7 +302,7 @@ func getUserAchievements(db *s.Database, communityId int, start int, count int) ) b ON v.addr = b.address LEFT OUTER JOIN ( SELECT * FROM crosstab( - $$SELECT addr, achievement_type, count(*) FROM user_achievements + $$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$$ diff --git a/backend/main/server/app.go b/backend/main/server/app.go index 5754fc024..856d1fce5 100644 --- a/backend/main/server/app.go +++ b/backend/main/server/app.go @@ -325,7 +325,7 @@ func (a *App) getResultsForProposal(w http.ResponseWriter, r *http.Request) { return } - if *p.Status == "closed" { + if *p.Computed_status == "closed" { models.AddWinningVoteAchievement(a.DB, votes, proposalResults, p.Community_id) } diff --git a/backend/main/test_utils/factory.go b/backend/main/test_utils/factory.go index 88775fe4d..9b3bfc494 100644 --- a/backend/main/test_utils/factory.go +++ b/backend/main/test_utils/factory.go @@ -259,13 +259,13 @@ func (otu *OverflowTestUtils) AddActiveProposalsWithStartTimeNow(cId int, count return retIds } -func (otu *OverflowTestUtils) UpdateProposalStatus(pId int, status string) { +func (otu *OverflowTestUtils) UpdateProposalEndTime(pId int, endTime time.Time) { _, err := otu.A.DB.Conn.Exec(otu.A.DB.Context, ` - UPDATE proposals SET status = $2 WHERE id = $1 - `, pId, status) + UPDATE proposals SET end_time = $2 WHERE id = $1 + `, pId, endTime) if err != nil { - log.Error().Err(err).Msg("update proposal status DB err") + log.Error().Err(err).Msg("update proposal end_time DB err") } }