/* Copyright 2017-2022 CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "database/sql" "embed" "errors" "fmt" "time" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/jmoiron/sqlx" "github.com/johejo/golang-migrate-extra/source/iofs" log "github.com/sirupsen/logrus" ) type sqlKey int const ( sqlLoadDecisions sqlKey = iota sqlLoadUnVotedDecisions sqlLoadDecisionByTag sqlLoadDecisionByID sqlLoadVoteCountsForDecision sqlLoadVotesForDecision sqlLoadEnabledVoterByEmail sqlCountOlderThanDecision sqlCountOlderThanUnVotedDecision sqlCreateDecision sqlUpdateDecision sqlUpdateDecisionStatus sqlSelectClosableDecisions sqlGetNextPendingDecisionDue sqlGetReminderVoters sqlFindUnVotedDecisionsForVoter sqlGetEnabledVoterByID sqlCreateVote sqlLoadVote sqlGetVotersForProxy ) var sqlStatements = map[sqlKey]string{ sqlLoadDecisions: ` SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified FROM decisions JOIN voters ON decisions.proponent=voters.id ORDER BY proposed DESC LIMIT 10 OFFSET 10 * $1`, sqlLoadUnVotedDecisions: ` SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified FROM decisions JOIN voters ON decisions.proponent=voters.id WHERE decisions.status = 0 AND decisions.id NOT IN (SELECT votes.decision FROM votes WHERE votes.voter = $1) ORDER BY proposed DESC LIMIT 10 OFFSET 10 * $2;`, sqlLoadDecisionByTag: ` SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified FROM decisions JOIN voters ON decisions.proponent=voters.id WHERE decisions.tag=$1;`, sqlLoadDecisionByID: ` SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified FROM decisions WHERE decisions.id=$1;`, sqlLoadVoteCountsForDecision: ` SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`, sqlLoadVotesForDecision: ` SELECT votes.decision, votes.voter, voters.name, votes.vote, votes.voted, votes.notes FROM votes JOIN voters ON votes.voter=voters.id WHERE decision=$1`, sqlLoadEnabledVoterByEmail: ` SELECT voters.id, voters.name, voters.reminder FROM voters JOIN emails ON voters.id=emails.voter JOIN user_roles ON user_roles.voter_id=voters.id WHERE emails.address=$1 AND user_roles.role='VOTER'`, sqlGetEnabledVoterByID: ` SELECT voters.id, voters.name, voters.reminder FROM voters JOIN user_roles ON user_roles.voter_id=voters.id WHERE user_roles.role='VOTER' AND voters.id=$1`, sqlCountOlderThanDecision: ` SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`, sqlCountOlderThanUnVotedDecision: ` SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1 AND status=0 AND id NOT IN (SELECT decision FROM votes WHERE votes.voter=$2)`, sqlCreateDecision: ` INSERT INTO decisions (proposed, proponent, title, content, votetype, status, due, modified,tag) VALUES ( :proposed, :proponent, :title, :content, :votetype, 0, :due, :proposed, 'm' || strftime('%Y%m%d', :proposed) || '.' || ( SELECT COUNT(*)+1 AS num FROM decisions WHERE proposed BETWEEN date(:proposed) AND date(:proposed, '1 day') ) )`, sqlUpdateDecision: ` UPDATE decisions SET proponent=:proponent, title=:title, content=:content, votetype=:votetype, due=:due, modified=:modified WHERE id=:id`, sqlUpdateDecisionStatus: ` UPDATE decisions SET status=:status, modified=:modified WHERE id=:id`, sqlSelectClosableDecisions: ` SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified FROM decisions WHERE decisions.status=0 AND :now > due`, sqlGetNextPendingDecisionDue: ` SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`, sqlGetVotersForProxy: ` SELECT voters.id, voters.name, voters.reminder FROM voters JOIN user_roles ON user_roles.voter_id=voters.id WHERE user_roles.role='VOTER' AND voters.id != $1`, sqlGetReminderVoters: ` SELECT voters.id, voters.name, voters.reminder FROM voters JOIN user_roles ON user_roles.voter_id=voters.id WHERE user_roles.role='VOTER' AND reminder!='' AND reminder IS NOT NULL`, sqlFindUnVotedDecisionsForVoter: ` SELECT tag, title, votetype, due FROM decisions WHERE status = 0 AND id NOT IN (SELECT decision FROM votes WHERE voter = $1) ORDER BY due ASC`, sqlCreateVote: ` INSERT OR REPLACE INTO votes (decision, voter, vote, voted, notes) VALUES (:decision, :voter, :vote, :voted, :notes)`, sqlLoadVote: ` SELECT decision, voter, vote, voted, notes FROM votes WHERE decision=$1 AND voter=$2`, } type VoteType uint8 type VoteStatus int8 type Decision struct { ID int64 `db:"id"` Proposed time.Time ProponentID int64 `db:"proponent"` Title string Content string Quorum int Majority int Status VoteStatus Due time.Time Modified time.Time Tag string VoteType VoteType } type Voter struct { ID int64 `db:"id"` Name string Reminder string // reminder email address } type VoteChoice int const ( voteAye = 1 voteNaye = -1 voteAbstain = 0 ) const ( voteTypeMotion = 0 voteTypeVeto = 1 ) const ( voteTypeLabelMotion = "motion" voteTypeLabelUnknown = "unknown" voteTypeLabelVeto = "veto" ) func (v VoteType) String() string { switch v { case voteTypeMotion: return voteTypeLabelMotion case voteTypeVeto: return voteTypeLabelVeto default: return voteTypeLabelUnknown } } func (v VoteType) QuorumAndMajority() (int, float32) { const ( majorityDefault = 0.99 majorityMotion = 0.50 quorumDefault = 1 quorumMotion = 3 ) switch v { case voteTypeMotion: return quorumMotion, majorityMotion default: return quorumDefault, majorityDefault } } func (v VoteChoice) String() string { switch v { case voteAye: return "aye" case voteNaye: return "naye" case voteAbstain: return "abstain" default: return "unknown" } } var VoteValues = map[string]VoteChoice{ "aye": voteAye, "naye": voteNaye, "abstain": voteAbstain, } var VoteChoices = map[int64]VoteChoice{ 1: voteAye, 0: voteAbstain, -1: voteNaye, } const ( voteStatusDeclined = -1 voteStatusPending = 0 voteStatusApproved = 1 voteStatusWithdrawn = -2 ) func (v VoteStatus) String() string { switch v { case voteStatusDeclined: return "declined" case voteStatusPending: return "pending" case voteStatusApproved: return "approved" case voteStatusWithdrawn: return "withdrawn" default: return "unknown" } } type Vote struct { DecisionID int64 `db:"decision"` VoterID int64 `db:"voter"` Vote VoteChoice Voted time.Time Notes string } type DbHandler struct { db *sqlx.DB } var db *DbHandler //go:embed boardvoting/migrations/* var migrations embed.FS func NewDB(database *sql.DB) *DbHandler { handler := &DbHandler{db: sqlx.NewDb(database, "sqlite3")} source, err := iofs.New(migrations, "boardvoting/migrations") if err != nil { log.Panicf("could not create migration source: %v", err) } driver, err := sqlite3.WithInstance(database, &sqlite3.Config{}) if err != nil { log.Panicf("could not create migration driver: %v", err) } m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver) if err != nil { log.Panicf("could not create migration instance: %v", err) } m.Log = NewLogger() err = m.Up() if err != nil { if !errors.Is(err, migrate.ErrNoChange) { log.Panicf("running database migration failed: %v", err) } log.Info("no database migrations required") } else { log.Info("applied database migrations") } failedStatements := make([]string, 0) for _, sqlStatement := range sqlStatements { var stmt *sqlx.Stmt stmt, err := handler.db.Preparex(sqlStatement) if err != nil { log.Errorf("error parsing statement %s: %s", sqlStatement, err) failedStatements = append(failedStatements, sqlStatement) } _ = stmt.Close() } if len(failedStatements) > 0 { log.Panicf("%d statements failed to prepare", len(failedStatements)) } return handler } type migrationLogger struct{} func (m migrationLogger) Printf(format string, v ...interface{}) { log.Printf(format, v...) } func (m migrationLogger) Verbose() bool { return log.IsLevelEnabled(log.DebugLevel) } func NewLogger() migrate.Logger { return &migrationLogger{} } func (d *DbHandler) Close() error { if err := d.db.Close(); err != nil { return fmt.Errorf("could not close database: %w", err) } return nil } func (d *DbHandler) getPreparedNamedStatement(statementKey sqlKey) *sqlx.NamedStmt { statement, err := d.db.PrepareNamed(sqlStatements[statementKey]) if err != nil { log.Panicf("Preparing statement failed: %v", err) } return statement } func (d *DbHandler) getPreparedStatement(statementKey sqlKey) *sqlx.Stmt { statement, err := d.db.Preparex(sqlStatements[statementKey]) if err != nil { log.Panicf("Preparing statement failed: %v", err) } return statement } func (v *Vote) Save() error { insertVoteStmt := db.getPreparedNamedStatement(sqlCreateVote) defer func() { _ = insertVoteStmt.Close() }() var err error if _, err = insertVoteStmt.Exec(v); err != nil { return fmt.Errorf("saving vote failed: %w", err) } getVoteStmt := db.getPreparedStatement(sqlLoadVote) defer func() { _ = getVoteStmt.Close() }() if err = getVoteStmt.Get(v, v.DecisionID, v.VoterID); err != nil { return fmt.Errorf("getting inserted vote failed: %w", err) } return nil } type VoteSums struct { Ayes int Nayes int Abstains int } func (v *VoteSums) VoteCount() int { return v.Ayes + v.Nayes + v.Abstains } func (v *VoteSums) TotalVotes() int { return v.Ayes + v.Nayes } func (v *VoteSums) Percent() int { totalVotes := v.TotalVotes() if totalVotes == 0 { return 0 } return v.Ayes * 100 / totalVotes } func (v *VoteSums) CalculateResult(quorum int, majority float32) (VoteStatus, string) { if v.VoteCount() < quorum { return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum) } if (float32(v.Ayes) / float32(v.TotalVotes())) < majority { return voteStatusDeclined, fmt.Sprintf("Needed majority of %0.2f%% has not been reached.", majority) } return voteStatusApproved, "Quorum and majority have been reached" } type VoteForDisplay struct { Vote Name string } type DecisionForDisplay struct { Decision Proposer string `db:"proposer"` *VoteSums Votes []VoteForDisplay } func FindDecisionForDisplayByTag(tag string) (*DecisionForDisplay, error) { decisionStmt := db.getPreparedStatement(sqlLoadDecisionByTag) defer func() { _ = decisionStmt.Close() }() decision := &DecisionForDisplay{} var err error if err = decisionStmt.Get(decision, tag); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("getting motion %s failed: %w", tag, err) } decision.VoteSums, err = decision.Decision.VoteSums() return decision, err } // FindDecisionsForDisplayOnPage loads a set of decisions from the database. // // This function uses OFFSET for pagination which is not a good idea for larger data sets. // // TODO: migrate to timestamp base pagination func FindDecisionsForDisplayOnPage(page int, unVoted bool, voter *Voter) ([]*DecisionForDisplay, error) { var decisionsStmt *sqlx.Stmt if unVoted && voter != nil { decisionsStmt = db.getPreparedStatement(sqlLoadUnVotedDecisions) } else { decisionsStmt = db.getPreparedStatement(sqlLoadDecisions) } defer func() { _ = decisionsStmt.Close() }() var ( rows *sqlx.Rows err error decisions []*DecisionForDisplay ) if unVoted && voter != nil { rows, err = decisionsStmt.Queryx(voter.ID, page-1) } else { rows, err = decisionsStmt.Queryx(page - 1) } if err != nil { return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err) } defer func() { _ = rows.Close() }() for rows.Next() { var d DecisionForDisplay if err = rows.StructScan(&d); err != nil { return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err) } d.VoteSums, err = d.Decision.VoteSums() if err != nil { return nil, err } decisions = append(decisions, &d) } return decisions, nil } func (d *Decision) VoteSums() (*VoteSums, error) { votesStmt := db.getPreparedStatement(sqlLoadVoteCountsForDecision) defer func() { _ = votesStmt.Close() }() voteRows, err := votesStmt.Queryx(d.ID) if err != nil { return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err) } defer func() { _ = voteRows.Close() }() sums := &VoteSums{} for voteRows.Next() { var ( vote VoteChoice count int ) if err = voteRows.Scan(&vote, &count); err != nil { return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err) } switch vote { case voteAye: sums.Ayes = count case voteNaye: sums.Nayes = count case voteAbstain: sums.Abstains = count } } return sums, nil } func (d *DecisionForDisplay) LoadVotes() (err error) { votesStmt := db.getPreparedStatement(sqlLoadVotesForDecision) defer func() { _ = votesStmt.Close() }() err = votesStmt.Select(&d.Votes, d.ID) if err != nil { log.Errorf("selecting votes for motion %s failed: %v", d.Tag, err) return } return } func (d *Decision) OlderExists(unvoted bool, voter *Voter) (bool, error) { var result bool if unvoted && voter != nil { olderStmt := db.getPreparedStatement(sqlCountOlderThanUnVotedDecision) defer func() { _ = olderStmt.Close() }() if err := olderStmt.Get(&result, d.Proposed, voter.ID); err != nil { return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err) } } else { olderStmt := db.getPreparedStatement(sqlCountOlderThanDecision) defer func() { _ = olderStmt.Close() }() if err := olderStmt.Get(&result, d.Proposed); err != nil { return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err) } } return result, nil } func (d *Decision) Create() error { insertDecisionStmt := db.getPreparedNamedStatement(sqlCreateDecision) defer func() { _ = insertDecisionStmt.Close() }() result, err := insertDecisionStmt.Exec(d) if err != nil { return fmt.Errorf("creating motion failed: %w", err) } decisionID, err := result.LastInsertId() if err != nil { return fmt.Errorf("getting id of inserted motion failed: %w", err) } rescheduleChannel <- JobIDCloseDecisions getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID) defer func() { _ = getDecisionStmt.Close() }() err = getDecisionStmt.Get(d, decisionID) if err != nil { return fmt.Errorf("getting inserted motion failed: %w", err) } return nil } func (d *Decision) LoadWithID() (err error) { getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID) defer func() { _ = getDecisionStmt.Close() }() err = getDecisionStmt.Get(d, d.ID) if err != nil { log.Errorf("loading updated motion failed: %v", err) return } return } func (d *Decision) Update() (err error) { updateDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecision) defer func() { _ = updateDecisionStmt.Close() }() result, err := updateDecisionStmt.Exec(d) if err != nil { log.Errorf("updating motion failed: %v", err) return } affectedRows, err := result.RowsAffected() if err != nil { log.Error("Problem determining the affected rows") return } else if affectedRows != 1 { log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows) } rescheduleChannel <- JobIDCloseDecisions err = d.LoadWithID() return } func (d *Decision) UpdateStatus() error { updateStatusStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus) defer func() { _ = updateStatusStmt.Close() }() result, err := updateStatusStmt.Exec(d) if err != nil { return fmt.Errorf("setting motion status failed: %w", err) } affectedRows, err := result.RowsAffected() if err != nil { return fmt.Errorf("determining the affected rows failed: %w", err) } else if affectedRows != 1 { log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows) } rescheduleChannel <- JobIDCloseDecisions err = d.LoadWithID() return err } func (d *Decision) String() string { return fmt.Sprintf("%s %s (ID %d)", d.Tag, d.Title, d.ID) } func FindVoterByAddress(emailAddress string) (*Voter, error) { findVoterStmt := db.getPreparedStatement(sqlLoadEnabledVoterByEmail) defer func() { _ = findVoterStmt.Close() }() voter := &Voter{} if err := findVoterStmt.Get(voter, emailAddress); err != nil { if !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("getting voter for address %s failed: %w", emailAddress, err) } voter = nil } return voter, nil } func (d *Decision) Close() error { quorum, majority := d.VoteType.QuorumAndMajority() var ( voteSums *VoteSums err error ) if voteSums, err = d.VoteSums(); err != nil { log.Errorf("getting vote sums failed: %v", err) return err } var reasoning string d.Status, reasoning = voteSums.CalculateResult(quorum, majority) closeDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus) defer func() { _ = closeDecisionStmt.Close() }() result, err := closeDecisionStmt.Exec(d) if err != nil { return fmt.Errorf("closing vote failed: %w", err) } affectedRows, err := result.RowsAffected() if err != nil { return fmt.Errorf("getting affected rows failed: %w", err) } if affectedRows != 1 { log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows) } NotifyMailChannel <- NewNotificationClosedDecision(d, voteSums, reasoning) log.Infof("decision %s closed with result %s: reasoning %s", d.Tag, d.Status, reasoning) return nil } func CloseDecisions() error { getClosableDecisionsStmt := db.getPreparedNamedStatement(sqlSelectClosableDecisions) defer func() { _ = getClosableDecisionsStmt.Close() }() decisions := make([]*Decision, 0) rows, err := getClosableDecisionsStmt.Queryx(struct{ Now time.Time }{time.Now().UTC()}) if err != nil { return fmt.Errorf("fetching closable decisions failed: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { decision := &Decision{} if err = rows.StructScan(decision); err != nil { return fmt.Errorf("scanning row failed: %w", err) } decisions = append(decisions, decision) } defer func() { _ = rows.Close() }() for _, decision := range decisions { log.Infof("found closable decision %s", decision.Tag) if err = decision.Close(); err != nil { return fmt.Errorf("closing decision %s failed: %w", decision.Tag, err) } } return nil } func GetNextPendingDecisionDue() (*time.Time, error) { getNextPendingDecisionDueStmt := db.getPreparedStatement(sqlGetNextPendingDecisionDue) defer func() { _ = getNextPendingDecisionDueStmt.Close() }() row := getNextPendingDecisionDueStmt.QueryRow() due := &time.Time{} if err := row.Scan(due); err != nil { if errors.Is(err, sql.ErrNoRows) { log.Debug("No pending decisions") return nil, nil } return nil, fmt.Errorf("parsing result failed: %w", err) } return due, nil } func GetReminderVoters() ([]Voter, error) { getReminderVotersStmt := db.getPreparedStatement(sqlGetReminderVoters) defer func() { _ = getReminderVotersStmt.Close() }() var voters []Voter if err := getReminderVotersStmt.Select(&voters); err != nil { return nil, fmt.Errorf("getting voters failed: %w", err) } return voters, nil } func FindUnVotedDecisionsForVoter(voter *Voter) ([]Decision, error) { findUnVotedDecisionsForVoterStmt := db.getPreparedStatement(sqlFindUnVotedDecisionsForVoter) defer func() { _ = findUnVotedDecisionsForVoterStmt.Close() }() var decisions []Decision if err := findUnVotedDecisionsForVoterStmt.Select(&decisions, voter.ID); err != nil { return nil, fmt.Errorf("getting unvoted decisions failed: %w", err) } return decisions, nil } func GetVoterByID(id int64) (*Voter, error) { getVoterByIDStmt := db.getPreparedStatement(sqlGetEnabledVoterByID) defer func() { _ = getVoterByIDStmt.Close() }() voter := &Voter{} if err := getVoterByIDStmt.Get(voter, id); err != nil { return nil, fmt.Errorf("getting voter failed: %w", err) } return voter, nil } func GetVotersForProxy(proxy *Voter) (voters *[]Voter, err error) { getVotersForProxyStmt := db.getPreparedStatement(sqlGetVotersForProxy) defer func() { _ = getVotersForProxyStmt.Close() }() votersSlice := make([]Voter, 0) if err = getVotersForProxyStmt.Select(&votersSlice, proxy.ID); err != nil { log.Errorf("Error getting voters for proxy failed: %v", err) return } voters = &votersSlice return }