Define target build directory environment variable
[cacert-boardvoting.git] / models.go
index d27610f..75473b4 100644 (file)
--- a/models.go
+++ b/models.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+       "bitbucket.org/liamstask/goose/lib/goose"
        "database/sql"
        "fmt"
        "github.com/jmoiron/sqlx"
@@ -24,118 +25,111 @@ const (
        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
+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)
+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;`,
+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;`,
+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`,
+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`,
+FROM   votes
+JOIN   voters ON votes.voter=voters.id
+WHERE  decision=$1`,
        sqlLoadEnabledVoterByEmail: `
 SELECT voters.id, voters.name, voters.enabled, voters.reminder
-FROM voters
-JOIN emails ON voters.id=emails.voter
-WHERE emails.address=$1 AND voters.enabled=1`,
+FROM   voters
+JOIN   emails ON voters.id=emails.voter
+WHERE  emails.address=$1 AND voters.enabled=1`,
+       sqlGetEnabledVoterById: `
+SELECT id, name, enabled, reminder
+FROM   voters
+WHERE  enabled=1 AND 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)`,
+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,
+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')
+               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`,
+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
-`,
+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
+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`,
-}
-
-var db *sqlx.DB
-
-func init() {
-       for _, sqlStatement := range sqlStatements {
-               var stmt *sqlx.Stmt
-               stmt, err := db.Preparex(sqlStatement)
-               if err != nil {
-                       logger.Fatalf("ERROR parsing statement %s: %s", sqlStatement, err)
-               }
-               stmt.Close()
-       }
+       sqlGetVotersForProxy: `
+SELECT id, name, reminder
+FROM   voters WHERE enabled=1 AND id != $1`,
+       sqlGetReminderVoters: `
+SELECT id, name, reminder FROM voters WHERE enabled=1 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          int
+       Id          int64
        Proposed    time.Time
-       ProponentId int `db:"proponent"`
+       ProponentId int64 `db:"proponent"`
        Title       string
        Content     string
        Quorum      int
