diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index cc32361..816c375 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -612,7 +612,18 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) func (app *application) userList(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) - // TODO: implement user listing + users, err := app.users.List( + r.Context(), + app.users.WithRolesOption(), + app.users.CanDeleteOption(), + ) + if err != nil { + app.serverError(w, err) + + return + } + + data.Users = users app.render(w, http.StatusOK, "users.html", data) } diff --git a/go.mod b/go.mod index 556503b..40a68ce 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/justinas/nosurf v1.1.1 github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb github.com/stretchr/testify v1.7.0 + golang.org/x/text v0.3.7 ) require ( diff --git a/go.sum b/go.sum index 0348e27..3538330 100644 --- a/go.sum +++ b/go.sum @@ -1492,6 +1492,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/models/models.go b/internal/models/models.go index 3ada42a..f5c02f3 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -55,4 +55,5 @@ const ( errCouldNotScanResult = "could not scan result: %w" errCouldNotStartTransaction = "could not start transaction: %w" errCouldNotCommitTransaction = "could not commit transaction: %w" + errCouldNotCreateInQuery = "could not create query with IN clause: %w" ) diff --git a/internal/models/users.go b/internal/models/users.go index 54eda22..9cf7529 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -24,12 +24,29 @@ import ( "strings" "github.com/jmoiron/sqlx" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type Role struct { Name string `db:"role"` } +func (r *Role) String() string { + return cases.Title(language.BritishEnglish).String(strings.ToLower(r.Name)) +} + +func (r *Role) Scan(src any) error { + value, ok := src.(string) + if !ok { + return fmt.Errorf("could not cast %v of %T to string", src, src) + } + + *r = Role{Name: value} + + return nil +} + type RoleName string const ( @@ -47,10 +64,11 @@ const ( // The columns cannot be dropped in SQLite without dropping and recreating // the voters table and all foreign key indices pointing to that table. type User struct { - ID int64 `db:"id"` - Name string `db:"name"` - Reminder string `db:"reminder"` // reminder email address - roles []*Role `db:"-"` + ID int64 `db:"id"` + Name string `db:"name"` + Reminder string `db:"reminder"` // reminder email address + roles []*Role `db:"-"` + canDelete bool } func (u *User) Roles() ([]*Role, error) { @@ -83,6 +101,10 @@ outer: return roleMatched, nil } +func (u *User) CanDelete() bool { + return u.canDelete +} + type UserModel struct { DB *sqlx.DB } @@ -322,3 +344,157 @@ WHERE v.id = ? return &user, nil } + +type UserListOption func(context.Context, []*User) error + +func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*User, error) { + rows, err := m.DB.QueryxContext(ctx, `SELECT id, name FROM voters ORDER BY name`) + if err != nil { + return nil, fmt.Errorf(errCouldNotExecuteQuery, err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + users := make([]*User, 0) + + for rows.Next() { + if err = rows.Err(); err != nil { + return nil, fmt.Errorf(errCouldNotFetchRow, err) + } + + var user User + if err = rows.StructScan(&user); err != nil { + return nil, fmt.Errorf(errCouldNotScanResult, err) + } + + users = append(users, &user) + } + + for _, option := range options { + if err = option(ctx, users); err != nil { + return nil, err + } + } + + return users, nil +} + +func (m *UserModel) WithRolesOption() UserListOption { + return func(ctx context.Context, users []*User) error { + if len(users) == 0 { + return nil + } + + userIDs := make([]int64, len(users)) + for idx := range users { + userIDs[idx] = users[idx].ID + } + + query, args, err := sqlx.In( + `SELECT voter_id, role FROM user_roles WHERE voter_id IN (?)`, + userIDs, + ) + if err != nil { + return fmt.Errorf(errCouldNotCreateInQuery, err) + } + + rows, err := m.DB.QueryxContext(ctx, query, args...) + if err != nil { + return fmt.Errorf(errCouldNotExecuteQuery, err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + roleMap := make(map[int64][]*Role, len(users)) + + for rows.Next() { + if err := rows.Err(); err != nil { + return fmt.Errorf(errCouldNotFetchRow, err) + } + + var userID int64 + var role Role + + if err := rows.Scan(&userID, &role); err != nil { + return fmt.Errorf(errCouldNotScanResult, err) + } + + if _, ok := roleMap[userID]; !ok { + roleMap[userID] = []*Role{&role} + } else { + roleMap[userID] = append(roleMap[userID], &role) + } + } + + for idx := range users { + if roles, ok := roleMap[users[idx].ID]; ok { + users[idx].roles = roles + + continue + } + + users[idx].roles = make([]*Role, 0) + } + + return nil + } +} + +func (m *UserModel) CanDeleteOption() UserListOption { + return func(ctx context.Context, users []*User) error { + if len(users) == 0 { + return nil + } + + userIDs := make([]int64, len(users)) + for idx := range users { + userIDs[idx] = users[idx].ID + } + + query, args, err := sqlx.In( + `SELECT id +FROM voters +WHERE id IN (?) + AND NOT EXISTS(SELECT * FROM decisions WHERE proponent = voters.id) + AND NOT EXISTS(SELECT * FROM votes WHERE voter = voters.id) + AND NOT EXISTS(SELECT * FROM user_roles WHERE voter_id = voters.id)`, + userIDs, + ) + if err != nil { + return fmt.Errorf(errCouldNotCreateInQuery, err) + } + + rows, err := m.DB.QueryxContext(ctx, query, args...) + if err != nil { + return fmt.Errorf(errCouldNotExecuteQuery, err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + for rows.Next() { + if err := rows.Err(); err != nil { + return fmt.Errorf(errCouldNotFetchRow, err) + } + + var userID int64 + + if err := rows.Scan(&userID); err != nil { + return fmt.Errorf(errCouldNotScanResult, err) + } + + for idx := range users { + if userID == users[idx].ID { + users[idx].canDelete = true + } + } + } + + return nil + } +} diff --git a/ui/html/pages/motions.html b/ui/html/pages/motions.html index 0e3c092..12a90d5 100644 --- a/ui/html/pages/motions.html +++ b/ui/html/pages/motions.html @@ -8,7 +8,7 @@ {{ range .Motions }}
{{ template "motion_display" . }} - {{ if $user }}{{ template "motion_actions" . }}{{ end }} + {{ if canVote $user }}{{ template "motion_actions" . }}{{ end }}
{{ end }} {{ template "pagination" $page }} diff --git a/ui/html/pages/users.html b/ui/html/pages/users.html index 3d7dc46..7526959 100644 --- a/ui/html/pages/users.html +++ b/ui/html/pages/users.html @@ -2,6 +2,35 @@ {{ define "main"}} {{ if .Users }} + + + + + + + + + + {{ $user := .User }} + {{ range .Users }} + + + + + + {{ end }} + +
NameRolesActions
{{ .Name }}{{ .Roles | join ", " }} + {{ if not (eq $user.ID .ID) }} + Edit + {{ if .CanDelete }} + + Delete + {{ end }} + {{ else }} + Cannot modify your own user. Ask another administrator or secretary. + {{ end }} +
{{ else }}