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  }