diff --git a/cmd/review/main.go b/cmd/review/main.go index 85372ed..1022c9e 100644 --- a/cmd/review/main.go +++ b/cmd/review/main.go @@ -1,27 +1,23 @@ package main import ( - "encoding/json" + "database/sql" "errors" "fmt" "math/rand" "os" "os/user" - "path" "strings" "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" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) -const ( - signupDir = "/town/signups" - acceptedDir = "/town/signups/accepted" - rejectedDir = "/town/signups/rejected" -) - func getTitle() string { titles := []string{ "yo bum rush the show", @@ -34,37 +30,36 @@ func getTitle() string { return titles[rand.Intn(len(titles))] } -type townSignup struct { - When time.Time - DecisionTime time.Time - Decision string - Filename string - Answers map[string]string +// TODO affordance for cleaning up email response + +type reviewer struct { + db *sql.DB + adminName string } -func (s townSignup) Accept() error { - return s.review("accept") +func newReviewer(db *sql.DB, adminName string) *reviewer { + return &reviewer{db: db, adminName: adminName} } -func (s townSignup) Reject() error { - return s.review("reject") -} - -func (s townSignup) review(decision string) error { +func (r *reviewer) Review(s *models.TownSignup, decision models.SignupDecision) error { s.DecisionTime = time.Now() - s.Decision = "decision" - oldpath := path.Join(signupDir, s.Filename) - newpath := path.Join(acceptedDir, s.Filename) - if decision == "reject" { - newpath = path.Join(rejectedDir, s.Filename) - } - return os.Rename(oldpath, newpath) + s.Decision = decision + s.DecidedBy = r.adminName + s.Insert(r.db) + return nil } -func (s townSignup) Render() string { - out := fmt.Sprintf("[-:-:b]submitted:[-:-:-] %s\n", s.When.Format("2006-01-02 15:04")) +func renderSignup(s models.TownSignup) string { + 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 += strings.TrimSpace(v) out += "\n\n" @@ -73,39 +68,20 @@ func (s townSignup) Render() string { 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 { + /* + 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() if err != nil { return fmt.Errorf("that's my purse. I don't know you! %w", err) @@ -118,11 +94,16 @@ func _main() error { if !isAdmin { return errors.New("this command can only be run by a town admin") } + + r := newReviewer(signupDB, u.Username) + rand.Seed(time.Now().Unix()) - signups, err := getSignups() + su := models.TownSignup{} + + signups, err := su.All(signupDB) if err != nil { - return fmt.Errorf("could not get signups: %w", err) + return fmt.Errorf("could not fetch signups: %w", err) } signupIx := 0 @@ -138,7 +119,7 @@ func _main() error { if len(signups) == 0 { appView.SetText("no signups found.") } else { - appView.SetText(signups[signupIx].Render()) + appView.SetText(renderSignup(*signups[signupIx])) } legend := tview.NewTextView() @@ -176,7 +157,7 @@ func _main() error { notate := tview.NewForm() notate.AddTextArea("note", "", 80, 10, 1000, func(string) {}) notate.AddButton("submit", func() { - // TODO add note; this will require re-serializing and saving the file + // add note and update pages.SwitchToPage("main") }) notate.AddButton("cancel", func() { @@ -200,11 +181,11 @@ func _main() error { if signupIx == len(signups) { signupIx = 0 } - appView.SetText(signups[signupIx].Render()) + appView.SetText(renderSignup(*signups[signupIx])) } - removeSignup := func(signup townSignup) { - newSignups := []townSignup{} + removeSignup := func(signup *models.TownSignup) { + newSignups := []*models.TownSignup{} for ix, s := range signups { if ix != signupIx { newSignups = append(newSignups, s) @@ -216,7 +197,7 @@ func _main() error { signupIx = 0 } } - appView.SetText(signups[signupIx].Render()) + appView.SetText(renderSignup(*signups[signupIx])) } app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { @@ -226,29 +207,32 @@ func _main() error { case 'r': if len(signups) > 0 { signupIx = rand.Intn(len(signups)) - appView.SetText(signups[signupIx].Render()) + appView.SetText(renderSignup(*signups[signupIx])) } case 'A': if len(signups) == 0 { return nil } + // TODO modal for collecting clean email signup := signups[signupIx] - err := signup.Accept() + err := r.Review(signup, models.SignupAccepted) 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") return nil } removeSignup(signup) updateCount() + // TODO generate invite token + // TODO send invite email case 'R': if len(signups) == 0 { return nil } signup := signups[signupIx] - err = signup.Reject() + err = r.Review(signup, models.SignupRejected) 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") return nil } diff --git a/cmd/signup/main.go b/cmd/signup/main.go index e01a9ae..6c355dc 100644 --- a/cmd/signup/main.go +++ b/cmd/signup/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "database/sql" "fmt" "io" "log" @@ -10,9 +11,11 @@ import ( "strings" "time" + "git.tilde.town/tildetown/town/models" "git.tilde.town/tildetown/town/signup" "github.com/MakeNowJust/heredoc/v2" "github.com/gdamore/tcell/v2" + _ "github.com/mattn/go-sqlite3" "github.com/rivo/tview" ) @@ -115,7 +118,7 @@ func main() { logger := log.New(logF, "", log.Ldate|log.Ltime) - db, err := signup.NewDB() + db, err := signup.ConnectDB() if err != nil { fmt.Fprintln(os.Stderr, err) 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") pages := tview.NewPages() mainFlex := tview.NewFlex() @@ -163,11 +166,11 @@ func _main(l *log.Logger, db *signup.DB) error { player := newCharacter("you", "TODO") - su := &signup.TownSignup{ID: -1} + su := &models.TownSignup{ID: -1} save := func() { su.Created = time.Now() - err := db.InsertSignup(su) + err := su.Insert(db) if err != nil { l.Printf("failed to write to db: %s", err.Error()) diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..4cc6a51 --- /dev/null +++ b/models/models.go @@ -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 +} diff --git a/signup/signup.go b/signup/signup.go index e136158..72d3562 100644 --- a/signup/signup.go +++ b/signup/signup.go @@ -2,92 +2,17 @@ package signup import ( "database/sql" - "time" _ "github.com/mattn/go-sqlite3" ) -type AdminNote struct { - ID int64 - Admin string - Note string - When time.Time -} +const dsn = "/town/var/signups/signups.db?mode=rw" -type SignupDecision string -type UserState string - -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") +func ConnectDB() (*sql.DB, error) { + db, err := sql.Open("sqlite3", dsn) if err != nil { return nil, err } - return &DB{ - 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() + return db, nil } diff --git a/sql/create_signups_db.sql b/sql/create_signups_db.sql new file mode 100644 index 0000000..e4551a6 --- /dev/null +++ b/sql/create_signups_db.sql @@ -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) +); diff --git a/sql/create_signups_table.sql b/sql/create_signups_table.sql deleted file mode 100644 index 12d304d..0000000 --- a/sql/create_signups_table.sql +++ /dev/null @@ -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 -);