github.com/DapperCollectives/CAST/backend@v0.0.0-20230921221157-1350c8be7c96/main/models/proposal.go (about) 1 package models 2 3 /////////////// 4 // Proposals // 5 /////////////// 6 7 import ( 8 "fmt" 9 "math" 10 "os" 11 "strings" 12 "time" 13 14 "github.com/DapperCollectives/CAST/backend/main/shared" 15 s "github.com/DapperCollectives/CAST/backend/main/shared" 16 "github.com/georgysavva/scany/pgxscan" 17 "github.com/jackc/pgx/v4" 18 ) 19 20 type Proposal struct { 21 ID int `json:"id,omitempty"` 22 Name string `json:"name" validate:"required"` 23 Community_id int `json:"communityId"` 24 Choices []s.Choice `json:"choices" validate:"required"` 25 Strategy *string `json:"strategy,omitempty"` 26 Max_weight *float64 `json:"maxWeight,omitempty"` 27 Min_balance *float64 `json:"minBalance,omitempty"` 28 Creator_addr string `json:"creatorAddr" validate:"required"` 29 Start_time time.Time `json:"startTime" validate:"required"` 30 Result *string `json:"result,omitempty"` 31 End_time time.Time `json:"endTime" validate:"required"` 32 Created_at *time.Time `json:"createdAt,omitempty"` 33 Cid *string `json:"cid,omitempty"` 34 Status *string `json:"status,omitempty"` 35 Body *string `json:"body,omitempty" validate:"required"` 36 Block_height *uint64 `json:"block_height"` 37 Total_votes int `json:"total_votes"` 38 Timestamp string `json:"timestamp" validate:"required"` 39 Composite_signatures *[]s.CompositeSignature `json:"compositeSignatures"` 40 Computed_status *string `json:"computedStatus,omitempty"` 41 Snapshot_status *string `json:"snapshotStatus,omitempty"` 42 Voucher *shared.Voucher `json:"voucher,omitempty"` 43 Achievements_done bool `json:"achievementsDone"` 44 } 45 46 type UpdateProposalRequestPayload struct { 47 Status string `json:"status"` 48 Voucher *s.Voucher `json:"voucher,omitempty"` 49 50 s.TimestampSignaturePayload 51 } 52 53 var computedStatusSQL = ` 54 CASE 55 WHEN status = 'published' AND start_time > (now() at time zone 'utc') THEN 'pending' 56 WHEN status = 'published' AND start_time < (now() at time zone 'utc') AND end_time > (now() at time zone 'utc') THEN 'active' 57 WHEN status = 'published' AND end_time < (now() at time zone 'utc') THEN 'closed' 58 WHEN status = 'cancelled' THEN 'cancelled' 59 WHEN status = 'closed' THEN 'closed' 60 END as computed_status 61 ` 62 63 func GetProposalsForCommunity( 64 db *s.Database, 65 communityId int, 66 status string, 67 params shared.PageParams, 68 ) ([]*Proposal, int, error) { 69 var proposals []*Proposal 70 var err error 71 72 // Get Proposals 73 sql := fmt.Sprintf(`SELECT *, %s FROM proposals WHERE community_id = $3`, computedStatusSQL) 74 statusFilter := "" 75 76 // Generate SQL based on computed status 77 // status: { pending | active | closed | cancelled } 78 switch status { 79 case "pending": 80 statusFilter = ` AND status = 'published' AND start_time > (now() at time zone 'utc')` 81 case "active": 82 statusFilter = ` AND status = 'published' AND start_time < (now() at time zone 'utc') AND end_time > (now() at time zone 'utc')` 83 case "closed": 84 statusFilter = ` AND status = 'published' AND end_time < (now() at time zone 'utc')` 85 case "cancelled": 86 statusFilter = ` AND status = 'cancelled'` 87 case "terminated": 88 statusFilter = ` AND (status = 'cancelled' OR (status = 'published' AND end_time < (now() at time zone 'utc')))` 89 case "inprogress": 90 statusFilter = ` AND status = 'published' AND end_time > (now() at time zone 'utc')` 91 } 92 93 orderBySql := fmt.Sprintf(` ORDER BY created_at %s`, params.Order) 94 limitOffsetSql := ` LIMIT $1 OFFSET $2` 95 sql = sql + statusFilter + orderBySql + limitOffsetSql 96 97 err = pgxscan.Select(db.Context, db.Conn, &proposals, sql, params.Count, params.Start, communityId) 98 99 // If we get pgx.ErrNoRows, just return an empty array 100 // and obfuscate error 101 if err != nil && err.Error() != pgx.ErrNoRows.Error() { 102 return nil, 0, err 103 } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { 104 return []*Proposal{}, 0, nil 105 } 106 107 // Get total number of proposals 108 var totalRecords int 109 countSql := `SELECT COUNT(*) FROM proposals WHERE community_id = $1` + statusFilter 110 _ = db.Conn.QueryRow(db.Context, countSql, communityId).Scan(&totalRecords) 111 112 return proposals, totalRecords, nil 113 } 114 115 func (p *Proposal) GetProposalById(db *s.Database) error { 116 sql := ` 117 SELECT p.*, %s, count(v.id) as total_votes from proposals as p 118 left join votes as v on v.proposal_id = p.id 119 WHERE p.id = $1 120 GROUP BY p.id` 121 sql = fmt.Sprintf(sql, computedStatusSQL) 122 return pgxscan.Get(db.Context, db.Conn, p, sql, p.ID) 123 } 124 125 func (p *Proposal) CreateProposal(db *s.Database) error { 126 err := db.Conn.QueryRow(db.Context, 127 ` 128 INSERT INTO proposals(community_id, 129 name, 130 choices, 131 strategy, 132 min_balance, 133 max_weight, 134 creator_addr, 135 start_time, 136 end_time, 137 status, 138 body, 139 block_height, 140 cid, 141 composite_signatures, 142 voucher 143 ) 144 VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) 145 RETURNING id, created_at 146 `, 147 p.Community_id, 148 p.Name, 149 p.Choices, 150 p.Strategy, 151 p.Min_balance, 152 p.Max_weight, 153 p.Creator_addr, 154 p.Start_time, 155 p.End_time, 156 p.Status, 157 p.Body, 158 p.Block_height, 159 p.Cid, 160 p.Composite_signatures, 161 p.Voucher, 162 ).Scan(&p.ID, &p.Created_at) 163 164 return err 165 } 166 167 func (p *Proposal) UpdateProposal(db *s.Database) error { 168 _, err := db.Conn.Exec(db.Context, ` 169 UPDATE proposals 170 SET status = $1 171 WHERE id = $2 172 `, p.Status, p.ID) 173 174 if err != nil { 175 return err 176 } 177 178 if *p.Status == "cancelled" { 179 err := handleCancelledProposal(db, p.ID) 180 if err != nil { 181 return err 182 } 183 } 184 185 err = p.GetProposalById(db) 186 return err 187 } 188 189 func (p *Proposal) IsLive() bool { 190 now := time.Now().UTC() 191 return now.After(p.Start_time) && now.Before(p.End_time) 192 } 193 194 // Validations 195 196 // Returns an error if the account's balance is insufficient to cast 197 // a vote on the proposal. 198 func (p *Proposal) ValidateBalance(weight float64) error { 199 if p.Min_balance == nil { 200 return nil 201 } 202 203 var Min_balance = *p.Min_balance 204 var ERROR error = fmt.Errorf("insufficient balance for strategy: %s\nmin threshold: %f, vote weight: %f", *p.Strategy, *p.Min_balance, weight) 205 206 // TODO: Feature flag 207 // Dont validate in DEV or TEST envs! 208 if os.Getenv("APP_ENV") == "TEST" || os.Getenv("APP_ENV") == "DEV" { 209 return nil 210 } 211 212 if weight == 0.00 { 213 return ERROR 214 } 215 216 if Min_balance != 0.00 && Min_balance > 0.00 && weight < Min_balance { 217 return ERROR 218 } 219 return nil 220 } 221 222 func (p *Proposal) EnforceMaxWeight(balance float64) float64 { 223 if p.Max_weight == nil { 224 return balance 225 } 226 227 var allowedBalance float64 228 var maxWeight = *p.Max_weight 229 230 //inversions is used to correctly shift Max_weight x amount of 231 //decimal places, depending on how many decimal places it originally is 232 var inversions = map[int]int{ 233 1: 8, 234 2: 7, 235 3: 6, 236 4: 5, 237 5: 4, 238 6: 3, 239 7: 2, 240 8: 1, 241 } 242 243 //we shift the maxWeight up by x decimal places so that the 244 //comparison block works as expected 245 //first, get the number of decimal places left side of . for maxWeight 246 maxLimitLength := len(strings.Split(fmt.Sprintf("%v", maxWeight), ".")[0]) 247 248 minuend := inversions[maxLimitLength] 249 powerToShift := minuend - maxLimitLength 250 shiftedMaxWeight := maxWeight * math.Pow(10, float64(powerToShift)) 251 252 if balance >= shiftedMaxWeight { 253 allowedBalance = shiftedMaxWeight 254 } else { 255 allowedBalance = balance 256 } 257 258 return allowedBalance 259 } 260 261 func GetActiveStrategiesForCommunity(db *s.Database, communityId int) ([]string, error) { 262 var strategies []string 263 var err error 264 265 // Get Strategies from active proposals 266 sql := ` 267 SELECT strategy FROM proposals 268 WHERE community_id = $1 269 AND ( 270 (status = 'published' AND start_time > (now() at time zone 'utc')) OR 271 (status = 'published' AND start_time < (now() at time zone 'utc') AND end_time > (now() at time zone 'utc')) OR 272 (status = 'published' AND end_time > (now() at time zone 'utc')) 273 ) 274 GROUP BY strategy 275 ` 276 277 err = pgxscan.Select(db.Context, db.Conn, &strategies, sql, communityId) 278 279 // If we get pgx.ErrNoRows, just return an empty array 280 // and obfuscate error 281 if err != nil && err.Error() != pgx.ErrNoRows.Error() { 282 return nil, err 283 } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { 284 return nil, nil 285 } 286 287 return strategies, nil 288 } 289 290 func handleCancelledProposal(db *s.Database, proposalId int) error { 291 292 // Delete All votes for cancelled proposal 293 _, err := db.Conn.Exec(db.Context, ` 294 UPDATE votes SET is_cancelled = 'true' WHERE proposal_id = $1 295 `, proposalId) 296 297 if err != nil { 298 return err 299 } 300 301 return nil 302 }