diff --git a/notifications/vote_reminders.go b/notifications/vote_reminders.go index 9e752133..3611a22c 100644 --- a/notifications/vote_reminders.go +++ b/notifications/vote_reminders.go @@ -52,7 +52,7 @@ func vrCheck() error { continue } - vi, err := votes.EntityVoteCheck(state.Context, userId, targetId, targetType) + vi, err := votes.EntityVoteCheck(state.Context, state.Pool, userId, targetId, targetType) if err != nil { state.Logger.Error("Error checking votes of entity", zap.Error(err), zap.String("userId", userId), zap.String("targetId", targetId), zap.String("targetType", targetType)) @@ -60,7 +60,7 @@ func vrCheck() error { } if !vi.HasVoted { - entityInfo, err := votes.GetEntityInfo(state.Context, targetId, targetType) + entityInfo, err := votes.GetEntityInfo(state.Context, state.Pool, targetId, targetType) if err != nil { state.Logger.Error("Error finding bot info", zap.Error(err), zap.String("targetId", targetId), zap.String("targetType", targetType)) diff --git a/routes/reminders/endpoints/put_user_reminders/route.go b/routes/reminders/endpoints/put_user_reminders/route.go index 19e6bb3c..171689d0 100644 --- a/routes/reminders/endpoints/put_user_reminders/route.go +++ b/routes/reminders/endpoints/put_user_reminders/route.go @@ -54,7 +54,7 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { return uapi.DefaultResponse(http.StatusBadRequest) } - entityInfo, err := votes.GetEntityInfo(d.Context, targetId, targetType) + entityInfo, err := votes.GetEntityInfo(d.Context, state.Pool, targetId, targetType) if err != nil { state.Logger.Error("Error getting entity info", zap.Error(err), zap.String("target_id", targetId), zap.String("target_type", targetType)) diff --git a/routes/votes/endpoints/get_user_entity_votes/route.go b/routes/votes/endpoints/get_user_entity_votes/route.go index 0c6fec46..4de55d90 100644 --- a/routes/votes/endpoints/get_user_entity_votes/route.go +++ b/routes/votes/endpoints/get_user_entity_votes/route.go @@ -18,7 +18,7 @@ import ( func Docs() *docs.Doc { return &docs.Doc{ Summary: "Get User Entity Votes", - Description: "Gets a vote a user has made for an entity. Note that for compatibility, a trailing 's' is removed", + Description: "Gets all votes a user has made for an entity. Note that for compatibility, a trailing 's' is removed", Params: []docs.Parameter{ { Name: "uid", @@ -60,7 +60,7 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { targetType = strings.TrimSuffix(targetType, "s") - uv, err := votes.EntityVoteCheck(d.Context, uid, targetId, targetType) + uv, err := votes.EntityVoteCheck(d.Context, state.Pool, uid, targetId, targetType) if err != nil { state.Logger.Error("Failed to get user entity votes", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) diff --git a/routes/votes/endpoints/put_user_entity_votes/route.go b/routes/votes/endpoints/put_user_entity_votes/route.go index 5658d497..a6836d4a 100644 --- a/routes/votes/endpoints/put_user_entity_votes/route.go +++ b/routes/votes/endpoints/put_user_entity_votes/route.go @@ -132,15 +132,18 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { targetType = strings.TrimSuffix(targetType, "s") - upvote := r.URL.Query().Get("upvote") + // Check if upvote query parameter is valid + upvoteStr := r.URL.Query().Get("upvote") - if upvote != "true" && upvote != "false" { + if upvoteStr != "true" && upvoteStr != "false" { return uapi.HttpResponse{ Status: http.StatusBadRequest, - Json: types.ApiError{Message: "upvote must be either true or false"}, + Json: types.ApiError{Message: "upvote must be either `true` or `false`"}, } } + upvote := upvoteStr == "true" + // Check if user is allowed to even make a vote right now. var voteBanned bool @@ -148,7 +151,10 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { if err != nil { state.Logger.Error("Failed to check if user is vote banned", zap.Error(err), zap.String("userId", uid)) - return uapi.DefaultResponse(http.StatusInternalServerError) + return uapi.HttpResponse{ + Status: http.StatusBadRequest, + Json: types.ApiError{Message: "Error checking if user is vote banned: " + err.Error()}, + } } if voteBanned { @@ -158,18 +164,20 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { } } - // Handle entity specific checks here, such as ensuring the entity actually exists - switch targetType { - case "bot": - if upvote == "false" { - return uapi.HttpResponse{ - Status: http.StatusNotImplemented, - Json: types.ApiError{Message: "Downvoting bots is not implemented yet"}, - } + // Create a new entity vote + tx, err := state.Pool.Begin(d.Context) + + if err != nil { + state.Logger.Error("Failed to create transaction [put_user_entity_votes]", zap.Error(err)) + return uapi.HttpResponse{ + Status: http.StatusInternalServerError, + Json: types.ApiError{Message: "Failed to create transaction: " + err.Error()}, } } - entityInfo, err := votes.GetEntityInfo(d.Context, targetId, targetType) + defer tx.Rollback(d.Context) + + entityInfo, err := votes.GetEntityInfo(d.Context, tx, targetId, targetType) if err != nil { state.Logger.Error("Failed to fetch entity info", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) @@ -180,46 +188,77 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { } // Now check the vote - vi, err := votes.EntityVoteCheck(d.Context, uid, targetId, targetType) + vi, err := votes.EntityVoteCheck(d.Context, tx, uid, targetId, targetType) if err != nil { state.Logger.Error("Failed to check vote", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) return uapi.DefaultResponse(http.StatusInternalServerError) } - if vi.HasVoted { - timeStr := fmt.Sprintf("%02d hours, %02d minutes. %02d seconds", vi.Wait.Hours, vi.Wait.Minutes, vi.Wait.Seconds) - - if len(vi.ValidVotes) > 1 { - return uapi.HttpResponse{ - Status: http.StatusBadRequest, - Json: types.ApiError{Message: "Your last vote was a double vote, calm down?: " + timeStr}, - } + if !vi.VoteInfo.SupportsDownvotes && !upvote { + return uapi.HttpResponse{ + Status: http.StatusBadRequest, + Json: types.ApiError{Message: "This entity does not support downvotes"}, } + } + if !vi.VoteInfo.SupportsUpvotes && upvote { return uapi.HttpResponse{ Status: http.StatusBadRequest, - Json: types.ApiError{Message: "Please wait " + timeStr + " before voting again"}, + Json: types.ApiError{Message: "This entity does not support upvotes"}, } } - // Create a new entity vote - tx, err := state.Pool.Begin(d.Context) + if vi.HasVoted { + // lacking MultipleVotes means that there can only be one vote per user for the entity + if !vi.VoteInfo.MultipleVotes { + if vi.ValidVotes[0].Upvote == upvote { + return uapi.HttpResponse{ + Status: http.StatusBadRequest, + Json: types.ApiError{Message: "You have already voted for this entity before!"}, + } + } else { + // Remove all old votes by said user + _, err = tx.Exec(d.Context, "DELETE FROM entity_votes WHERE author = $1 AND target_id = $2 AND target_type = $3", uid, targetId, targetType) + + if err != nil { + state.Logger.Error("Failed to delete old vote", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) + return uapi.HttpResponse{ + Status: http.StatusInternalServerError, + Json: types.ApiError{Message: "Failed to delete old vote: " + err.Error()}, + } + } + } + } else { + var timeStr string + if vi.Wait != nil { + timeStr = fmt.Sprintf("%02d hours, %02d minutes. %02d seconds", vi.Wait.Hours, vi.Wait.Minutes, vi.Wait.Seconds) + } else { + timeStr = "a while" + } - if err != nil { - state.Logger.Error("Failed to create transaction [put_user_entity_votes]", zap.Error(err)) - return uapi.DefaultResponse(http.StatusInternalServerError) - } + if len(vi.ValidVotes) > 1 { + return uapi.HttpResponse{ + Status: http.StatusBadRequest, + Json: types.ApiError{Message: "Your last vote was a double vote, calm down for " + timeStr + "?"}, + } + } - defer tx.Rollback(d.Context) + return uapi.HttpResponse{ + Status: http.StatusBadRequest, + Json: types.ApiError{Message: "Please wait " + timeStr + " before voting again"}, + } + } + } // Keep adding votes until, but not including vi.VoteInfo.PerUser - for i := 0; i < vi.VoteInfo.PerUser; i++ { - _, err = tx.Exec(d.Context, "INSERT INTO entity_votes (author, target_id, target_type, upvote, vote_num) VALUES ($1, $2, $3, $4, $5)", uid, targetId, targetType, upvote == "true", i) + err = votes.EntityGiveVotes(d.Context, tx, upvote, uid, targetType, targetId, vi.VoteInfo) - if err != nil { - state.Logger.Error("Failed to insert vote", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType), zap.String("upvote", upvote)) - return uapi.DefaultResponse(http.StatusInternalServerError) + if err != nil { + state.Logger.Error("Failed to give votes", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) + return uapi.HttpResponse{ + Status: http.StatusInternalServerError, + Json: types.ApiError{Message: "Failed to give votes: " + err.Error()}, } } @@ -228,7 +267,10 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { if err != nil { state.Logger.Error("Failed to fetch new vote count", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) - return uapi.DefaultResponse(http.StatusInternalServerError) + return uapi.HttpResponse{ + Status: http.StatusInternalServerError, + Json: types.ApiError{Message: "Failed to fetch new vote count: " + err.Error()}, + } } // Commit transaction @@ -240,59 +282,61 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { } // Fetch user info to log it to server - userObj, err := dovewing.GetUser(d.Context, uid, state.DovewingPlatformDiscord) + go func() { + userObj, err := dovewing.GetUser(d.Context, uid, state.DovewingPlatformDiscord) - if err != nil { - state.Logger.Error("Failed to fetch user info", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) - return uapi.DefaultResponse(http.StatusInternalServerError) - } + if err != nil { + state.Logger.Error("Failed to fetch user info", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) + return + } - _, err = state.Discord.ChannelMessageSendComplex(state.Config.Channels.VoteLogs, &discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{ - { - URL: entityInfo.URL, - Thumbnail: &discordgo.MessageEmbedThumbnail{ - URL: entityInfo.Avatar, - }, - Title: "🎉 Vote Count Updated!", - Description: ":heart:" + userObj.DisplayName + " has voted for " + targetType + ": " + entityInfo.Name, - Color: 0x8A6BFD, - Fields: []*discordgo.MessageEmbedField{ - { - Name: "Vote Count:", - Value: strconv.Itoa(nvc), - Inline: true, - }, - { - Name: "Votes Added:", - Value: strconv.Itoa(vi.VoteInfo.PerUser), - Inline: true, + _, err = state.Discord.ChannelMessageSendComplex(state.Config.Channels.VoteLogs, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + URL: entityInfo.URL, + Thumbnail: &discordgo.MessageEmbedThumbnail{ + URL: entityInfo.Avatar, }, - { - Name: "User ID:", - Value: userObj.ID, - Inline: true, - }, - { - Name: "View " + targetType + "'s page", - Value: "[View " + entityInfo.Name + "](" + entityInfo.URL + ")", - Inline: true, - }, - { - Name: "Vote Page", - Value: "[Vote for " + entityInfo.Name + "](" + entityInfo.VoteURL + ")", - Inline: true, + Title: "🎉 Vote Count Updated!", + Description: ":heart:" + userObj.DisplayName + " has voted for " + targetType + ": " + entityInfo.Name, + Color: 0x8A6BFD, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Vote Count:", + Value: strconv.Itoa(nvc), + Inline: true, + }, + { + Name: "Votes Added:", + Value: strconv.Itoa(vi.VoteInfo.PerUser), + Inline: true, + }, + { + Name: "User ID:", + Value: userObj.ID, + Inline: true, + }, + { + Name: "View " + targetType + "'s page", + Value: "[View " + entityInfo.Name + "](" + entityInfo.URL + ")", + Inline: true, + }, + { + Name: "Vote Page", + Value: "[Vote for " + entityInfo.Name + "](" + entityInfo.VoteURL + ")", + Inline: true, + }, }, }, }, - }, - }) + }) - if err != nil { - state.Logger.Error("Failed to send vote log message", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) - } + if err != nil { + state.Logger.Error("Failed to send vote log message", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) + } + }() - // Send webhook in a goroutine refunding the vote if it failed + // Send webhook in a goroutine go func() { err = nil // Be sure error is empty before we start @@ -305,6 +349,10 @@ func Route(d uapi.RouteData, r *http.Request) uapi.HttpResponse { PerUser: vi.VoteInfo.PerUser, }, }) + + if err != nil { + state.Logger.Error("Failed to send webhook", zap.Error(err), zap.String("userId", uid), zap.String("targetId", targetId), zap.String("targetType", targetType)) + } }() return uapi.DefaultResponse(http.StatusNoContent) diff --git a/types/vote.go b/types/vote.go index 13ba80fe..2d3a92b7 100644 --- a/types/vote.go +++ b/types/vote.go @@ -26,8 +26,12 @@ type EntityVote struct { // Vote Info type VoteInfo struct { - PerUser int `json:"per_user" description:"The amount of votes a single vote creates on this entity"` - VoteTime uint16 `json:"vote_time" description:"The amount of time in hours until a user can vote again"` + PerUser int `json:"per_user" description:"The amount of votes a single vote creates on this entity"` + VoteTime uint16 `json:"vote_time" description:"The amount of time in hours until a user can vote again"` + VoteCredits bool `json:"vote_credits" description:"Whether or not the entity supports vote credits"` + MultipleVotes bool `json:"multiple_votes" description:"Whether or not the entity supports multiple votes per time interval"` + SupportsUpvotes bool `json:"supports_upvotes" description:"Whether or not the entity supports upvotes"` + SupportsDownvotes bool `json:"supports_downvotes" description:"Whether or not the entity supports downvotes"` } // Stores the hours, minutes and seconds until the user can vote again @@ -38,13 +42,14 @@ type VoteWait struct { } type ValidVote struct { - Upvote bool `json:"upvote" description:"Whether or not the vote was an upvote"` - CreatedAt time.Time `json:"created_at" description:"The time the vote was created"` + ID pgtype.UUID `json:"id" description:"The ID of the vote"` + Upvote bool `json:"upvote" description:"Whether or not the vote was an upvote"` + CreatedAt time.Time `json:"created_at" description:"The time the vote was created"` } // A user vote is a struct containing basic info on a users vote type UserVote struct { - HasVoted bool `json:"has_voted" description:"Whether or not the user has voted"` + HasVoted bool `json:"has_voted" description:"Whether or not the user has voted for the entity. If an entity supports multiple votes, this will be true if the user has voted in the last vote time, otherwise, it will be true if the user has voted at all"` ValidVotes []*ValidVote `json:"valid_votes" description:"Some information about a valid vote"` VoteInfo *VoteInfo `json:"vote_info" description:"Some information about the vote"` Wait *VoteWait `json:"wait" description:"The time until the user can vote again"` diff --git a/votes/common.go b/votes/common.go index f7564059..b44211cd 100644 --- a/votes/common.go +++ b/votes/common.go @@ -2,46 +2,179 @@ package votes import ( "context" + "errors" + "fmt" + "popplio/assetmanager" "popplio/state" "popplio/types" + "strconv" "time" + "github.com/infinitybotlist/eureka/dovewing" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" ) +type DbConn interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + func GetDoubleVote() bool { - return time.Now().Weekday() == time.Friday || time.Now().Weekday() == time.Saturday || time.Now().Weekday() == time.Sunday + weekday := time.Now().Weekday() + return weekday == time.Friday || weekday == time.Saturday || weekday == time.Sunday } -func GetVoteTime() uint16 { - if GetDoubleVote() { - return 6 - } else { - return 12 +type EntityInfo struct { + Name string + URL string + VoteURL string + Avatar string +} + +// GetEntityInfo returns information about the entity that is being voted for including vote bans etc. +func GetEntityInfo(ctx context.Context, c DbConn, targetId, targetType string) (*EntityInfo, error) { + // Handle entity specific checks here, such as ensuring the entity actually exists + switch targetType { + case "bot": + var botType string + var voteBanned bool + + err := c.QueryRow(ctx, "SELECT type, vote_banned FROM bots WHERE bot_id = $1", targetId).Scan(&botType, &voteBanned) + + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("bot not found") + } + + if err != nil { + return nil, fmt.Errorf("failed to fetch bot data for this vote: %w", err) + } + + if voteBanned { + return nil, errors.New("bot is vote banned and cannot be voted for right now") + } + + if botType != "approved" && botType != "certified" { + return nil, errors.New("bot is not approved or certified and cannot be voted for right now") + } + + botObj, err := dovewing.GetUser(ctx, targetId, state.DovewingPlatformDiscord) + + if err != nil { + return nil, err + } + + // Set entityInfo for log + return &EntityInfo{ + URL: state.Config.Sites.Frontend.Parse() + "/bot/" + targetId, + VoteURL: state.Config.Sites.Frontend.Parse() + "/bot/" + targetId + "/vote", + Name: botObj.Username, + Avatar: botObj.Avatar, + }, nil + case "pack": + return &EntityInfo{ + URL: state.Config.Sites.Frontend.Parse() + "/pack/" + targetId, + VoteURL: state.Config.Sites.Frontend.Parse() + "/pack/" + targetId, + Name: targetId, + Avatar: state.Config.Sites.CDN + "/avatars/default.webp", + }, nil + case "team": + var name string + var voteBanned bool + + err := c.QueryRow(ctx, "SELECT name, vote_banned FROM teams WHERE id = $1", targetId).Scan(&name, &voteBanned) + + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("team not found") + } + + if err != nil { + return nil, fmt.Errorf("failed to fetch team data for this vote: %w", err) + } + + if voteBanned { + return nil, errors.New("team is vote banned and cannot be voted for right now") + } + + avatar := assetmanager.AvatarInfo(assetmanager.AssetTargetTypeTeams, targetId) + + var avatarPath string + + if avatar.Exists { + avatarPath = state.Config.Sites.CDN + "/" + avatar.Path + "?ts=" + strconv.FormatInt(avatar.LastModified.Unix(), 10) + } else { + avatarPath = state.Config.Sites.CDN + "/" + avatar.DefaultPath + } + + // Set entityInfo for log + return &EntityInfo{ + URL: state.Config.Sites.Frontend.Parse() + "/team/" + targetId, + VoteURL: state.Config.Sites.Frontend.Parse() + "/team/" + targetId + "/vote", + Name: name, + Avatar: avatarPath, + }, nil + case "server": + var name, avatar string + var voteBanned bool + + err := c.QueryRow(ctx, "SELECT name, avatar, vote_banned FROM servers WHERE server_id = $1", targetId).Scan(&name, &avatar, &voteBanned) + + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("server not found") + } + + if err != nil { + return nil, fmt.Errorf("failed to fetch server data for this vote: %w", err) + } + + if voteBanned { + return nil, errors.New("server is vote banned and cannot be voted for right now") + } + + // Set entityInfo for log + return &EntityInfo{ + URL: state.Config.Sites.Frontend.Parse() + "/server/" + targetId, + VoteURL: state.Config.Sites.Frontend.Parse() + "/server/" + targetId + "/vote", + Name: name, + Avatar: avatar, + }, nil + case "blog": + return &EntityInfo{ + URL: state.Config.Sites.Frontend.Parse() + "/blog/" + targetId, + VoteURL: state.Config.Sites.Frontend.Parse() + "/blog/" + targetId, + Name: targetId, + Avatar: state.Config.Sites.CDN + "/avatars/default.webp", + }, nil + default: + return nil, errors.New("unimplemented target type:" + targetType) } } // Returns core vote info about the entity (such as the amount of cooldown time the entity has) // -// If user id is specified, then in the future special perks for the user will be returned as well -func EntityVoteInfo(ctx context.Context, userId, targetId, targetType string) (*types.VoteInfo, error) { - var defaultVoteEntity = types.VoteInfo{ - PerUser: func() int { - if GetDoubleVote() { - return 2 - } else { - return 1 - } - }(), - VoteTime: GetVoteTime(), +// # If user id is specified, then in the future special perks for the user will be returned as well +// +// If vote time is negative, then it is not possible to revote +func EntityVoteInfo(ctx context.Context, c DbConn, userId, targetId, targetType string) (*types.VoteInfo, error) { + var voteEntity = types.VoteInfo{ + PerUser: 1, // 1 vote per user + VoteTime: 12, // per day + MultipleVotes: true, // Multiple votes per time interval + VoteCredits: false, // Vote credits are not supported unless opted in + SupportsUpvotes: true, // Upvotes are supported (usually) + SupportsDownvotes: true, // Downvotes are supported (usually) } // Add other special cases of entities not following the basic voting system rules switch targetType { case "bot": + voteEntity.VoteCredits = true // Bots support vote credits + voteEntity.SupportsDownvotes = false // Bots cannot be downvoted + var premium bool - err := state.Pool.QueryRow(ctx, "SELECT premium FROM bots WHERE bot_id = $1", targetId).Scan(&premium) + err := c.QueryRow(ctx, "SELECT premium FROM bots WHERE bot_id = $1", targetId).Scan(&premium) if err != nil { return nil, err @@ -49,40 +182,70 @@ func EntityVoteInfo(ctx context.Context, userId, targetId, targetType string) (* // Premium bots get vote time of 4 if premium { - defaultVoteEntity.VoteTime = 4 + voteEntity.VoteTime = 4 + } else { + // Bot is not premium + if GetDoubleVote() { + voteEntity.PerUser = 2 // 2 votes per user + voteEntity.VoteTime = 6 // Half of the normal vote time + } } case "server": + voteEntity.VoteCredits = true + var premium bool - err := state.Pool.QueryRow(ctx, "SELECT premium FROM servers WHERE server_id = $1", targetId).Scan(&premium) + err := c.QueryRow(ctx, "SELECT premium FROM servers WHERE server_id = $1", targetId).Scan(&premium) if err != nil { return nil, err } - // Premium bots get vote time of 4 + // Premium servers get vote time of 4 if premium { - defaultVoteEntity.VoteTime = 4 + voteEntity.VoteTime = 4 + } else { + // Server is not premium + if GetDoubleVote() { + voteEntity.PerUser = 2 // 2 votes per user + voteEntity.VoteTime = 6 // Half of the normal vote time + } + } + case "blog": + voteEntity.MultipleVotes = false + voteEntity.PerUser = 1 // Only 1 vote per blog post + case "team": + // Teams cannot be premium yet + if GetDoubleVote() { + voteEntity.PerUser = 2 // 2 votes per user + voteEntity.VoteTime = 6 // Half of the normal vote time + } + case "pack": + // Packs cannot be premium yet + if GetDoubleVote() { + voteEntity.PerUser = 2 // 2 votes per user + voteEntity.VoteTime = 6 // Half of the normal vote time } } - return &defaultVoteEntity, nil + return &voteEntity, nil } // Checks whether or not a user has voted for an entity -func EntityVoteCheck(ctx context.Context, userId, targetId, targetType string) (*types.UserVote, error) { - vi, err := EntityVoteInfo(ctx, userId, targetId, targetType) +func EntityVoteCheck(ctx context.Context, c DbConn, userId, targetId, targetType string) (*types.UserVote, error) { + vi, err := EntityVoteInfo(ctx, c, userId, targetId, targetType) if err != nil { return nil, err } - rows, err := state.Pool.Query( + var rows pgx.Rows + + rows, err = c.Query( ctx, - "SELECT created_at, upvote FROM entity_votes WHERE author = $1 AND target_id = $2 AND target_type = $3 AND void = false AND NOW() - created_at < make_interval(hours => $4) ORDER BY created_at DESC", + "SELECT itag, created_at, upvote FROM entity_votes WHERE author = $1 AND target_id = $2 AND target_type = $3 AND void = false ORDER BY created_at DESC", userId, targetId, targetType, - vi.VoteTime, ) if err != nil { @@ -92,16 +255,18 @@ func EntityVoteCheck(ctx context.Context, userId, targetId, targetType string) ( var validVotes []*types.ValidVote for rows.Next() { + var itag pgtype.UUID var createdAt time.Time var upvote bool - err = rows.Scan(&createdAt, &upvote) + err = rows.Scan(&itag, &createdAt, &upvote) if err != nil { return nil, err } validVotes = append(validVotes, &types.ValidVote{ + ID: itag, Upvote: upvote, CreatedAt: createdAt, }) @@ -109,38 +274,46 @@ func EntityVoteCheck(ctx context.Context, userId, targetId, targetType string) ( var vw *types.VoteWait - if len(validVotes) > 0 { - timeElapsed := time.Since(validVotes[0].CreatedAt) + // If there is a valid vote in this period and the entity supports multiple votes, figure out how long the user has to wait + var hasVoted bool + + // Case 1: Multiple votes + if vi.MultipleVotes { + if len(validVotes) > 0 { + // Check if the user has voted in the last vote time + hasVoted = validVotes[0].CreatedAt.Add(time.Duration(vi.VoteTime) * time.Hour).After(time.Now()) + + if hasVoted { + timeElapsed := time.Since(validVotes[0].CreatedAt) - timeToWait := int64(vi.VoteTime)*60*60*1000 - timeElapsed.Milliseconds() + timeToWait := int64(vi.VoteTime)*60*60*1000 - timeElapsed.Milliseconds() - timeToWaitTime := (time.Duration(timeToWait) * time.Millisecond) + timeToWaitTime := (time.Duration(timeToWait) * time.Millisecond) - hours := timeToWaitTime / time.Hour - mins := (timeToWaitTime - (hours * time.Hour)) / time.Minute - secs := (timeToWaitTime - (hours*time.Hour + mins*time.Minute)) / time.Second + hours := timeToWaitTime / time.Hour + mins := (timeToWaitTime - (hours * time.Hour)) / time.Minute + secs := (timeToWaitTime - (hours*time.Hour + mins*time.Minute)) / time.Second - vw = &types.VoteWait{ - Hours: int(hours), - Minutes: int(mins), - Seconds: int(secs), + vw = &types.VoteWait{ + Hours: int(hours), + Minutes: int(mins), + Seconds: int(secs), + } + } } + } else { + // Case 2: Single vote entity + hasVoted = len(validVotes) > 0 } return &types.UserVote{ - HasVoted: len(validVotes) > 0, + HasVoted: hasVoted, ValidVotes: validVotes, VoteInfo: vi, Wait: vw, }, nil } -type DbConn interface { - QueryRow(ctx context.Context, sql string, args ...any) pgx.Row - Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) - Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) -} - // Returns the exact (non-cached/approximate) vote count for an entity func EntityGetVoteCount(ctx context.Context, c DbConn, targetId, targetType string) (int, error) { var upvotes int @@ -163,3 +336,15 @@ func EntityGetVoteCount(ctx context.Context, c DbConn, targetId, targetType stri return upvotes - downvotes, nil } + +func EntityGiveVotes(ctx context.Context, c DbConn, upvote bool, author, targetType, targetId string, vi *types.VoteInfo) error { + // Keep adding votes until, but not including vi.VoteInfo.PerUser + for i := 0; i < vi.PerUser; i++ { + _, err := c.Exec(ctx, "INSERT INTO entity_votes (author, target_id, target_type, upvote, vote_num) VALUES ($1, $2, $3, $4, $5)", author, targetId, targetType, upvote, i) + + if err != nil { + return fmt.Errorf("failed to insert vote: %w", err) + } + } + return nil +} diff --git a/votes/helpers.go b/votes/helpers.go deleted file mode 100644 index fc7ccc72..00000000 --- a/votes/helpers.go +++ /dev/null @@ -1,133 +0,0 @@ -package votes - -import ( - "context" - "errors" - "fmt" - "popplio/assetmanager" - "popplio/state" - "strconv" - - "github.com/infinitybotlist/eureka/dovewing" - "github.com/jackc/pgx/v5" -) - -type EntityInfo struct { - Name string - URL string - VoteURL string - Avatar string -} - -// GetEntityInfo returns information about the entity that is being voted for including vote bans etc. -// -// TODO: Refactor vote ban checks to its own function -func GetEntityInfo(ctx context.Context, targetId, targetType string) (*EntityInfo, error) { - // Handle entity specific checks here, such as ensuring the entity actually exists - switch targetType { - case "bot": - var botType string - var voteBanned bool - - err := state.Pool.QueryRow(ctx, "SELECT type, vote_banned FROM bots WHERE bot_id = $1", targetId).Scan(&botType, &voteBanned) - - if errors.Is(err, pgx.ErrNoRows) { - return nil, errors.New("bot not found") - } - - if err != nil { - return nil, fmt.Errorf("failed to fetch bot data for this vote: %w", err) - } - - if voteBanned { - return nil, errors.New("bot is vote banned and cannot be voted for right now") - } - - if botType != "approved" && botType != "certified" { - return nil, errors.New("bot is not approved or certified and cannot be voted for right now") - } - - botObj, err := dovewing.GetUser(ctx, targetId, state.DovewingPlatformDiscord) - - if err != nil { - return nil, err - } - - // Set entityInfo for log - return &EntityInfo{ - URL: "https://botlist.site/" + targetId, - VoteURL: "https://botlist.site/" + targetId + "/vote", - Name: botObj.Username, - Avatar: botObj.Avatar, - }, nil - case "pack": - return &EntityInfo{ - URL: "https://botlist.site/pack/" + targetId, - VoteURL: "https://botlist.site/pack/" + targetId, - Name: targetId, - Avatar: "", - }, nil - case "team": - var name string - var voteBanned bool - - err := state.Pool.QueryRow(ctx, "SELECT name, vote_banned FROM teams WHERE id = $1", targetId).Scan(&name, &voteBanned) - - if errors.Is(err, pgx.ErrNoRows) { - return nil, errors.New("team not found") - } - - if err != nil { - return nil, fmt.Errorf("failed to fetch team data for this vote: %w", err) - } - - if voteBanned { - return nil, errors.New("team is vote banned and cannot be voted for right now") - } - - avatar := assetmanager.AvatarInfo(assetmanager.AssetTargetTypeTeams, targetId) - - var avatarPath string - - if avatar.Exists { - avatarPath = state.Config.Sites.CDN + "/" + avatar.Path + "?ts=" + strconv.FormatInt(avatar.LastModified.Unix(), 10) - } else { - avatarPath = state.Config.Sites.CDN + "/" + avatar.DefaultPath - } - - // Set entityInfo for log - return &EntityInfo{ - URL: "https://botlist.site/team/" + targetId, - VoteURL: "https://botlist.site/team/" + targetId + "/vote", - Name: name, - Avatar: avatarPath, - }, nil - case "server": - var name, avatar string - var voteBanned bool - - err := state.Pool.QueryRow(ctx, "SELECT name, avatar, vote_banned FROM servers WHERE server_id = $1", targetId).Scan(&name, &avatar, &voteBanned) - - if errors.Is(err, pgx.ErrNoRows) { - return nil, errors.New("server not found") - } - - if err != nil { - return nil, fmt.Errorf("failed to fetch server data for this vote: %w", err) - } - - if voteBanned { - return nil, errors.New("server is vote banned and cannot be voted for right now") - } - - // Set entityInfo for log - return &EntityInfo{ - URL: "https://botlist.site/server/" + targetId, - VoteURL: "https://botlist.site/server/" + targetId + "/vote", - Name: name, - Avatar: avatar, - }, nil - default: - return nil, errors.New("unimplemented target type:" + targetType) - } -}