@@ -148,12 +142,12 @@ type Decision struct {
 }
 
 type Email struct {
-       VoterId int `db:"voter"`
+       VoterId int64 `db:"voter"`
        Address string
 }
 
 type Voter struct {
-       Id       int
+       Id       int64
        Name     string
        Enabled  bool
        Reminder string // reminder email address
@@ -161,14 +155,6 @@ type Voter struct {
 
 type VoteChoice int
 
-type Vote struct {
-       DecisionId int `db:"decision"`
-       VoterId    int `db:"voter"`
-       Vote       VoteChoice
-       Voted      time.Time
-       Notes      string
-}
-
 const (
        voteAye     = 1
        voteNaye    = -1
@@ -213,6 +199,18 @@ func (v VoteChoice) String() string {
        }
 }
 
+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
@@ -235,6 +233,99 @@ func (v VoteStatus) String() string {
        }
 }
 
+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
+
+func NewDB(database *sqlx.DB) *dbHandler {
+       handler := &dbHandler{db: database}
+       failed_statements := make([]string, 0)
+       for _, sqlStatement := range sqlStatements {
+               var stmt *sqlx.Stmt
+               stmt, err := database.Preparex(sqlStatement)
+               if err != nil {
+                       log.Critical("ERROR parsing statement %s: %s", sqlStatement, err)
+                       failed_statements = append(failed_statements, sqlStatement)
+               }
+               stmt.Close()
+       }
+       if len(failed_statements) > 0 {
+               log.Panicf("%d statements failed to prepare", len(failed_statements))
+       }
+
+       migrateConf := &goose.DBConf{
+               MigrationsDir: config.MigrationsPath,
+               Env:           "production",
+               Driver: goose.DBDriver{
+                       Name:    "sqlite3",
+                       OpenStr: config.DatabaseFile,
+                       Import:  "github.com/mattn/go-sqlite3",
+                       Dialect: &goose.Sqlite3Dialect{},
+               },
+       }
+
+       latest, err := goose.GetMostRecentDBVersion(migrateConf.MigrationsDir)
+       if err != nil {
+               log.Panicf("getting the most recent database repository version failed: %v", err)
+       }
+
+       err = goose.RunMigrationsOnDb(migrateConf, migrateConf.MigrationsDir, latest, database.DB)
+       if err != nil {
+               log.Panicf("running database migration failed: %v", err)
+       }
+       return handler
+}
+
+func (d *dbHandler) Close() error {
+       return d.db.Close()
+}
+
+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() (err error) {
+       insertVoteStmt := db.getPreparedNamedStatement(sqlCreateVote)
+       defer insertVoteStmt.Close()
+
+       if _, err = insertVoteStmt.Exec(v); err != nil {
+               log.Errorf("saving vote failed: %v", err)
+               return
+       }
+
+       getVoteStmt := db.getPreparedStatement(sqlLoadVote)
+       defer getVoteStmt.Close()
+
+       if err = getVoteStmt.Get(v, v.DecisionId, v.VoterId); err != nil {
+               log.Errorf("getting inserted vote failed: %v", err)
+               return
+       }
+
+       return
+}
+
 type VoteSums struct {
        Ayes     int
        Nayes    int
@@ -257,6 +348,17 @@ func (v *VoteSums) Percent() int {
        return v.Ayes * 100 / totalVotes
 }
 
+func (v *VoteSums) CalculateResult(quorum int, majority int) (status VoteStatus, reasoning string) {
+       if v.VoteCount() < quorum {
+               status, reasoning = voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
+       } else if (v.Ayes / v.TotalVotes()) < (majority / 100) {
+               status, reasoning = voteStatusDeclined, fmt.Sprintf("Needed majority of %d%% has not been reached.", majority)
+       } else {
+               status, reasoning = voteStatusApproved, "Quorum and majority have been reached"
+       }
+       return
+}
+
 type VoteForDisplay struct {
        Vote
        Name string
@@ -270,11 +372,7 @@ type DecisionForDisplay struct {
 }
 
 func FindDecisionForDisplayByTag(tag string) (decision *DecisionForDisplay, err error) {
-       decisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionByTag])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       decisionStmt := db.getPreparedStatement(sqlLoadDecisionByTag)
        defer decisionStmt.Close()
 
        decision = &DecisionForDisplay{}
@@ -283,7 +381,8 @@ func FindDecisionForDisplayByTag(tag string) (decision *DecisionForDisplay, err
                        decision = nil
                        err = nil
                } else {
-                       logger.Printf("Error getting motion %s: %v\n", tag, err)
+                       log.Errorf("getting motion %s failed: %v", tag, err)
+                       return
                }
        }
        decision.VoteSums, err = decision.Decision.VoteSums()
@@ -298,13 +397,9 @@ func FindDecisionForDisplayByTag(tag string) (decision *DecisionForDisplay, err
 func FindDecisionsForDisplayOnPage(page int64, unvoted bool, voter *Voter) (decisions []*DecisionForDisplay, err error) {
        var decisionsStmt *sqlx.Stmt
        if unvoted && voter != nil {
-               decisionsStmt, err = db.Preparex(sqlStatements[sqlLoadUnvotedDecisions])
+               decisionsStmt = db.getPreparedStatement(sqlLoadUnvotedDecisions)
        } else {
-               decisionsStmt, err = db.Preparex(sqlStatements[sqlLoadDecisions])
-       }
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
+               decisionsStmt = db.getPreparedStatement(sqlLoadDecisions)
        }
        defer decisionsStmt.Close()
 
@@ -315,7 +410,7 @@ func FindDecisionsForDisplayOnPage(page int64, unvoted bool, voter *Voter) (deci
                rows, err = decisionsStmt.Queryx(page - 1)
        }
        if err != nil {
-               logger.Printf("Error loading motions for page %d: %v\n", page, err)
+               log.Errorf("loading motions for page %d failed: %v", page, err)
                return
        }
        defer rows.Close()
@@ -323,7 +418,7 @@ func FindDecisionsForDisplayOnPage(page int64, unvoted bool, voter *Voter) (deci
        for rows.Next() {
                var d DecisionForDisplay
                if err = rows.StructScan(&d); err != nil {
-                       logger.Printf("Error loading motions for page %d: %v\n", page, err)
+                       log.Errorf("loading motions for page %d failed: %v", page, err)
                        return
                }
                d.VoteSums, err = d.Decision.VoteSums()
@@ -336,16 +431,12 @@ func FindDecisionsForDisplayOnPage(page int64, unvoted bool, voter *Voter) (deci
 }
 
 func (d *Decision) VoteSums() (sums *VoteSums, err error) {
-       votesStmt, err := db.Preparex(sqlStatements[sqlLoadVoteCountsForDecision])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       votesStmt := db.getPreparedStatement(sqlLoadVoteCountsForDecision)
        defer votesStmt.Close()
 
        voteRows, err := votesStmt.Queryx(d.Id)
        if err != nil {
-               logger.Printf("Error fetching vote sums for motion %s: %v\n", d.Tag, err)
+               log.Errorf("fetching vote sums for motion %s failed: %v", d.Tag, err)
                return
        }
        defer voteRows.Close()
@@ -355,7 +446,7 @@ func (d *Decision) VoteSums() (sums *VoteSums, err error) {
                var vote VoteChoice
                var count int
                if err = voteRows.Scan(&vote, &count); err != nil {
-                       logger.Printf("Error fetching vote sums for motion %s: %v\n", d.Tag, err)
+                       log.Errorf("fetching vote sums for motion %s failed: %v", d.Tag, err)
                        return
                }
                switch vote {
@@ -371,39 +462,33 @@ func (d *Decision) VoteSums() (sums *VoteSums, err error) {
 }
 
 func (d *DecisionForDisplay) LoadVotes() (err error) {
-       votesStmt, err := db.Preparex(sqlStatements[sqlLoadVotesForDecision])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       votesStmt := db.getPreparedStatement(sqlLoadVotesForDecision)
        defer votesStmt.Close()
+
        err = votesStmt.Select(&d.Votes, d.Id)
        if err != nil {
-               logger.Printf("Error selecting votes for motion %s: %v\n", d.Tag, err)
+               log.Errorf("selecting votes for motion %s failed: %v", d.Tag, err)
+               return
        }
        return
 }
 
 func (d *Decision) OlderExists(unvoted bool, voter *Voter) (result bool, err error) {
-       var olderStmt *sqlx.Stmt
        if unvoted && voter != nil {
-               olderStmt, err = db.Preparex(sqlStatements[sqlCountOlderThanUnvotedDecision])
-       } else {
-               olderStmt, err = db.Preparex(sqlStatements[sqlCountOlderThanDecision])
-       }
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
-       defer olderStmt.Close()
+               olderStmt := db.getPreparedStatement(sqlCountOlderThanUnvotedDecision)
+               defer olderStmt.Close()
 
-       if unvoted && voter != nil {
                if err = olderStmt.Get(&result, d.Proposed, voter.Id); err != nil {
-                       logger.Printf("Error finding older motions than %s: %v\n", d.Tag, err)
+                       log.Errorf("finding older motions than %s failed: %v", d.Tag, err)
+                       return
                }
        } else {
+               olderStmt := db.getPreparedStatement(sqlCountOlderThanDecision)
+               defer olderStmt.Close()
+
                if err = olderStmt.Get(&result, d.Proposed); err != nil {
-                       logger.Printf("Error finding older motions than %s: %v\n", d.Tag, err)
+                       log.Errorf("finding older motions than %s failed: %v", d.Tag, err)
+                       return
                }
        }
 
@@ -411,76 +496,62 @@ func (d *Decision) OlderExists(unvoted bool, voter *Voter) (result bool, err err
 }
 
 func (d *Decision) Create() (err error) {
-       insertDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlCreateDecision])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       insertDecisionStmt := db.getPreparedNamedStatement(sqlCreateDecision)
        defer insertDecisionStmt.Close()
 
        result, err := insertDecisionStmt.Exec(d)
        if err != nil {
-               logger.Println("Error creating motion:", err)
+               log.Errorf("creating motion failed: %v", err)
                return
        }
 
        lastInsertId, err := result.LastInsertId()
        if err != nil {
-               logger.Println("Error getting id of inserted motion:", err)
+               log.Errorf("getting id of inserted motion failed: %v", err)
                return
        }
        rescheduleChannel <- JobIdCloseDecisions
 
-       getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionById)
        defer getDecisionStmt.Close()
 
        err = getDecisionStmt.Get(d, lastInsertId)
        if err != nil {
-               logger.Println("Error getting inserted motion:", err)
+               log.Errorf("getting inserted motion failed: %v", err)
+               return
        }
 
        return
 }
 
 func (d *Decision) LoadWithId() (err error) {
-       getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionById)
        defer getDecisionStmt.Close()
 
        err = getDecisionStmt.Get(d, d.Id)
        if err != nil {
-               logger.Println("Error loading updated motion:", err)
+               log.Errorf("loading updated motion failed: %v", err)
+               return
        }
 
        return
 }
 
 func (d *Decision) Update() (err error) {
-       updateDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecision])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       updateDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecision)
        defer updateDecisionStmt.Close()
 
        result, err := updateDecisionStmt.Exec(d)
        if err != nil {
-               logger.Println("Error updating motion:", err)
+               log.Errorf("updating motion failed: %v", err)
                return
        }
        affectedRows, err := result.RowsAffected()
        if err != nil {
-               logger.Print("Problem determining the affected rows")
+               log.Error("Problem determining the affected rows")
                return
        } else if affectedRows != 1 {
-               logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows)
+               log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
        }
        rescheduleChannel <- JobIdCloseDecisions
 
@@ -489,24 +560,20 @@ func (d *Decision) Update() (err error) {
 }
 
 func (d *Decision) UpdateStatus() (err error) {
-       updateStatusStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecisionStatus])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       updateStatusStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
        defer updateStatusStmt.Close()
 
        result, err := updateStatusStmt.Exec(d)
        if err != nil {
-               logger.Println("Error setting motion status:", err)
+               log.Errorf("setting motion status failed: %v", err)
                return
        }
        affectedRows, err := result.RowsAffected()
        if err != nil {
-               logger.Print("Problem determining the affected rows")
+               log.Errorf("determining the affected rows failed: %v", err)
                return
        } else if affectedRows != 1 {
-               logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows)
+               log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
        }
        rescheduleChannel <- JobIdCloseDecisions
 
