diff --git a/.env b/.env new file mode 100644 index 0000000..078237c --- /dev/null +++ b/.env @@ -0,0 +1 @@ +db_path="./pta_vote.db" diff --git a/.gitignore b/.gitignore index 5b90e79..1553a08 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,3 @@ go.work go.work.sum -# env file -.env - diff --git a/server/config/config.go b/server/config/config.go index 0c49a67..57bd22b 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -1,15 +1,15 @@ package config import ( - "encoding/json" "fmt" + "log" "os" "strings" ) type Config struct { - DBPath string `json:"db_path"` - RedisHost string `json:"redis_host"` + DBPath string `json:"db_path"` + RedisHost string `json:"redis_host"` RedisPassword string `json:"redis_password"` } @@ -17,6 +17,8 @@ var conf *Config var conf_path string = ".env" func GetConfig() *Config { + _ = GenerateEnvFileIfNotExists("./sjles-pta-vote.db") + if conf != nil { return conf } @@ -25,9 +27,8 @@ func GetConfig() *Config { // TODO: Make this into a ini or toml file configContent, err := os.ReadFile(conf_path) - if err != nil { - fmt.Println("Error reading .env file: ", err) + log.Printf("Error reading .env file: %v", err) os.Exit(1) } @@ -42,11 +43,6 @@ func GetConfig() *Config { } } - if err := json.Unmarshal([]byte(configContent), conf); err != nil { - fmt.Println("Error unmarshalling config file: ", err) - os.Exit(1) - } - // TODO: Better mapping of key to json values // TODO: Better error checking if values are missing // TODO: Default values @@ -58,7 +54,7 @@ func GetConfig() *Config { } else if strings.Contains(key, "redis_password") { conf.RedisPassword = value } else { - fmt.Println("Error, Unknown key value pair: ", key, " = ", value) + log.Printf("Error, Unknown key value pair: %s = %s", key, value) } } @@ -67,4 +63,13 @@ func GetConfig() *Config { func SetConfig(init_conf *Config) { conf = init_conf +} + +func GenerateEnvFileIfNotExists(dbPath string) error { + _, err := os.Stat(".env") + if err == nil { + return nil + } + envContent := fmt.Sprintf("db_path=\"%s\"\n", dbPath) + return os.WriteFile(".env", []byte(envContent), 0644) } \ No newline at end of file diff --git a/server/db/db.go b/server/db/db.go index c6adba7..ad9109e 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -2,6 +2,10 @@ package db import ( "database/sql" + "errors" + "log" + "os" + "strings" "go-sjles-pta-vote/server/config" @@ -32,28 +36,39 @@ CREATE TABLE IF NOT EXISTS members ( email TEXT NOT NULL, member_name TEXT, school_year UNSIGNED INT NOT NULL, - PRIMARY KEY (email) + PRIMARY KEY (email, school_year) ); ` var db *sql.DB func Connect() (*sql.DB, error) { + log.Printf("Connecting to database") + db_config := config.GetConfig() + log.Printf("Database path: %s", db_config.DBPath) db, err := sql.Open("sqlite", db_config.DBPath) - if err != nil { + log.Printf("Error opening database: %v", err) return nil, err } _, err = db.Exec(build_db_query) - - return db, err + if err != nil { + log.Printf("Error updating schema: %v", err) + _ = db.Close() + return nil, err + } + + return db, nil } func Close() { if db != nil { - _ = db.Close() + err := db.Close() + if err != nil { + log.Printf("Error closing database: %v", err) + } } } diff --git a/server/icons/android-chrome-192x192.png b/server/icons/android-chrome-192x192.png new file mode 100644 index 0000000..4e9e6f9 Binary files /dev/null and b/server/icons/android-chrome-192x192.png differ diff --git a/server/icons/android-chrome-512x512.png b/server/icons/android-chrome-512x512.png new file mode 100644 index 0000000..d8e9b53 Binary files /dev/null and b/server/icons/android-chrome-512x512.png differ diff --git a/server/icons/apple-touch-icon.png b/server/icons/apple-touch-icon.png new file mode 100644 index 0000000..e5d5000 Binary files /dev/null and b/server/icons/apple-touch-icon.png differ diff --git a/server/icons/favicon-16x16.png b/server/icons/favicon-16x16.png new file mode 100644 index 0000000..e1cabf3 Binary files /dev/null and b/server/icons/favicon-16x16.png differ diff --git a/server/icons/favicon-32x32.png b/server/icons/favicon-32x32.png new file mode 100644 index 0000000..d82daa0 Binary files /dev/null and b/server/icons/favicon-32x32.png differ diff --git a/server/icons/favicon.ico b/server/icons/favicon.ico new file mode 100644 index 0000000..ff8c258 Binary files /dev/null and b/server/icons/favicon.ico differ diff --git a/server/icons/site.webmanifest b/server/icons/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/server/icons/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/server/main.go b/server/main.go index ada9b50..0fdc590 100644 --- a/server/main.go +++ b/server/main.go @@ -5,13 +5,16 @@ import ( "io/ioutil" "log" "net/http" + "os" + "strconv" "github.com/gorilla/mux" + "go-sjles-pta-vote/server/models" "go-sjles-pta-vote/server/services" ) func voteHandler(w http.ResponseWriter, r *http.Request) { - var vote services.Vote + var vote models.Vote if err := json.NewDecoder(r.Body).Decode(&vote); err != nil { http.Error(w, "Invalid request payload", http.StatusBadRequest) return @@ -28,15 +31,20 @@ func voteHandler(w http.ResponseWriter, r *http.Request) { func voteIDHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - id := vars["id"] + idStr := vars["id"] + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "Invalid poll ID", http.StatusBadRequest) + return + } - vote := services.Vote{ + vote := models.Vote{ PollId: id, Email: "example@example.com", // Replace with actual email retrieval logic Vote: true, // Replace with actual vote retrieval logic } - err := services.SetVote(&vote) + err = services.SetVote(&vote) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -46,16 +54,24 @@ func voteIDHandler(w http.ResponseWriter, r *http.Request) { } func statsHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] + if r.Method == "GET" { + filePath := "./server/templates/stats.html" + log.Printf("Serving stats.html from %s", filePath) + http.ServeFile(w, r, filePath) + } else if r.Method == "POST" { + vars := mux.Vars(r) + id := vars["id"] - poll, err := services.GetPollByQuestion(id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + poll, err := services.GetPollByQuestion(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(poll) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) } - - json.NewEncoder(w).Encode(poll) } func statsIDHandler(w http.ResponseWriter, r *http.Request) { @@ -77,16 +93,31 @@ func adminHandler(w http.ResponseWriter, r *http.Request) { } func adminIDHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] + //vars := mux.Vars(r) + //id := vars["id"] // Add admin functionality here w.WriteHeader(http.StatusOK) } -func membersHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - file, handler, err := r.FormFile("members.csv") +func adminMembersHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + filePath := "./server/templates/members.html" + log.Printf("Serving members.html from %s", filePath) + http.ServeFile(w, r, filePath) + } else if r.Method == "POST" { + var year int + var err error + r.ParseForm() + if y := r.FormValue("year"); y != "" { + year, err = strconv.Atoi(y) + if err != nil { + http.Error(w, "Invalid year", http.StatusBadRequest) + return + } + } + + file, _, err := r.FormFile("members.csv") if err != nil { http.Error(w, "Failed to upload file", http.StatusBadRequest) return @@ -99,28 +130,37 @@ func membersHandler(w http.ResponseWriter, r *http.Request) { return } - members, err := services.ParseMembersFromBytes(2023, fileBytes) // Assuming year 2023 for demonstration purposes + err = services.ParseMembersFromBytes(year, fileBytes) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } - - json.NewEncoder(w).Encode(members) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"success": true}) } else { w.WriteHeader(http.StatusMethodNotAllowed) } } func main() { - r := mux.NewRouter() + log.SetOutput(os.Stdout) + log.SetFlags(log.LstdFlags | log.Lshortfile) - r.HandleFunc("/vote", voteHandler).Methods("POST") - r.HandleFunc("/vote/{id}", voteIDHandler).Methods("POST") - r.HandleFunc("/stats", statsHandler).Methods("POST") - r.HandleFunc("/stats/{id}", statsIDHandler).Methods("POST") - r.HandleFunc("/admin", adminHandler).Methods("GET") - r.HandleFunc("/admin/{id}", adminIDHandler).Methods("GET") - r.HandleFunc("/admin/members", membersHandler).Methods("POST") + log.Printf("Starting server on :8080") - log.Fatal(http.ListenAndServe(":8080", r)) + http.HandleFunc("/vote", voteHandler) + http.HandleFunc("/vote/{id}", voteIDHandler) + http.HandleFunc("/stats", statsHandler) + http.HandleFunc("/stats/{id}", statsIDHandler) + http.HandleFunc("/admin", adminHandler) + http.HandleFunc("/admin/{id}", adminIDHandler) + http.HandleFunc("/admin/members", adminMembersHandler) + http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + filePath := "./server/icons/favicon.ico" + http.ServeFile(w, r, filePath) + }) + + log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/server/services/putmembers.go b/server/services/putmembers.go index 63bcbd9..2183e46 100644 --- a/server/services/putmembers.go +++ b/server/services/putmembers.go @@ -3,9 +3,12 @@ package services import ( "encoding/csv" "fmt" + "log" "strings" "github.com/pkg/errors" + + "go-sjles-pta-vote/server/db" ) type Member struct { @@ -13,14 +16,18 @@ type Member struct { Email string } -func ParseMembersFromBytes(year int, fileBytes []byte) ([]Member, error) { +BATCH_SIZE := 100 + +func ParseMembersFromBytes(year int, fileBytes []byte) error { reader := csv.NewReader(strings.NewReader(string(fileBytes))) + reader.FieldsPerRecord = -1 // Allow variable number of fields per record records, err := reader.ReadAll() if err != nil { - return nil, errors.Wrap(err, "failed to read CSV from bytes") + return errors.Wrap(err, "failed to read CSV from bytes") } var members []Member + for i, record := range records { if i == 0 { continue // Skip the first line (column headers) @@ -28,18 +35,86 @@ func ParseMembersFromBytes(year int, fileBytes []byte) ([]Member, error) { if len(record) < 4 { continue } - firstName := record[1] - lastName := record[2] - email := record[3] + + firstName := strings.TrimSpace(record[1]) + lastName := strings.TrimSpace(record[2]) + email := strings.TrimSpace(record[3]) + members = append(members, Member{ Name: fmt.Sprintf("%s %s", firstName, lastName), Email: email, }) + + if len(record) < 30 { + continue + } + + email2 := strings.TrimSpace(record[27]) + if email2 != "" { + firstName2 := strings.TrimSpace(record[29]) + lastName2 := strings.TrimSpace(record[28]) + + members = append(members, Member{ + Name: fmt.Sprintf("%s %s", firstName2, lastName2), + Email: email2, + }) + } } - if len(members) == 0 { - members = []Member{} + return saveMember(year, members) +} + +func saveMember(year int, members []Member) error { + insertMembersQuery := ` + INSERT OR REPLACE INTO members (email, member_name, school_year) + VALUES ($1, $2, $3) + ` + log.Printf("Starting to save %d members for year %d", len(members), year) + + db_conn, err := db.Connect() + if err != nil { + return errors.Wrap(err, "failed to connect to database") + } + defer db_conn.Close() + + tx, err := db_conn.Begin() + if err != nil { + return errors.Wrap(err, "failed to begin transaction") } - return members, nil + stmt, err := tx.Prepare(insertMembersQuery) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to prepare statement") + } + defer stmt.Close() + + for index, member := range members { + _, err = stmt.Exec(member.Email, member.Name, year) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to execute insert") + } + + if (index+1) % BATCH_SIZE == 0 { + err = tx.Commit() + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to commit transaction") + } + + tx, err = db_conn.Begin() + if err != nil { + return errors.Wrap(err, "failed to begin new transaction") + } + + stmt, err = tx.Prepare(insertMembersQuery) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "failed to prepare new statement") + } + } + } + + return tx.Commit() } \ No newline at end of file diff --git a/server/templates/members.html b/server/templates/members.html new file mode 100644 index 0000000..18cff03 --- /dev/null +++ b/server/templates/members.html @@ -0,0 +1,18 @@ + + + + + Upload Members + + + +

Upload Members CSV

+
+ +

+ +

+ +
+ + diff --git a/server/templates/stats.html b/server/templates/stats.html new file mode 100644 index 0000000..30e9853 --- /dev/null +++ b/server/templates/stats.html @@ -0,0 +1,10 @@ + + + + + Stats + + +

Hello World!

+ +