retooling signup for sql

vilmibm 2023-02-23 00:04:26 +00:00
parent 717c1b93f1
commit 1ed390feba
6 changed files with 216 additions and 168 deletions

View File

@ -1,27 +1,23 @@
package main package main
import ( import (
"encoding/json" "database/sql"
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
"os" "os"
"os/user" "os/user"
"path"
"strings" "strings"
"time" "time"
"git.tilde.town/tildetown/town/models"
//"git.tilde.town/tildetown/town/review"
"git.tilde.town/tildetown/town/signup"
tuser "git.tilde.town/tildetown/town/user" tuser "git.tilde.town/tildetown/town/user"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
const (
signupDir = "/town/signups"
acceptedDir = "/town/signups/accepted"
rejectedDir = "/town/signups/rejected"
)
func getTitle() string { func getTitle() string {
titles := []string{ titles := []string{
"yo bum rush the show", "yo bum rush the show",
@ -34,37 +30,36 @@ func getTitle() string {
return titles[rand.Intn(len(titles))] return titles[rand.Intn(len(titles))]
} }
type townSignup struct { // TODO affordance for cleaning up email response
When time.Time
DecisionTime time.Time type reviewer struct {
Decision string db *sql.DB
Filename string adminName string
Answers map[string]string
} }
func (s townSignup) Accept() error { func newReviewer(db *sql.DB, adminName string) *reviewer {
return s.review("accept") return &reviewer{db: db, adminName: adminName}
} }
func (s townSignup) Reject() error { func (r *reviewer) Review(s *models.TownSignup, decision models.SignupDecision) error {
return s.review("reject")
}
func (s townSignup) review(decision string) error {
s.DecisionTime = time.Now() s.DecisionTime = time.Now()
s.Decision = "decision" s.Decision = decision
oldpath := path.Join(signupDir, s.Filename) s.DecidedBy = r.adminName
newpath := path.Join(acceptedDir, s.Filename) s.Insert(r.db)
if decision == "reject" { return nil
newpath = path.Join(rejectedDir, s.Filename)
}
return os.Rename(oldpath, newpath)
} }
func (s townSignup) Render() string { func renderSignup(s models.TownSignup) string {
out := fmt.Sprintf("[-:-:b]submitted:[-:-:-] %s\n", s.When.Format("2006-01-02 15:04")) out := fmt.Sprintf("[-:-:b]submitted:[-:-:-] %s\n", s.Created.Format("2006-01-02 15:04"))
for k, v := range s.Answers { pairs := map[string]string{
"e-mail": s.Email,
"how found / referral": s.How,
"why like town / what do": s.Why,
"links": s.Links,
}
for k, v := range pairs {
out += fmt.Sprintf("[-:-:b]%s[-:-:-]\n", k) out += fmt.Sprintf("[-:-:b]%s[-:-:-]\n", k)
out += strings.TrimSpace(v) out += strings.TrimSpace(v)
out += "\n\n" out += "\n\n"
@ -73,39 +68,20 @@ func (s townSignup) Render() string {
return out return out
} }
func getSignups() ([]townSignup, error) {
entries, err := os.ReadDir(signupDir)
if err != nil {
return nil, fmt.Errorf("failed to read '%s': %w", signupDir, err)
}
out := []townSignup{}
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), "json") {
continue
}
abs := path.Join(signupDir, entry.Name())
data, err := os.ReadFile(abs)
if err != nil {
return nil, fmt.Errorf("could not read signup file '%s': %w", abs, err)
}
fmt.Println(string(data))
var signup townSignup
err = json.Unmarshal(data, &signup)
if err != nil {
return nil, fmt.Errorf("could not unmarshal signup file '%s': %w", abs, err)
}
signup.Filename = entry.Name()
out = append(out, signup)
}
return out, nil
}
func _main() error { func _main() error {
/*
TODO will use this for invites
userDB, err := review.ConnectDB()
if err != nil {
return fmt.Errorf("could not connect to user database: %w", err)
}
*/
signupDB, err := signup.ConnectDB()
if err != nil {
return fmt.Errorf("could not connect to signups database: %w", err)
}
u, err := user.Current() u, err := user.Current()
if err != nil { if err != nil {
return fmt.Errorf("that's my purse. I don't know you! %w", err) return fmt.Errorf("that's my purse. I don't know you! %w", err)
@ -118,11 +94,16 @@ func _main() error {
if !isAdmin { if !isAdmin {
return errors.New("this command can only be run by a town admin") return errors.New("this command can only be run by a town admin")
} }
r := newReviewer(signupDB, u.Username)
rand.Seed(time.Now().Unix()) rand.Seed(time.Now().Unix())
signups, err := getSignups() su := models.TownSignup{}
signups, err := su.All(signupDB)
if err != nil { if err != nil {
return fmt.Errorf("could not get signups: %w", err) return fmt.Errorf("could not fetch signups: %w", err)
} }
signupIx := 0 signupIx := 0
@ -138,7 +119,7 @@ func _main() error {
if len(signups) == 0 { if len(signups) == 0 {
appView.SetText("no signups found.") appView.SetText("no signups found.")
} else { } else {
appView.SetText(signups[signupIx].Render()) appView.SetText(renderSignup(*signups[signupIx]))
} }
legend := tview.NewTextView() legend := tview.NewTextView()
@ -176,7 +157,7 @@ func _main() error {
notate := tview.NewForm() notate := tview.NewForm()
notate.AddTextArea("note", "", 80, 10, 1000, func(string) {}) notate.AddTextArea("note", "", 80, 10, 1000, func(string) {})
notate.AddButton("submit", func() { notate.AddButton("submit", func() {
// TODO add note; this will require re-serializing and saving the file // add note and update
pages.SwitchToPage("main") pages.SwitchToPage("main")
}) })
notate.AddButton("cancel", func() { notate.AddButton("cancel", func() {
@ -200,11 +181,11 @@ func _main() error {
if signupIx == len(signups) { if signupIx == len(signups) {
signupIx = 0 signupIx = 0
} }
appView.SetText(signups[signupIx].Render()) appView.SetText(renderSignup(*signups[signupIx]))
} }
removeSignup := func(signup townSignup) { removeSignup := func(signup *models.TownSignup) {
newSignups := []townSignup{} newSignups := []*models.TownSignup{}
for ix, s := range signups { for ix, s := range signups {
if ix != signupIx { if ix != signupIx {
newSignups = append(newSignups, s) newSignups = append(newSignups, s)
@ -216,7 +197,7 @@ func _main() error {
signupIx = 0 signupIx = 0
} }
} }
appView.SetText(signups[signupIx].Render()) appView.SetText(renderSignup(*signups[signupIx]))
} }
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@ -226,29 +207,32 @@ func _main() error {
case 'r': case 'r':
if len(signups) > 0 { if len(signups) > 0 {
signupIx = rand.Intn(len(signups)) signupIx = rand.Intn(len(signups))
appView.SetText(signups[signupIx].Render()) appView.SetText(renderSignup(*signups[signupIx]))
} }
case 'A': case 'A':
if len(signups) == 0 { if len(signups) == 0 {
return nil return nil
} }
// TODO modal for collecting clean email
signup := signups[signupIx] signup := signups[signupIx]
err := signup.Accept() err := r.Review(signup, models.SignupAccepted)
if err != nil { if err != nil {
errorModal.SetText(fmt.Sprintf("error! failed to approve '%s': %s", signup.Filename, err.Error())) errorModal.SetText(fmt.Sprintf("error! failed to approve '%d': %s", signup.ID, err.Error()))
pages.SwitchToPage("error") pages.SwitchToPage("error")
return nil return nil
} }
removeSignup(signup) removeSignup(signup)
updateCount() updateCount()
// TODO generate invite token
// TODO send invite email
case 'R': case 'R':
if len(signups) == 0 { if len(signups) == 0 {
return nil return nil
} }
signup := signups[signupIx] signup := signups[signupIx]
err = signup.Reject() err = r.Review(signup, models.SignupRejected)
if err != nil { if err != nil {
errorModal.SetText(fmt.Sprintf("error! failed to reject '%s': %s", signup.Filename, err.Error())) errorModal.SetText(fmt.Sprintf("error! failed to reject '%d': %s", signup.ID, err.Error()))
pages.SwitchToPage("error") pages.SwitchToPage("error")
return nil return nil
} }

View File

@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"database/sql"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -10,9 +11,11 @@ import (
"strings" "strings"
"time" "time"
"git.tilde.town/tildetown/town/models"
"git.tilde.town/tildetown/town/signup" "git.tilde.town/tildetown/town/signup"
"github.com/MakeNowJust/heredoc/v2" "github.com/MakeNowJust/heredoc/v2"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
_ "github.com/mattn/go-sqlite3"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
@ -115,7 +118,7 @@ func main() {
logger := log.New(logF, "", log.Ldate|log.Ltime) logger := log.New(logF, "", log.Ldate|log.Ltime)
db, err := signup.NewDB() db, err := signup.ConnectDB()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(2) os.Exit(2)
@ -128,7 +131,7 @@ func main() {
} }
} }
func _main(l *log.Logger, db *signup.DB) error { func _main(l *log.Logger, db *sql.DB) error {
l.Println("starting a session") l.Println("starting a session")
pages := tview.NewPages() pages := tview.NewPages()
mainFlex := tview.NewFlex() mainFlex := tview.NewFlex()
@ -163,11 +166,11 @@ func _main(l *log.Logger, db *signup.DB) error {
player := newCharacter("you", "TODO") player := newCharacter("you", "TODO")
su := &signup.TownSignup{ID: -1} su := &models.TownSignup{ID: -1}
save := func() { save := func() {
su.Created = time.Now() su.Created = time.Now()
err := db.InsertSignup(su) err := su.Insert(db)
if err != nil { if err != nil {
l.Printf("failed to write to db: %s", err.Error()) l.Printf("failed to write to db: %s", err.Error())

119
models/models.go 100644
View File

@ -0,0 +1,119 @@
// shared database related code
package models
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
// TODO this is in flux; might want SignupNote and UserNote structs separately
type AdminNote struct {
ID int64
Admin string
Note string
When time.Time
}
type SignupDecision string
const (
SignupAccepted SignupDecision = "accepted"
SignupRejected SignupDecision = "rejected"
)
// TODO add DecisionTime column to DB
// TODO add DecisionBy column to DB
// TODO add CleanEmail column to DB
type TownSignup struct {
ID int64
Created time.Time
Email string
How string
Why string
Links string
Notes []AdminNote
Decision SignupDecision
DecisionTime time.Time
DecidedBy string
CleanEmail string
}
func (s *TownSignup) Insert(db *sql.DB) error {
stmt, err := db.Prepare(`
INSERT INTO signups (created, email, how, why, links) VALUES(
?, ?, ?, ?, ?
) RETURNING id
`)
if err != nil {
return err
}
result, err := stmt.Exec(s.Created.Unix(), s.Email, s.How, s.Why, s.Links)
if err != nil {
return err
}
defer stmt.Close()
liid, err := result.LastInsertId()
if err != nil {
return err
}
s.ID = liid
return nil
}
func (s *TownSignup) All(db *sql.DB) ([]*TownSignup, error) {
// TODO notes; circle back once can author them
rows, err := db.Query(`
SELECT
id, created, email, how, why, links
FROM signups WHERE decision = ""`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*TownSignup{}
for rows.Next() {
su := &TownSignup{}
if err = rows.Scan(
&su.ID,
&su.Created,
&su.Email,
&su.How,
&su.Why,
&su.Links,
); err != nil {
return nil, err
}
// TODO fetch notes
out = append(out, su)
}
return out, nil
}
// below is all TODO and unused rn
type UserState string
const (
StateActive = "active"
StateTempBan = "temp_banned"
StateBan = "banned"
)
type TownAccount struct {
ID int64
Emails []string
Username string
Signup int
Notes []AdminNote
State UserState
Admin bool
}

View File

@ -2,92 +2,17 @@ package signup
import ( import (
"database/sql" "database/sql"
"time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
type AdminNote struct { const dsn = "/town/var/signups/signups.db?mode=rw"
ID int64
Admin string
Note string
When time.Time
}
type SignupDecision string func ConnectDB() (*sql.DB, error) {
type UserState string db, err := sql.Open("sqlite3", dsn)
const (
SignupAccepted = "accepted"
SignupRejected = "rejected"
StateActive = "active"
StateTempBan = "temp_banned"
StateBan = "banned"
)
type TownSignup struct {
ID int64
Created time.Time
Email string
How string
Why string
Links string
Notes []AdminNote
Decision SignupDecision
}
type TownAccount struct {
ID int
Emails []string
Username string
Signup int
Notes []AdminNote
State UserState
Admin bool
}
type DB struct {
db *sql.DB
}
func NewDB() (*DB, error) {
db, err := sql.Open("sqlite3", "/town/var/signups/signups.db?mode=rw")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &DB{ return db, nil
db: db,
}, nil
}
func (d *DB) InsertSignup(su *TownSignup) error {
stmt, err := d.db.Prepare(`
INSERT INTO signups (created, email, how, why, links) VALUES(
?, ?, ?, ?, ?
) RETURNING id
`)
if err != nil {
return err
}
result, err := stmt.Exec(su.Created.Unix(), su.Email, su.How, su.Why, su.Links)
if err != nil {
return err
}
defer stmt.Close()
liid, err := result.LastInsertId()
if err != nil {
return err
}
su.ID = liid
return nil
}
func (d *DB) Close() error {
return d.db.Close()
} }

View File

@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS signups (
id INTEGER PRIMARY KEY,
-- from user
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
email TEXT,
how TEXT,
why TEXT,
links TEXT,
-- admin provided
decision_time TEXT,
decision TEXT
decided_by TEXT,
clean_email TEXT
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
signupid INTEGER,
author TEXT,
content TEXT,
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
FOREIGN KEY (signupid) REFERENCES signups(signupid)
);

View File

@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS signups (
id INTEGER PRIMARY KEY,
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
email TEXT,
how TEXT,
why TEXT,
links TEXT,
decision TEXT
);