@@ -519,17 +586,13 @@ func (d *Decision) String() string {
 }
 
 func FindVoterByAddress(emailAddress string) (voter *Voter, err error) {
-       findVoterStmt, err := db.Preparex(sqlStatements[sqlLoadEnabledVoterByEmail])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       findVoterStmt := db.getPreparedStatement(sqlLoadEnabledVoterByEmail)
        defer findVoterStmt.Close()
 
        voter = &Voter{}
        if err = findVoterStmt.Get(voter, emailAddress); err != nil {
                if err != sql.ErrNoRows {
-                       logger.Printf("Error getting voter for address %s: %v\n", emailAddress, err)
+                       log.Errorf("getting voter for address %s failed: %v", emailAddress, err)
                } else {
                        err = nil
                        voter = nil
@@ -538,72 +601,56 @@ func FindVoterByAddress(emailAddress string) (voter *Voter, err error) {
        return
 }
 
-func (d *Decision) Close() (err error) {
+func (d *Decision) Close() error {
        quorum, majority := d.VoteType.QuorumAndMajority()
 
-       voteSums, err := d.VoteSums()
+       var voteSums *VoteSums
+       var err error
 
-       if err != nil {
-               logger.Println("Error getting vote sums")
-               return
+       if voteSums, err = d.VoteSums(); err != nil {
+               log.Errorf("getting vote sums failed: %v", err)
+               return err
        }
-       votes := voteSums.VoteCount()
+       var reasoning string
+       d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
 
-       if votes < quorum {
-               d.Status = voteStatusDeclined
-       } else {
-               votes = voteSums.TotalVotes()
-               if (voteSums.Ayes / votes) > (majority / 100) {
-                       d.Status = voteStatusApproved
-               } else {
-                       d.Status = voteStatusDeclined
-               }
-       }
-
-       closeDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecisionStatus])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       closeDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
        defer closeDecisionStmt.Close()
 
        result, err := closeDecisionStmt.Exec(d)
        if err != nil {
-               logger.Println("Error closing vote:", err)
-               return
+               log.Errorf("closing vote failed: %v", err)
+               return err
        }
-       affectedRows, err := result.RowsAffected()
-       if err != nil {
-               logger.Println("Error getting affected rows:", err)
-       }
-       if affectedRows != 1 {
-               logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows)
+       if affectedRows, err := result.RowsAffected(); err != nil {
+               log.Errorf("getting affected rows failed: %v", err)
+               return err
+       } else if affectedRows != 1 {
+               log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
        }
 
-       notifyMail <- &NotificationClosedDecision{decision: *d, voteSums: *voteSums}
+       NotifyMailChannel <- NewNotificationClosedDecision(d, voteSums, reasoning)
 
-       return
+       log.Infof("decision %s closed with result %s: reasoning %s", d.Tag, d.Status, reasoning)
+
+       return nil
 }
 
 func CloseDecisions() (err error) {
-       getClosableDecisionsStmt, err := db.PrepareNamed(sqlStatements[sqlSelectClosableDecisions])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       getClosableDecisionsStmt := db.getPreparedNamedStatement(sqlSelectClosableDecisions)
        defer getClosableDecisionsStmt.Close()
 
        decisions := make([]*Decision, 0)
        rows, err := getClosableDecisionsStmt.Queryx(struct{ Now time.Time }{time.Now().UTC()})
        if err != nil {
-               logger.Println("Error fetching closable decisions", err)
+               log.Errorf("fetching closable decisions failed: %v", err)
                return
        }
        defer rows.Close()
        for rows.Next() {
                decision := &Decision{}
                if err = rows.StructScan(decision); err != nil {
-                       logger.Println("Error scanning row", err)
+                       log.Errorf("scanning row failed: %v", err)
                        return
                }
                decisions = append(decisions, decision)
@@ -611,9 +658,9 @@ func CloseDecisions() (err error) {
        rows.Close()
 
        for _, decision := range decisions {
-               logger.Println("DEBUG found closable decision", decision)
+               log.Debugf("found closable decision %s", decision.Tag)
                if err = decision.Close(); err != nil {
-                       logger.Printf("Error closing decision %s: %s\n", decision, err)
+                       log.Errorf("closing decision %s failed: %s", decision.Tag, err)
                        return
                }
        }
@@ -622,25 +669,78 @@ func CloseDecisions() (err error) {
 }
 
 func GetNextPendingDecisionDue() (due *time.Time, err error) {
-       getNextPendingDecisionDueStmt, err := db.Preparex(sqlStatements[sqlGetNextPendingDecisionDue])
-       if err != nil {
-               logger.Println("Error preparing statement:", err)
-               return
-       }
+       getNextPendingDecisionDueStmt := db.getPreparedStatement(sqlGetNextPendingDecisionDue)
        defer getNextPendingDecisionDueStmt.Close()
 
        row := getNextPendingDecisionDueStmt.QueryRow()
 
-       var dueTimestamp time.Time
-       if err = row.Scan(&dueTimestamp); err != nil {
+       due = &time.Time{}
+       if err = row.Scan(due); err != nil {
                if err == sql.ErrNoRows {
-                       logger.Println("DEBUG No pending decisions")
+                       log.Debug("No pending decisions")
                        return nil, nil
                }
-               logger.Println("Error parsing result", err)
+               log.Errorf("parsing result failed: %v", err)
+               return nil, err
+       }
+
+       return
+}
+
+func GetReminderVoters() (voters *[]Voter, err error) {
+       getReminderVotersStmt := db.getPreparedStatement(sqlGetReminderVoters)
+       defer getReminderVotersStmt.Close()
+
+       voterSlice := make([]Voter, 0)
+
+       if err = getReminderVotersStmt.Select(&voterSlice); err != nil {
+               log.Errorf("getting voters failed: %v", err)
+               return
+       }
+       voters = &voterSlice
+
+       return
+}
+
+func FindUnvotedDecisionsForVoter(voter *Voter) (decisions *[]Decision, err error) {
+       findUnvotedDecisionsForVoterStmt := db.getPreparedStatement(sqlFindUnvotedDecisionsForVoter)
+       defer findUnvotedDecisionsForVoterStmt.Close()
+
+       decisionsSlice := make([]Decision, 0)
+
+       if err = findUnvotedDecisionsForVoterStmt.Select(&decisionsSlice, voter.Id); err != nil {
+               log.Errorf("getting unvoted decisions failed: %v", err)
+               return
+       }
+       decisions = &decisionsSlice
+
+       return
+}
+
+func GetVoterById(id int64) (voter *Voter, err error) {
+       getVoterByIdStmt := db.getPreparedStatement(sqlGetEnabledVoterById)
+       defer getVoterByIdStmt.Close()
+
+       voter = &Voter{}
+       if err = getVoterByIdStmt.Get(voter, id); err != nil {
+               log.Errorf("getting voter failed: %v", err)
+               return
+       }
+
+       return
+}
+
+func GetVotersForProxy(proxy *Voter) (voters *[]Voter, err error) {
+       getVotersForProxyStmt := db.getPreparedStatement(sqlGetVotersForProxy)
+       defer 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
        }
-       due = &dueTimestamp
+       voters = &votersSlice
 
        return
 }