From 0c02daf29a2c8d58a846c75f81f6c338aa9164e3 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Fri, 27 May 2022 17:39:54 +0200 Subject: [PATCH] Implement direct voting --- cmd/boardvoting/handlers.go | 133 ++++++++----- cmd/boardvoting/helpers.go | 62 +++++- cmd/boardvoting/jobs.go | 2 +- cmd/boardvoting/middleware.go | 22 ++- cmd/boardvoting/notifications.go | 83 ++++++--- internal/forms/forms.go | 4 + internal/mailtemplates/direct_vote_mail.txt | 6 +- .../2022052701_add_vote_constrains.down.sql | 16 ++ .../2022052701_add_vote_constrains.up.sql | 24 +++ .../2022052702_add_emails_constrains.down.sql | 15 ++ .../2022052702_add_emails_constrains.up.sql | 18 ++ internal/models/motions.go | 176 +++++++++++------- models.go | 2 +- ui/html/pages/direct_vote.html | 24 +++ ui/html/partials/motion_display.html | 2 +- 15 files changed, 431 insertions(+), 158 deletions(-) create mode 100644 internal/migrations/2022052701_add_vote_constrains.down.sql create mode 100644 internal/migrations/2022052701_add_vote_constrains.up.sql create mode 100644 internal/migrations/2022052702_add_emails_constrains.down.sql create mode 100644 internal/migrations/2022052702_add_emails_constrains.up.sql create mode 100644 ui/html/pages/direct_vote.html diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 59df55d..968cc27 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -18,16 +18,15 @@ limitations under the License. package main import ( - "database/sql" "encoding/json" - "errors" "fmt" "net/http" "time" - "git.cacert.org/cacert-boardvoting/internal/forms" "github.com/julienschmidt/httprouter" + "git.cacert.org/cacert-boardvoting/internal/forms" + "git.cacert.org/cacert-boardvoting/internal/models" ) @@ -178,20 +177,10 @@ func (app *application) calculateMotionListOptions(r *http.Request) (*models.Mot func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) - tag := params.ByName("tag") - showVotes := r.URL.Query().Has("showvotes") - motion, err := app.motions.GetMotionByTag(r.Context(), tag, showVotes) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - app.serverError(w, err) - - return - } - + motion := app.motionFromRequestParam(w, r, params, showVotes) if motion == nil { - app.notFound(w) - return } @@ -267,7 +256,8 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) } app.mailNotifier.notifyChannel <- &NewDecisionNotification{ - Decision: &models.NewMotion{Decision: decision, Proposer: user}, + Decision: decision, + Proposer: user, } app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title)) @@ -278,18 +268,8 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) func (app *application) editMotionForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) - tag := params.ByName("tag") - - motion, err := app.motions.GetMotionByTag(r.Context(), tag, false) - if err != nil { - app.serverError(w, err) - - return - } - - if motion.ID == 0 { - app.notFound(w) - + motion := app.motionFromRequestParam(w, r, params, false) + if motion == nil { return } @@ -309,24 +289,14 @@ func (app *application) editMotionForm(w http.ResponseWriter, r *http.Request) { func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) - tag := params.ByName("tag") - - motion, err := app.motions.GetMotionByTag(r.Context(), tag, false) - if err != nil { - app.serverError(w, err) - - return - } - - if motion.ID == 0 { - app.notFound(w) - + motion := app.motionFromRequestParam(w, r, params, false) + if motion == nil { return } var form forms.EditMotionForm - err = app.decodePostForm(r, &form) + err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) @@ -362,6 +332,11 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) form.Content, now.Add(dueDuration), ) + if err != nil { + app.serverError(w, err) + + return + } decision, err := app.motions.GetByID(r.Context(), motion.ID) if err != nil { @@ -371,7 +346,8 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) } app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{ - Decision: &models.UpdatedMotion{Decision: decision, User: user}, + Decision: decision, + User: user, } app.sessionManager.Put( @@ -391,12 +367,79 @@ func (app *application) withdrawMotionSubmit(_ http.ResponseWriter, _ *http.Requ panic("not implemented") } -func (app *application) voteForm(_ http.ResponseWriter, _ *http.Request) { - panic("not implemented") +func (app *application) voteForm(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + motion := app.motionFromRequestParam(w, r, params, false) + if motion == nil { + return + } + + choice := app.choiceFromRequestParam(w, params) + if choice == nil { + return + } + + data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) + + data.Motion = motion + + data.Form = &forms.DirectVoteForm{ + Choice: choice, + } + + app.render(w, http.StatusOK, "direct_vote.html", data) } -func (app *application) voteSubmit(_ http.ResponseWriter, _ *http.Request) { - panic("not implemented") +func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + motion := app.motionFromRequestParam(w, r, params, false) + if motion == nil { + return + } + + choice := app.choiceFromRequestParam(w, params) + if choice == nil { + return + } + + user, err := app.GetUser(r) + if err != nil { + app.clientError(w, http.StatusUnauthorized) + + return + } + + clientCert, err := getPEMClientCert(r) + if err != nil { + app.serverError(w, err) + + return + } + + err = app.motions.DirectVote(r.Context(), user.ID, motion.ID, func(v *models.Vote) { + v.Vote = choice + v.Voted = time.Now().UTC() + v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert) + }) + if err != nil { + app.serverError(w, err) + + return + } + + app.mailNotifier.notifyChannel <- &DirectVoteNotification{ + Decision: motion, User: user, Choice: choice, + } + + app.sessionManager.Put( + r.Context(), + "flash", + fmt.Sprintf("Your vote for motion %s has been registered.", motion.Tag), + ) + + http.Redirect(w, r, "/motions", http.StatusSeeOther) } func (app *application) proxyVoteForm(_ http.ResponseWriter, _ *http.Request) { diff --git a/cmd/boardvoting/helpers.go b/cmd/boardvoting/helpers.go index 10c9b41..cf034dd 100644 --- a/cmd/boardvoting/helpers.go +++ b/cmd/boardvoting/helpers.go @@ -19,6 +19,8 @@ package main import ( "bytes" + "crypto/x509" + "encoding/pem" "errors" "fmt" "html/template" @@ -30,6 +32,7 @@ import ( "github.com/Masterminds/sprig/v3" "github.com/go-playground/form/v4" + "github.com/julienschmidt/httprouter" "github.com/justinas/nosurf" "git.cacert.org/cacert-boardvoting/internal/models" @@ -117,8 +120,8 @@ func newTemplateCache() (map[string]*template.Template, error) { type templateData struct { PrevPage string NextPage string - Motion *models.MotionForDisplay - Motions []*models.MotionForDisplay + Motion *models.Motion + Motions []*models.Motion User *models.User Users []*models.User Request *http.Request @@ -167,3 +170,58 @@ func (app *application) render(w http.ResponseWriter, status int, page string, d _, _ = buf.WriteTo(w) } + +func (app *application) motionFromRequestParam( + w http.ResponseWriter, + r *http.Request, + params httprouter.Params, + withVotes bool, +) *models.Motion { + tag := params.ByName("tag") + + motion, err := app.motions.GetMotionByTag(r.Context(), tag, withVotes) + if err != nil { + app.serverError(w, err) + + return nil + } + + if motion.ID == 0 { + app.notFound(w) + + return nil + } + + return motion +} + +func (app *application) choiceFromRequestParam(w http.ResponseWriter, params httprouter.Params) *models.VoteChoice { + choiceParam := params.ByName("choice") + + choice, err := models.VoteChoiceFromString(choiceParam) + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return nil + } + + return choice +} + +func getPEMClientCert(r *http.Request) (string, error) { + cert := r.Context().Value(ctxAuthenticatedCert) + + authenticatedCertificate, ok := cert.(*x509.Certificate) + if !ok { + return "", errors.New("could not handle certificate as x509.Certificate") + } + + clientCertPEM := bytes.NewBuffer(make([]byte, 0)) + + err := pem.Encode(clientCertPEM, &pem.Block{Type: "CERTIFICATE", Bytes: authenticatedCertificate.Raw}) + if err != nil { + return "", fmt.Errorf("error encoding client certificate: %w", err) + } + + return clientCertPEM.String(), nil +} diff --git a/cmd/boardvoting/jobs.go b/cmd/boardvoting/jobs.go index b9a4870..8e19fda 100644 --- a/cmd/boardvoting/jobs.go +++ b/cmd/boardvoting/jobs.go @@ -173,7 +173,7 @@ func (c *CloseDecisionsJob) Run() { } for _, res := range results { - c.notify <- &ClosedDecisionNotification{decision: res} + c.notify <- &ClosedDecisionNotification{Decision: res} } c.reschedule <- c diff --git a/cmd/boardvoting/middleware.go b/cmd/boardvoting/middleware.go index 9224ec2..4d173a4 100644 --- a/cmd/boardvoting/middleware.go +++ b/cmd/boardvoting/middleware.go @@ -19,6 +19,7 @@ package main import ( "context" + "crypto/x509" "errors" "fmt" "net/http" @@ -33,6 +34,7 @@ type contextKey int const ( ctxUser contextKey = iota + ctxAuthenticatedCert ) func secureHeaders(next http.Handler) http.Handler { @@ -56,28 +58,29 @@ func (app *application) logRequest(next http.Handler) http.Handler { }) } -func (app *application) authenticateRequest(r *http.Request) (*models.User, error) { +func (app *application) authenticateRequest(r *http.Request) (*models.User, *x509.Certificate, error) { if r.TLS == nil { - return nil, nil + return nil, nil, nil } if len(r.TLS.PeerCertificates) < 1 { - return nil, nil + return nil, nil, nil } - emails := r.TLS.PeerCertificates[0].EmailAddresses + clientCert := r.TLS.PeerCertificates[0] + emails := clientCert.EmailAddresses user, err := app.users.GetUser(r.Context(), emails) if err != nil { - return nil, fmt.Errorf("could not get user information from database: %w", err) + return nil, nil, fmt.Errorf("could not get user information from database: %w", err) } - return user, nil + return user, clientCert, nil } func (app *application) tryAuthenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, err := app.authenticateRequest(r) + user, cert, err := app.authenticateRequest(r) if err != nil { app.serverError(w, err) @@ -92,7 +95,10 @@ func (app *application) tryAuthenticate(next http.Handler) http.Handler { w.Header().Add("Cache-Control", "no-store") - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user))) + certContext := context.WithValue(r.Context(), ctxAuthenticatedCert, cert) + userContext := context.WithValue(certContext, ctxUser, user) + + next.ServeHTTP(w, r.WithContext(userContext)) }) } diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go index 15fc6d5..34a541d 100644 --- a/cmd/boardvoting/notifications.go +++ b/cmd/boardvoting/notifications.go @@ -148,36 +148,51 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati } func defaultRecipient(mc *mailConfig) recipientData { - return recipientData{field: "To", address: mc.NoticeMailAddress, name: "CAcert board mailing list"} + return recipientData{ + field: "To", + address: mc.NoticeMailAddress, + name: "CAcert board mailing list", + } +} + +func voteNoticeRecipient(mc *mailConfig) recipientData { + return recipientData{ + field: "To", + address: mc.VoteNoticeMailAddress, + name: "CAcert board votes mailing list", + } +} + +func motionReplyHeaders(m *models.Motion) map[string][]string { + return map[string][]string{ + "References": {fmt.Sprintf("<%s>", m.Tag)}, + "In-Reply-To": {fmt.Sprintf("<%s>", m.Tag)}, + } } type ClosedDecisionNotification struct { - decision *models.ClosedMotion + Decision *models.Motion } func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { return &NotificationContent{ - template: "closed_motion_mail.txt", - data: c.decision, - subject: fmt.Sprintf("Re: %s - %s - finalised", c.decision.Decision.Tag, c.decision.Decision.Title), - headers: c.getHeaders(), + template: "closed_motion_mail.txt", + data: struct { + *models.Motion + }{c.Decision}, + subject: fmt.Sprintf("Re: %s - %s - finalised", c.Decision.Tag, c.Decision.Title), + headers: motionReplyHeaders(c.Decision), recipients: []recipientData{defaultRecipient(mc)}, } } -func (c *ClosedDecisionNotification) getHeaders() map[string][]string { - return map[string][]string{ - "References": {fmt.Sprintf("<%s>", c.decision.Decision.Tag)}, - "In-Reply-To": {fmt.Sprintf("<%s>", c.decision.Decision.Tag)}, - } -} - type NewDecisionNotification struct { - Decision *models.NewMotion + Decision *models.Motion + Proposer *models.User } func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { - voteURL := fmt.Sprintf("/vote/%s", n.Decision.Decision.Tag) + voteURL := fmt.Sprintf("/vote/%s", n.Decision.Tag) unvotedURL := "/motions/?unvoted=1" return &NotificationContent{ @@ -187,8 +202,8 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific Name string VoteURL string UnvotedURL string - }{n.Decision.Decision, n.Decision.Proposer.Name, voteURL, unvotedURL}, - subject: fmt.Sprintf("%s - %s", n.Decision.Decision.Tag, n.Decision.Decision.Title), + }{n.Decision, n.Proposer.Name, voteURL, unvotedURL}, + subject: fmt.Sprintf("%s - %s", n.Decision.Tag, n.Decision.Title), headers: n.getHeaders(), recipients: []recipientData{defaultRecipient(mc)}, } @@ -196,16 +211,17 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific func (n NewDecisionNotification) getHeaders() map[string][]string { return map[string][]string{ - "Message-ID": {fmt.Sprintf("<%s>", n.Decision.Decision.Tag)}, + "Message-ID": {fmt.Sprintf("<%s>", n.Decision.Tag)}, } } type UpdateDecisionNotification struct { - Decision *models.UpdatedMotion + Decision *models.Motion + User *models.User } func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { - voteURL := fmt.Sprintf("/vote/%s", u.Decision.Decision.Tag) + voteURL := fmt.Sprintf("/vote/%s", u.Decision.Tag) unvotedURL := "/motions/?unvoted=1" return &NotificationContent{ @@ -215,16 +231,29 @@ func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *Noti Name string VoteURL string UnvotedURL string - }{u.Decision.Decision, u.Decision.User.Name, voteURL, unvotedURL}, - subject: fmt.Sprintf("%s - %s", u.Decision.Decision.Tag, u.Decision.Decision.Title), - headers: u.getHeaders(), + }{u.Decision, u.User.Name, voteURL, unvotedURL}, + subject: fmt.Sprintf("%s - %s", u.Decision.Tag, u.Decision.Title), + headers: motionReplyHeaders(u.Decision), recipients: []recipientData{defaultRecipient(mc)}, } } -func (u UpdateDecisionNotification) getHeaders() map[string][]string { - return map[string][]string{ - "References": {fmt.Sprintf("<%s>", u.Decision.Decision.Tag)}, - "In-Reply-To": {fmt.Sprintf("<%s>", u.Decision.Decision.Tag)}, +type DirectVoteNotification struct { + Decision *models.Motion + User *models.User + Choice *models.VoteChoice +} + +func (d DirectVoteNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { + return &NotificationContent{ + template: "direct_vote_mail.txt", + data: struct { + *models.Motion + Name string + Choice *models.VoteChoice + }{d.Decision, d.User.Name, d.Choice}, + subject: fmt.Sprintf("Re: %s - %s", d.Decision.Tag, d.Decision.Title), + headers: motionReplyHeaders(d.Decision), + recipients: []recipientData{voteNoticeRecipient(mc)}, } } diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 690fba4..9c7025d 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -125,3 +125,7 @@ func (f EditMotionForm) Validate() { f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice", ) } + +type DirectVoteForm struct { + Choice *models.VoteChoice +} diff --git a/internal/mailtemplates/direct_vote_mail.txt b/internal/mailtemplates/direct_vote_mail.txt index 95bacb8..6e259ad 100644 --- a/internal/mailtemplates/direct_vote_mail.txt +++ b/internal/mailtemplates/direct_vote_mail.txt @@ -1,10 +1,10 @@ Dear Board, -{{ .Voter }} has just voted {{ .Vote }} on motion {{ .Decision.Tag }}. +{{ .Data.Name }} has just voted {{ .Data.Choice }} on motion {{ .Data.Tag }}. Motion: - {{ .Decision.Title }} - {{ .Decision.Content }} + {{ .Data.Title }} + {{ .Data.Content }} Kind regards, the vote system \ No newline at end of file diff --git a/internal/migrations/2022052701_add_vote_constrains.down.sql b/internal/migrations/2022052701_add_vote_constrains.down.sql new file mode 100644 index 0000000..5a31eac --- /dev/null +++ b/internal/migrations/2022052701_add_vote_constrains.down.sql @@ -0,0 +1,16 @@ +-- drop unique constraint for motion by specific voter from votes +CREATE TABLE votes_new +( + decision INTEGER NOT NULL, + voter INTEGER NOT NULL, + vote INTEGER NOT NULL, + voted DATETIME NOT NULL, + notes TEXT NOT NULL DEFAULT '' +); + +INSERT INTO votes_new (decision, voter, vote, voted, notes) +SELECT decision, voter, vote, voted, notes +FROM votes; + +DROP TABLE votes; +ALTER TABLE votes_new RENAME TO votes; \ No newline at end of file diff --git a/internal/migrations/2022052701_add_vote_constrains.up.sql b/internal/migrations/2022052701_add_vote_constrains.up.sql new file mode 100644 index 0000000..1ec05df --- /dev/null +++ b/internal/migrations/2022052701_add_vote_constrains.up.sql @@ -0,0 +1,24 @@ +-- add constraints on votes table +CREATE TABLE votes_new +( + decision INTEGER NOT NULL REFERENCES decisions (id), + voter INTEGER NOT NULL REFERENCES voters (id), + vote INTEGER NOT NULL, + voted DATETIME NOT NULL, + notes TEXT NOT NULL DEFAULT '', + PRIMARY KEY (decision, voter) +); +INSERT INTO votes_new (decision, voter, vote, voted, notes) +SELECT decision, + voter, + vote, + voted, + notes +FROM votes +GROUP BY decision, voter +HAVING MAX(voted) = voted; + +ALTER TABLE votes + RENAME TO votes_orig_with_duplicates; +ALTER TABLE votes_new + RENAME TO votes; diff --git a/internal/migrations/2022052702_add_emails_constrains.down.sql b/internal/migrations/2022052702_add_emails_constrains.down.sql new file mode 100644 index 0000000..35b976e --- /dev/null +++ b/internal/migrations/2022052702_add_emails_constrains.down.sql @@ -0,0 +1,15 @@ +-- drop constraints from votes table +CREATE TABLE emails_new +( + voter INT4, + address VARCHAR(255) +); + +INSERT INTO emails_new (voter, address) +SELECT voter, address +FROM emails; + +DROP TABLE emails; +ALTER TABLE emails_new + RENAME TO emails; +DROP TABLE emails_backup; \ No newline at end of file diff --git a/internal/migrations/2022052702_add_emails_constrains.up.sql b/internal/migrations/2022052702_add_emails_constrains.up.sql new file mode 100644 index 0000000..07680ba --- /dev/null +++ b/internal/migrations/2022052702_add_emails_constrains.up.sql @@ -0,0 +1,18 @@ +-- add constraints on votes table +CREATE TABLE emails_new +( + id INTEGER PRIMARY KEY, + voter INTEGER NOT NULL REFERENCES voters (id), + address VARCHAR(255) NOT NULL UNIQUE, + reminder bool NOT NULL DEFAULT FALSE +); +INSERT INTO emails_new (voter, address, reminder) +SELECT emails.voter, + emails.address, + EXISTS(SELECT * FROM voters WHERE voters.reminder = emails.address AND voters.id = emails.voter) +FROM emails; + +ALTER TABLE emails + RENAME TO emails_backup; +ALTER TABLE emails_new + RENAME TO emails; diff --git a/internal/models/motions.go b/internal/models/motions.go index 496c6c9..f119021 100644 --- a/internal/models/motions.go +++ b/internal/models/motions.go @@ -35,8 +35,6 @@ type VoteType struct { id uint8 } -const unknownVariant = "unknown" - var ( VoteTypeMotion = &VoteType{label: "motion", id: 0} VoteTypeVeto = &VoteType{label: "veto", id: 1} @@ -103,19 +101,19 @@ func (v *VoteType) QuorumAndMajority() (int, float32) { type VoteStatus struct { Label string - Id int8 + ID int8 } var ( - voteStatusDeclined = &VoteStatus{Label: "declined", Id: -1} - voteStatusPending = &VoteStatus{Label: "pending", Id: 0} - voteStatusApproved = &VoteStatus{Label: "approved", Id: 1} - voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", Id: -2} + voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1} + voteStatusPending = &VoteStatus{Label: "pending", ID: 0} + voteStatusApproved = &VoteStatus{Label: "approved", ID: 1} + voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2} ) func VoteStatusFromInt(id int64) (*VoteStatus, error) { for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} { - if int64(vs.Id) == id { + if int64(vs.ID) == id { return vs, nil } } @@ -144,23 +142,23 @@ func (v *VoteStatus) Scan(src any) error { } func (v *VoteStatus) Value() (driver.Value, error) { - return int64(v.Id), nil + return int64(v.ID), nil } type VoteChoice struct { - label string - id int8 + Label string + ID int8 } var ( - VoteAye = &VoteChoice{label: "aye", id: 1} - VoteNaye = &VoteChoice{label: "naye", id: -1} - VoteAbstain = &VoteChoice{label: "abstain", id: 0} + VoteAye = &VoteChoice{Label: "aye", ID: 1} + VoteNaye = &VoteChoice{Label: "naye", ID: -1} + VoteAbstain = &VoteChoice{Label: "abstain", ID: 0} ) func VoteChoiceFromString(label string) (*VoteChoice, error) { for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} { - if strings.EqualFold(vc.label, label) { + if strings.EqualFold(vc.Label, label) { return vc, nil } } @@ -170,7 +168,7 @@ func VoteChoiceFromString(label string) (*VoteChoice, error) { func VoteChoiceFromInt(id int64) (*VoteChoice, error) { for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} { - if int64(vc.id) == id { + if int64(vc.ID) == id { return vc, nil } } @@ -179,7 +177,7 @@ func VoteChoiceFromInt(id int64) (*VoteChoice, error) { } func (v *VoteChoice) String() string { - return v.label + return v.Label } func (v *VoteChoice) Scan(src any) error { @@ -199,11 +197,11 @@ func (v *VoteChoice) Scan(src any) error { } func (v *VoteChoice) Value() (driver.Value, error) { - return int64(v.id), nil + return int64(v.ID), nil } func (v *VoteChoice) Equal(other *VoteChoice) bool { - return v.id == other.id + return v.ID == other.ID } type VoteSums struct { @@ -233,7 +231,8 @@ func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, s type Motion struct { ID int64 `db:"id"` Proposed time.Time - Proponent int64 `db:"proponent"` + Proponent int64 `db:"proponent"` + Proposer string `db:"proposer"` Title string Content string Status *VoteStatus @@ -241,22 +240,9 @@ type Motion struct { Modified time.Time Tag string Type *VoteType `db:"votetype"` -} - -type ClosedMotion struct { - Decision *Motion - VoteSums *VoteSums - Reasoning string -} - -type NewMotion struct { - Decision *Motion - Proposer *User -} - -type UpdatedMotion struct { - Decision *Motion - User *User + Sums *VoteSums `db:"-"` + Votes []*Vote `db:"-"` + Reasoning string `db:"-"` } type MotionModel struct { @@ -279,6 +265,7 @@ func (m *MotionModel) Create( Content: content, Due: due.UTC(), Type: voteType, + Status: voteStatusPending, } result, err := m.DB.NamedExecContext( @@ -305,7 +292,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop return id, nil } -func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*ClosedMotion, error) { +func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*Motion, error) { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("could not start transaction: %w", err) @@ -343,9 +330,9 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now decisions = append(decisions, decision) } - results := make([]*ClosedMotion, 0, len(decisions)) + results := make([]*Motion, 0, len(decisions)) - var decisionResult *ClosedMotion + var decisionResult *Motion for _, decision := range decisions { m.InfoLog.Printf("found closable decision %s", decision.Tag) @@ -364,7 +351,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now return results, nil } -func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*ClosedMotion, error) { +func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) { quorum, majority := d.Type.QuorumAndMajority() var ( @@ -400,7 +387,10 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) m.InfoLog.Printf("decision %s closed with result %s: reasoning '%s'", d.Tag, d.Status, reasoning) - return &ClosedMotion{d, voteSums, reasoning}, nil + d.Sums = voteSums + d.Reasoning = reasoning + + return d, nil } func (m *MotionModel) UnVotedDecisionsForVoter(_ context.Context, _ *User) ([]*Motion, error) { @@ -480,22 +470,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e return &due, nil } -type MotionForDisplay struct { - ID int64 `db:"id"` - Tag string - Proponent int64 - Proposer string - Proposed time.Time - Title string - Content string - Type *VoteType `db:"votetype"` - Status *VoteStatus - Due time.Time - Modified time.Time - Sums VoteSums - Votes []*VoteForDisplay -} - type VoteForDisplay struct { Name string Vote *VoteChoice @@ -557,7 +531,7 @@ WHERE due >= ? return firstTs, lastTs, nil } -func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*MotionForDisplay, error) { +func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*Motion, error) { var ( rows *sqlx.Rows err error @@ -580,10 +554,10 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions _ = rows.Close() }(rows) - motions := make([]*MotionForDisplay, 0, options.Limit) + motions := make([]*Motion, 0, options.Limit) for rows.Next() { - var decision MotionForDisplay + var decision Motion if err = rows.Err(); err != nil { return nil, fmt.Errorf("could not fetch row: %w", err) @@ -606,11 +580,12 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions return motions, nil } -func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*MotionForDisplay) error { +func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) error { decisionIds := make([]int64, len(decisions)) - decisionMap := make(map[int64]*MotionForDisplay, len(decisions)) + decisionMap := make(map[int64]*Motion, len(decisions)) for idx, decision := range decisions { + decision.Sums = &VoteSums{} decisionIds[idx] = decision.ID decisionMap[decision.ID] = decision } @@ -781,7 +756,7 @@ LIMIT ?`, return rows, nil } -func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*MotionForDisplay, error) { +func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) { row := m.DB.QueryRowxContext( ctx, `SELECT decisions.id, @@ -804,13 +779,13 @@ WHERE decisions.tag = ?`, return nil, fmt.Errorf("could not query motion: %w", err) } - var result MotionForDisplay + var result Motion if err := row.StructScan(&result); err != nil { return nil, fmt.Errorf("could not fill motion from query result: %w", err) } - if err := m.FillVoteSums(ctx, []*MotionForDisplay{&result}); err != nil { + if err := m.FillVoteSums(ctx, []*Motion{&result}); err != nil { return nil, fmt.Errorf("could not get vote sums: %w", err) } @@ -823,7 +798,7 @@ WHERE decisions.tag = ?`, return &result, nil } -func (m *MotionModel) FillVotes(ctx context.Context, md *MotionForDisplay) error { +func (m *MotionModel) FillVotes(ctx context.Context, md *Motion) error { rows, err := m.DB.QueryxContext(ctx, `SELECT voters.name, votes.vote FROM voters @@ -848,13 +823,13 @@ ORDER BY voters.name`, return fmt.Errorf("could not get row: %w", err) } - var voteDisplay VoteForDisplay + var vote Vote - if err := rows.StructScan(&voteDisplay); err != nil { + if err := rows.StructScan(&vote); err != nil { return fmt.Errorf("could not scan row: %w", err) } - md.Votes = append(md.Votes, &voteDisplay) + md.Votes = append(md.Votes, &vote) } return nil @@ -876,7 +851,14 @@ func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) { return &motion, nil } -func (m *MotionModel) Update(ctx context.Context, id int64, voteType *VoteType, title string, content string, due time.Time) error { +func (m *MotionModel) Update( + ctx context.Context, + id int64, + voteType *VoteType, + title string, + content string, + due time.Time, +) error { _, err := m.DB.ExecContext( ctx, `UPDATE decisions SET title=?, content=?, votetype=?, due=?, modified=? WHERE id=?`, @@ -888,3 +870,57 @@ func (m *MotionModel) Update(ctx context.Context, id int64, voteType *VoteType, return nil } + +type Vote struct { + UserID int64 `db:"voter"` + MotionID int64 `db:"decision"` + Vote *VoteChoice `db:"vote"` + Voted time.Time `db:"voted"` + Notes string `db:"notes"` + Name string `db:"name"` +} + +func (m *MotionModel) DirectVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error { + tx, err := m.DB.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("could not start transaction: %w", err) + } + + defer func(tx *sqlx.Tx) { + _ = tx.Rollback() + }(tx) + + row := tx.QueryRowxContext(ctx, `SELECT * FROM votes WHERE voter=? AND decision=?`, userID, motionID) + if err := row.Err(); err != nil { + return fmt.Errorf("could not execute query: %w", err) + } + + vote := Vote{UserID: userID, MotionID: motionID} + + if err := row.StructScan(&vote); err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("could not scan vote structure: %w", err) + } + + performVoteFn(&vote) + + if _, err := tx.NamedExecContext( + ctx, + `INSERT INTO votes (decision, voter, vote, voted, notes) +VALUES (:decision, :voter, :vote, :voted, :notes) +ON CONFLICT (decision, voter) + DO UPDATE SET vote=:vote, + voted=:voted, + notes=:notes +WHERE decision = :decision + AND voter = :voter`, + vote, + ); err != nil { + return fmt.Errorf("could not insert or update vote: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + return nil +} diff --git a/models.go b/models.go index 368785a..b60d096 100644 --- a/models.go +++ b/models.go @@ -285,7 +285,7 @@ type DbHandler struct { var db *DbHandler -//go:embed boardvoting/migrations/* +// go:embed boardvoting/migrations/* var migrations embed.FS func NewDB(database *sql.DB) *DbHandler { diff --git a/ui/html/pages/direct_vote.html b/ui/html/pages/direct_vote.html new file mode 100644 index 0000000..395ab77 --- /dev/null +++ b/ui/html/pages/direct_vote.html @@ -0,0 +1,24 @@ +{{ define "title" }}Vote on Motion {{ .Motion.Tag }}{{ end }} + +{{ define "main" }} +
+ {{ template "motion_display" .Motion }} +
+ + {{ with .Form }} +
+ {{ if eq "aye" .Choice.Label }} + + {{ else if eq "naye" .Choice.Label }} + + {{ else }} + + {{ end }} +
+ {{ end }} +
+
+{{ end }} \ No newline at end of file diff --git a/ui/html/partials/motion_display.html b/ui/html/partials/motion_display.html index 2b6f0da..ecf2145 100644 --- a/ui/html/partials/motion_display.html +++ b/ui/html/partials/motion_display.html @@ -11,7 +11,7 @@ Proposed - {{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }} + {{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }} by {{ .Proposer }} Vote type: