diff --git a/frontend/index.html b/frontend/index.html index 2346b60..23571fc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -176,6 +176,12 @@

No public KML files available

+ +
+ + Page 1 + +
diff --git a/go.mod b/go.mod index bbefa6a..ad7ebda 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module code.stevenpolley.net/steven/pedestrian-simulator go 1.25.5 + +require github.com/go-sql-driver/mysql v1.9.3 + +require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bcdcfa --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= diff --git a/server/db.go b/server/db.go new file mode 100644 index 0000000..0de2d08 --- /dev/null +++ b/server/db.go @@ -0,0 +1,107 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + + _ "github.com/go-sql-driver/mysql" +) + +var db *sql.DB + +func InitDB() { + host := os.Getenv("DB_HOST") + port := os.Getenv("DB_PORT") + name := os.Getenv("DB_DATABASENAME") + user := os.Getenv("DB_USERNAME") + pass := os.Getenv("DB_PASSWORD") + + if host == "" { + log.Println("DB_HOST not set, skipping database initialization") + return + } + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", user, pass, host, port, name) + var err error + db, err = sql.Open("mysql", dsn) + if err != nil { + log.Fatalf("Error opening database: %v", err) + } + + if err := db.Ping(); err != nil { + log.Fatalf("Error connecting to database: %v", err) + } + + log.Println("Connected to MariaDB successfully") + createTables() +} + +func createTables() { + queries := []string{ + `CREATE TABLE IF NOT EXISTS users ( + fitbit_user_id VARCHAR(255) PRIMARY KEY, + display_name VARCHAR(255), + avatar_url TEXT, + created_at DATETIME + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(255) PRIMARY KEY, + fitbit_user_id VARCHAR(255), + created_at DATETIME, + expires_at DATETIME, + FOREIGN KEY (fitbit_user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS fitbit_tokens ( + user_id VARCHAR(255) PRIMARY KEY, + access_token TEXT, + refresh_token TEXT, + expires_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS kml_metadata ( + id INT AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255), + user_id VARCHAR(255), + distance DOUBLE, + is_public BOOLEAN DEFAULT FALSE, + uploaded_at DATETIME, + UNIQUE KEY (user_id, filename), + FOREIGN KEY (user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS kml_votes ( + kml_id INT, + user_id VARCHAR(255), + vote_value INT, + PRIMARY KEY (kml_id, user_id), + FOREIGN KEY (kml_id) REFERENCES kml_metadata(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS trips ( + user_id VARCHAR(255) PRIMARY KEY, + start_date VARCHAR(10), + start_time DATETIME, + start_day_initial_steps INT, + previous_total_steps INT, + target_total_steps INT, + last_sync_time DATETIME, + next_sync_time DATETIME, + FOREIGN KEY (user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS daily_steps ( + user_id VARCHAR(255), + date VARCHAR(10), + steps INT, + PRIMARY KEY (user_id, date), + FOREIGN KEY (user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE + )`, + } + + for _, query := range queries { + if _, err := db.Exec(query); err != nil { + log.Fatalf("Error creating table: %v\nQuery: %s", err, query) + } + } + log.Println("Database tables initialized") +} diff --git a/server/fitbit.go b/server/fitbit.go index e735e85..235ae0f 100644 --- a/server/fitbit.go +++ b/server/fitbit.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "encoding/json" "fmt" "io" @@ -38,33 +39,26 @@ func InitFitbit() { } func loadTokens(userID string) (*FitbitConfig, error) { - configPath := fmt.Sprintf("data/users/%s/fitbit_tokens.json", userID) - data, err := os.ReadFile(configPath) - if err != nil { - return nil, err - } - var config FitbitConfig - if err := json.Unmarshal(data, &config); err != nil { + err := db.QueryRow("SELECT access_token, refresh_token, expires_at FROM fitbit_tokens WHERE user_id = ?", userID). + Scan(&config.AccessToken, &config.RefreshToken, &config.ExpiresAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("tokens not found for user %s", userID) + } return nil, err } - return &config, nil } func saveTokens(userID string, config *FitbitConfig) error { - userDir := fmt.Sprintf("data/users/%s", userID) - if err := os.MkdirAll(userDir, 0755); err != nil { - return fmt.Errorf("error creating user directory: %w", err) - } - - configPath := fmt.Sprintf("%s/fitbit_tokens.json", userDir) - data, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - return os.WriteFile(configPath, data, 0600) + _, err := db.Exec(` + INSERT INTO fitbit_tokens (user_id, access_token, refresh_token, expires_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE access_token = ?, refresh_token = ?, expires_at = ? + `, userID, config.AccessToken, config.RefreshToken, config.ExpiresAt, + config.AccessToken, config.RefreshToken, config.ExpiresAt) + return err } // GetDailySteps fetches step count for a specific date (YYYY-MM-DD) for a specific user @@ -241,14 +235,9 @@ func HandleFitbitCallback(w http.ResponseWriter, r *http.Request) { } } - // Create or update user in registry - fmt.Println("[OAuth Callback] Creating/updating user in registry...") - if userRegistry == nil { - fmt.Println("[OAuth Callback] ERROR: userRegistry is nil!") - http.Error(w, "Server configuration error: user registry not initialized", http.StatusInternalServerError) - return - } - _, err = userRegistry.CreateOrUpdateUser(fitbitUserID, displayName, avatarURL) + // Create or update user in database + fmt.Println("[OAuth Callback] Creating/updating user in database...") + _, err = CreateOrUpdateUser(fitbitUserID, displayName, avatarURL) if err != nil { fmt.Printf("[OAuth Callback] ERROR: Failed to create user: %v\n", err) http.Error(w, fmt.Sprintf("Failed to create user: %v", err), http.StatusInternalServerError) diff --git a/server/kml.go b/server/kml.go index 1aa21bb..7661c3f 100644 --- a/server/kml.go +++ b/server/kml.go @@ -10,9 +10,8 @@ import ( "net/http" "os" "path/filepath" - "sort" + "strconv" "strings" - "sync" "time" ) @@ -45,95 +44,26 @@ type KMLMetadata struct { Votes int `json:"votes"` // Net votes (calculated from voting system) } -// Global vote tracking: kmlID -> userID -> vote (+1, -1, or 0) -type VoteRegistry struct { - Votes map[string]map[string]int `json:"votes"` // kmlID -> (userID -> vote) - mu sync.RWMutex -} - -var voteRegistry *VoteRegistry - -// InitVoteRegistry loads the vote registry from disk -func InitVoteRegistry() { - voteRegistry = &VoteRegistry{ - Votes: make(map[string]map[string]int), - } - voteRegistry.Load() -} - -func (vr *VoteRegistry) Load() error { - vr.mu.Lock() - defer vr.mu.Unlock() - - data, err := os.ReadFile("data/kml_votes.json") - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - return json.Unmarshal(data, vr) -} - -func (vr *VoteRegistry) Save() error { - vr.mu.RLock() - defer vr.mu.RUnlock() - return vr.saveUnlocked() -} - -func (vr *VoteRegistry) saveUnlocked() error { - if err := os.MkdirAll("data", 0755); err != nil { - return err - } - - data, err := json.MarshalIndent(vr, "", " ") - if err != nil { - return err - } - - return os.WriteFile("data/kml_votes.json", data, 0644) -} - -// GetVote return the vote of a user for a KML file (-1, 0, +1) -func (vr *VoteRegistry) GetVote(kmlID, userID string) int { - vr.mu.RLock() - defer vr.mu.RUnlock() - - if userVotes, exists := vr.Votes[kmlID]; exists { - return userVotes[userID] - } - return 0 -} - -// SetVote sets a user's vote for a KML file -func (vr *VoteRegistry) SetVote(kmlID, userID string, vote int) error { - vr.mu.Lock() - defer vr.mu.Unlock() - - if vr.Votes[kmlID] == nil { - vr.Votes[kmlID] = make(map[string]int) - } - +// SetVote sets a user's vote for a KML file in the database +func SetVote(kmlID int, userID string, vote int) error { if vote == 0 { - delete(vr.Votes[kmlID], userID) - } else { - vr.Votes[kmlID][userID] = vote + _, err := db.Exec("DELETE FROM kml_votes WHERE kml_id = ? AND user_id = ?", kmlID, userID) + return err } - - return vr.saveUnlocked() + _, err := db.Exec(` + INSERT INTO kml_votes (kml_id, user_id, vote_value) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE vote_value = ? + `, kmlID, userID, vote, vote) + return err } -// CalculateNetVotes calculates net votes for a KML file -func (vr *VoteRegistry) CalculateNetVotes(kmlID string) int { - vr.mu.RLock() - defer vr.mu.RUnlock() - - total := 0 - if userVotes, exists := vr.Votes[kmlID]; exists { - for _, vote := range userVotes { - total += vote - } +// CalculateNetVotes calculates net votes for a KML file from the database +func CalculateNetVotes(kmlID int) int { + var total int + err := db.QueryRow("SELECT COALESCE(SUM(vote_value), 0) FROM kml_votes WHERE kml_id = ?", kmlID).Scan(&total) + if err != nil { + return 0 } return total } @@ -265,28 +195,14 @@ func HandleKMLUpload(w http.ResponseWriter, r *http.Request) { return } - // Get user info - user, _ := userRegistry.GetUser(userID) - displayName := userID - if user != nil { - displayName = user.DisplayName - } - - // Create metadata - metadata := KMLMetadata{ - Filename: handler.Filename, - UserID: userID, - DisplayName: displayName, - Distance: distance, - IsPublic: false, // Private by default - UploadedAt: time.Now(), - Votes: 0, - } - - metaPath := filepath.Join(kmlDir, handler.Filename+".meta.json") - metaData, _ := json.MarshalIndent(metadata, "", " ") - if err := os.WriteFile(metaPath, metaData, 0644); err != nil { - http.Error(w, "Failed to save metadata", http.StatusInternalServerError) + // Save metadata to database + _, err = db.Exec(` + INSERT INTO kml_metadata (filename, user_id, distance, is_public, uploaded_at) + VALUES (?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE distance = ?, uploaded_at = NOW() + `, handler.Filename, userID, distance, false, distance) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to save metadata: %v", err), http.StatusInternalServerError) return } @@ -298,7 +214,7 @@ func HandleKMLUpload(w http.ResponseWriter, r *http.Request) { }) } -// HandleKMLList lists KML files (user's own + public files) +// HandleKMLList lists KML files with pagination and sorting func HandleKMLList(w http.ResponseWriter, r *http.Request) { userID, ok := getUserID(r.Context()) if !ok { @@ -306,89 +222,88 @@ func HandleKMLList(w http.ResponseWriter, r *http.Request) { return } - var allFiles []KMLMetadata + // Parse pagination parameters + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 10 + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page <= 0 { + page = 1 + } + offset := (page - 1) * limit - // Walk through all user directories - usersDir := "data/users" - entries, err := os.ReadDir(usersDir) + sortBy := r.URL.Query().Get("sort_by") + order := r.URL.Query().Get("order") + if order != "ASC" { + order = "DESC" + } + + // 1. Get my files + myFiles, err := queryKMLMetadata(` + SELECT m.filename, m.user_id, u.display_name, m.distance, m.is_public, m.uploaded_at, + (SELECT COALESCE(SUM(v.vote_value), 0) FROM kml_votes v WHERE v.kml_id = m.id) as votes + FROM kml_metadata m + JOIN users u ON m.user_id = u.fitbit_user_id + WHERE m.user_id = ? + ORDER BY m.uploaded_at DESC + `, userID) if err != nil { - // No users yet - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "my_files": []KMLMetadata{}, - "public_files": []KMLMetadata{}, - }) + http.Error(w, err.Error(), http.StatusInternalServerError) return } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - ownerID := entry.Name() - kmlDir := filepath.Join(usersDir, ownerID, "kml") - - kmlFiles, err := os.ReadDir(kmlDir) - if err != nil { - continue - } - - for _, kmlFile := range kmlFiles { - if !strings.HasSuffix(kmlFile.Name(), ".meta.json") { - continue - } - - metaPath := filepath.Join(kmlDir, kmlFile.Name()) - data, err := os.ReadFile(metaPath) - if err != nil { - continue - } - - var meta KMLMetadata - if err := json.Unmarshal(data, &meta); err != nil { - continue - } - - // Calculate current votes - kmlID := fmt.Sprintf("%s/%s", ownerID, meta.Filename) - meta.Votes = voteRegistry.CalculateNetVotes(kmlID) - - // Include if: 1) owned by current user, OR 2) public - if ownerID == userID || meta.IsPublic { - allFiles = append(allFiles, meta) - } - } + // 2. Get public files (with pagination and sorting) + sortClause := "votes" + switch sortBy { + case "date": + sortClause = "m.uploaded_at" + case "distance": + sortClause = "m.distance" } - // Separate into own files and public files - var myFiles, publicFiles []KMLMetadata - for _, file := range allFiles { - if file.UserID == userID { - myFiles = append(myFiles, file) - } - if file.IsPublic { - publicFiles = append(publicFiles, file) - } + publicFiles, err := queryKMLMetadata(fmt.Sprintf(` + SELECT m.filename, m.user_id, u.display_name, m.distance, m.is_public, m.uploaded_at, + (SELECT COALESCE(SUM(v.vote_value), 0) FROM kml_votes v WHERE v.kml_id = m.id) as votes + FROM kml_metadata m + JOIN users u ON m.user_id = u.fitbit_user_id + WHERE m.is_public = 1 + ORDER BY %s %s + LIMIT ? OFFSET ? + `, sortClause, order), limit, offset) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - // Sort public files by votes (highest first) - sort.Slice(publicFiles, func(i, j int) bool { - return publicFiles[i].Votes > publicFiles[j].Votes - }) - - // Sort my files by upload date (newest first) - sort.Slice(myFiles, func(i, j int) bool { - return myFiles[i].UploadedAt.After(myFiles[j].UploadedAt) - }) - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "my_files": myFiles, "public_files": publicFiles, + "page": page, + "limit": limit, }) } +func queryKMLMetadata(query string, args ...interface{}) ([]KMLMetadata, error) { + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var files []KMLMetadata + for rows.Next() { + var m KMLMetadata + err := rows.Scan(&m.Filename, &m.UserID, &m.DisplayName, &m.Distance, &m.IsPublic, &m.UploadedAt, &m.Votes) + if err != nil { + return nil, err + } + files = append(files, m) + } + return files, nil +} + // HandleKMLPrivacyToggle toggles the privacy setting of a KML file func HandleKMLPrivacyToggle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -410,39 +325,22 @@ func HandleKMLPrivacyToggle(w http.ResponseWriter, r *http.Request) { return } - metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", userID, req.Filename) - data, err := os.ReadFile(metaPath) - if err != nil { - http.Error(w, "File not found", http.StatusNotFound) - return - } - - var meta KMLMetadata - if err := json.Unmarshal(data, &meta); err != nil { - http.Error(w, "Failed to parse metadata", http.StatusInternalServerError) - return - } - - // Verify ownership - if meta.UserID != userID { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - // Toggle privacy - meta.IsPublic = !meta.IsPublic - - // Save updated metadata - newData, _ := json.MarshalIndent(meta, "", " ") - if err := os.WriteFile(metaPath, newData, 0644); err != nil { + // Toggle privacy in database + if _, err := db.Exec("UPDATE kml_metadata SET is_public = NOT is_public WHERE filename = ? AND user_id = ?", req.Filename, userID); err != nil { http.Error(w, "Failed to update metadata", http.StatusInternalServerError) return } + var isPublic bool + if err := db.QueryRow("SELECT is_public FROM kml_metadata WHERE filename = ? AND user_id = ?", req.Filename, userID).Scan(&isPublic); err != nil { + http.Error(w, "Failed to fetch updated metadata", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, - "is_public": meta.IsPublic, + "is_public": isPublic, }) } @@ -475,16 +373,22 @@ func HandleKMLVote(w http.ResponseWriter, r *http.Request) { return } - kmlID := fmt.Sprintf("%s/%s", req.OwnerID, req.Filename) + // Get KML ID + var kmlID int + err := db.QueryRow("SELECT id FROM kml_metadata WHERE user_id = ? AND filename = ?", req.OwnerID, req.Filename).Scan(&kmlID) + if err != nil { + http.Error(w, "KML not found", http.StatusNotFound) + return + } // Set the vote - if err := voteRegistry.SetVote(kmlID, userID, req.Vote); err != nil { + if err := SetVote(kmlID, userID, req.Vote); err != nil { http.Error(w, "Failed to save vote", http.StatusInternalServerError) return } // Calculate new net votes - netVotes := voteRegistry.CalculateNetVotes(kmlID) + netVotes := CalculateNetVotes(kmlID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -514,30 +418,15 @@ func HandleKMLDelete(w http.ResponseWriter, r *http.Request) { return } - // Verify ownership by reading metadata - metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", userID, req.Filename) - data, err := os.ReadFile(metaPath) - if err != nil { - http.Error(w, "File not found", http.StatusNotFound) + // Verify ownership and delete metadata from database + if _, err := db.Exec("DELETE FROM kml_metadata WHERE filename = ? AND user_id = ?", req.Filename, userID); err != nil { + http.Error(w, "Failed to delete metadata or file not found", http.StatusInternalServerError) return } - var meta KMLMetadata - if err := json.Unmarshal(data, &meta); err != nil { - http.Error(w, "Failed to parse metadata", http.StatusInternalServerError) - return - } - - // Verify ownership - if meta.UserID != userID { - http.Error(w, "Forbidden - you can only delete your own files", http.StatusForbidden) - return - } - - // Delete KML file and metadata + // Delete KML file kmlPath := fmt.Sprintf("data/users/%s/kml/%s", userID, req.Filename) os.Remove(kmlPath) - os.Remove(metaPath) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -563,20 +452,14 @@ func HandleKMLDownload(w http.ResponseWriter, r *http.Request) { // Verify permission: ownerID == userID OR file is public if ownerID != userID { - metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", ownerID, filename) - data, err := os.ReadFile(metaPath) + var isPublic bool + err := db.QueryRow("SELECT is_public FROM kml_metadata WHERE user_id = ? AND filename = ?", ownerID, filename).Scan(&isPublic) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } - var meta KMLMetadata - if err := json.Unmarshal(data, &meta); err != nil { - http.Error(w, "Error reading metadata", http.StatusInternalServerError) - return - } - - if !meta.IsPublic { + if !isPublic { http.Error(w, "Forbidden", http.StatusForbidden) return } diff --git a/server/main.go b/server/main.go index bfde82c..bcb9c32 100644 --- a/server/main.go +++ b/server/main.go @@ -6,37 +6,14 @@ import ( "log" "net/http" "os" - "sync" "time" + + _ "github.com/go-sql-driver/mysql" ) -var ( - stepManagers = make(map[string]*StepManager) // userID -> StepManager - smMutex sync.RWMutex -) - -// getOrCreateStepManager retrieves or creates a StepManager for the given user -func getOrCreateStepManager(userID string) *StepManager { - smMutex.RLock() - sm, exists := stepManagers[userID] - smMutex.RUnlock() - - if exists { - return sm - } - - // Create new StepManager for this user - smMutex.Lock() - defer smMutex.Unlock() - - // Double-check it wasn't created while we were waiting for the lock - if sm, exists := stepManagers[userID]; exists { - return sm - } - - sm = NewStepManager(userID) - stepManagers[userID] = sm - return sm +// getStepManager creates a new StepManager for the given user, loading state from DB +func getStepManager(userID string) *StepManager { + return NewStepManager(userID) } func initTimezone() { @@ -58,9 +35,8 @@ func initTimezone() { func main() { // Initialize components initTimezone() + InitDB() InitFitbit() - InitUserRegistry() - InitVoteRegistry() // 1. Serve Static Files (Frontend) fs := http.FileServer(http.Dir("frontend")) @@ -69,19 +45,19 @@ func main() { // 2. API Endpoints (all require authentication) http.HandleFunc("/api/status", RequireAuth(func(w http.ResponseWriter, r *http.Request) { userID, _ := getUserID(r.Context()) - sm := getOrCreateStepManager(userID) + sm := getStepManager(userID) status := sm.GetStatus() // Add user info to status - user, exists := userRegistry.GetUser(userID) - if exists && user != nil { + user, err := GetUser(userID) + if err == nil && user != nil { status["user"] = map[string]string{ "displayName": user.DisplayName, "avatarUrl": user.AvatarURL, } } else { - fmt.Printf("[API Status] WARNING: User info not found for ID: %s (exists=%v)\n", userID, exists) + fmt.Printf("[API Status] WARNING: User info not found for ID: %s (err=%v)\n", userID, err) } w.Header().Set("Content-Type", "application/json") @@ -95,7 +71,7 @@ func main() { } userID, _ := getUserID(r.Context()) - sm := getOrCreateStepManager(userID) + sm := getStepManager(userID) sm.Sync() w.WriteHeader(http.StatusOK) })) @@ -107,7 +83,7 @@ func main() { } userID, _ := getUserID(r.Context()) - sm := getOrCreateStepManager(userID) + sm := getStepManager(userID) sm.StartNewTrip() w.WriteHeader(http.StatusOK) })) @@ -119,7 +95,7 @@ func main() { } userID, _ := getUserID(r.Context()) - sm := getOrCreateStepManager(userID) + sm := getStepManager(userID) go sm.Drain() // Async so we don't block w.WriteHeader(http.StatusOK) })) diff --git a/server/session.go b/server/session.go index 74ebcb4..f7f328d 100644 --- a/server/session.go +++ b/server/session.go @@ -2,12 +2,10 @@ package main import ( "crypto/rand" + "database/sql" "encoding/base64" - "encoding/json" "fmt" "net/http" - "os" - "path/filepath" "time" ) @@ -54,32 +52,24 @@ func CreateSession(fitbitUserID string) (*Session, error) { return session, nil } -// SaveSession persists a session to disk +// SaveSession persists a session to the database func SaveSession(session *Session) error { - sessionDir := "data/sessions" - if err := os.MkdirAll(sessionDir, 0755); err != nil { - return fmt.Errorf("failed to create sessions directory: %w", err) - } - - sessionPath := filepath.Join(sessionDir, session.Token+".json") - data, err := json.MarshalIndent(session, "", " ") - if err != nil { - return err - } - - return os.WriteFile(sessionPath, data, 0600) + _, err := db.Exec(` + INSERT INTO sessions (token, fitbit_user_id, created_at, expires_at) + VALUES (?, ?, ?, ?) + `, session.Token, session.FitbitUserID, session.CreatedAt, session.ExpiresAt) + return err } -// LoadSession loads a session from disk by token +// LoadSession loads a session from the database by token func LoadSession(token string) (*Session, error) { - sessionPath := filepath.Join("data/sessions", token+".json") - data, err := os.ReadFile(sessionPath) - if err != nil { - return nil, err - } - var session Session - if err := json.Unmarshal(data, &session); err != nil { + err := db.QueryRow("SELECT token, fitbit_user_id, created_at, expires_at FROM sessions WHERE token = ?", token). + Scan(&session.Token, &session.FitbitUserID, &session.CreatedAt, &session.ExpiresAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("session not found") + } return nil, err } @@ -92,10 +82,10 @@ func LoadSession(token string) (*Session, error) { return &session, nil } -// DeleteSession removes a session from disk +// DeleteSession removes a session from the database func DeleteSession(token string) error { - sessionPath := filepath.Join("data/sessions", token+".json") - return os.Remove(sessionPath) + _, err := db.Exec("DELETE FROM sessions WHERE token = ?", token) + return err } // GetSessionFromRequest extracts the session from the request cookie diff --git a/server/step_manager.go b/server/step_manager.go index 5a8dcea..b019367 100644 --- a/server/step_manager.go +++ b/server/step_manager.go @@ -1,9 +1,8 @@ package main import ( - "encoding/json" + "database/sql" "fmt" - "os" "sync" "time" ) @@ -29,70 +28,80 @@ type StepManager struct { } func NewStepManager(userID string) *StepManager { - now := time.Now() - interval := 15 * time.Minute - - // Default state (will be used if load fails or file missing) - defaultState := TripState{ - StartDate: now.Format("2006-01-02"), - StartTime: now, - DailyCache: make(map[string]int), - } - sm := &StepManager{ userID: userID, - tripState: defaultState, - syncInterval: interval, - lastSyncTime: now.Add(-interval), - nextSyncTime: now, + syncInterval: 15 * time.Minute, } - - if err := sm.LoadTripState(); err != nil { - fmt.Printf("Warning: Failed to load trip state: %v. Using new trip defaults.\n", err) - } else { - // Initialize total steps from the loaded state to avoid interpolating from 0 - initialTotal := sm.RecalculateTotalFromState() - sm.previousTotalSteps = initialTotal - sm.targetTotalSteps = initialTotal - fmt.Printf("Initialized step counts from cache: %d\n", initialTotal) - } - + sm.LoadTripState() return sm } func (sm *StepManager) LoadTripState() error { - tripPath := fmt.Sprintf("data/users/%s/trip.json", sm.userID) - data, err := os.ReadFile(tripPath) + var startTime time.Time + err := db.QueryRow(` + SELECT start_date, start_time, start_day_initial_steps, previous_total_steps, target_total_steps, last_sync_time, next_sync_time + FROM trips WHERE user_id = ? + `, sm.userID).Scan( + &sm.tripState.StartDate, &startTime, &sm.tripState.StartDayInitialSteps, + &sm.previousTotalSteps, &sm.targetTotalSteps, &sm.lastSyncTime, &sm.nextSyncTime, + ) if err != nil { - if os.IsNotExist(err) { + if err == sql.ErrNoRows { return nil // Normal for first run } return err } + sm.tripState.StartTime = startTime - var loadedState TripState - if err := json.Unmarshal(data, &loadedState); err != nil { - return fmt.Errorf("failed to parse trip.json: %w", err) + // Load daily cache from DB + rows, err := db.Query("SELECT date, steps FROM daily_steps WHERE user_id = ?", sm.userID) + if err != nil { + return err + } + defer rows.Close() + + sm.tripState.DailyCache = make(map[string]int) + for rows.Next() { + var date string + var steps int + if err := rows.Scan(&date, &steps); err != nil { + return err + } + sm.tripState.DailyCache[date] = steps } - // Only update if valid - sm.tripState = loadedState - if sm.tripState.DailyCache == nil { - sm.tripState.DailyCache = make(map[string]int) - } - fmt.Printf("Loaded trip state: StartDate=%s, InitialSteps=%d\n", sm.tripState.StartDate, sm.tripState.StartDayInitialSteps) return nil } func (sm *StepManager) SaveTripState() { - userDir := fmt.Sprintf("data/users/%s", sm.userID) - if err := os.MkdirAll(userDir, 0755); err != nil { - fmt.Printf("Error creating user directory: %v\n", err) - return + _, err := db.Exec(` + INSERT INTO trips (user_id, start_date, start_time, start_day_initial_steps, previous_total_steps, target_total_steps, last_sync_time, next_sync_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + start_date = VALUES(start_date), + start_time = VALUES(start_time), + start_day_initial_steps = VALUES(start_day_initial_steps), + previous_total_steps = VALUES(previous_total_steps), + target_total_steps = VALUES(target_total_steps), + last_sync_time = VALUES(last_sync_time), + next_sync_time = VALUES(next_sync_time) + `, sm.userID, sm.tripState.StartDate, sm.tripState.StartTime, sm.tripState.StartDayInitialSteps, + sm.previousTotalSteps, sm.targetTotalSteps, sm.lastSyncTime, sm.nextSyncTime) + if err != nil { + fmt.Printf("Error saving trip state: %v\n", err) + } + + // Save daily cache + for date, steps := range sm.tripState.DailyCache { + _, err := db.Exec(` + INSERT INTO daily_steps (user_id, date, steps) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE steps = VALUES(steps) + `, sm.userID, date, steps) + if err != nil { + fmt.Printf("Error saving daily steps for %s: %v\n", date, err) + } } - tripPath := fmt.Sprintf("%s/trip.json", userDir) - data, _ := json.MarshalIndent(sm.tripState, "", " ") - os.WriteFile(tripPath, data, 0644) } func (sm *StepManager) StartNewTrip() { @@ -233,9 +242,12 @@ func (sm *StepManager) GetStatus() map[string]interface{} { sm.mu.Lock() defer sm.mu.Unlock() + // Reload from DB to get latest sync results from other instances + sm.LoadTripState() + // Auto-trigger sync if needed if time.Now().After(sm.nextSyncTime) { - go sm.Sync() // Async sync + sm.Sync() // Sync and save to DB } currentSmoothed := sm.calculateSmoothedTokenAt(time.Now()) diff --git a/server/user.go b/server/user.go index dc87922..17222c3 100644 --- a/server/user.go +++ b/server/user.go @@ -1,11 +1,11 @@ package main import ( + "database/sql" "encoding/json" "fmt" "net/http" "os" - "sync" "time" ) @@ -16,104 +16,38 @@ type User struct { CreatedAt time.Time `json:"created_at"` } -type UserRegistry struct { - Users map[string]*User `json:"users"` // Map of FitbitUserID -> User - mu sync.RWMutex -} - -var userRegistry *UserRegistry - -// InitUserRegistry loads or creates the user registry -func InitUserRegistry() { - userRegistry = &UserRegistry{ - Users: make(map[string]*User), - } - userRegistry.Load() -} - -// Load reads the user registry from disk -func (ur *UserRegistry) Load() error { - ur.mu.Lock() - defer ur.mu.Unlock() - - data, err := os.ReadFile("data/users.json") +// GetUser retrieves a user by Fitbit user ID from the database +func GetUser(fitbitUserID string) (*User, error) { + var user User + err := db.QueryRow("SELECT fitbit_user_id, display_name, avatar_url, created_at FROM users WHERE fitbit_user_id = ?", fitbitUserID). + Scan(&user.FitbitUserID, &user.DisplayName, &user.AvatarURL, &user.CreatedAt) if err != nil { - if os.IsNotExist(err) { - return nil // First run, no users yet + if err == sql.ErrNoRows { + return nil, nil } - return err + return nil, err } - - return json.Unmarshal(data, ur) + return &user, nil } -// Save writes the user registry to disk -func (ur *UserRegistry) Save() error { - ur.mu.RLock() - defer ur.mu.RUnlock() - return ur.saveUnlocked() -} - -// saveUnlocked writes the user registry to disk without locking (caller must hold lock) -func (ur *UserRegistry) saveUnlocked() error { - if err := os.MkdirAll("data", 0755); err != nil { - return err - } - - data, err := json.MarshalIndent(ur, "", " ") +// CreateOrUpdateUser adds or updates a user in the database +func CreateOrUpdateUser(fitbitUserID, displayName, avatarURL string) (*User, error) { + _, err := db.Exec(` + INSERT INTO users (fitbit_user_id, display_name, avatar_url, created_at) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE display_name = ?, avatar_url = ? + `, fitbitUserID, displayName, avatarURL, displayName, avatarURL) if err != nil { - return err - } - - return os.WriteFile("data/users.json", data, 0644) -} - -// GetUser retrieves a user by Fitbit user ID -func (ur *UserRegistry) GetUser(fitbitUserID string) (*User, bool) { - ur.mu.RLock() - defer ur.mu.RUnlock() - - user, exists := ur.Users[fitbitUserID] - return user, exists -} - -// CreateOrUpdateUser adds or updates a user in the registry -func (ur *UserRegistry) CreateOrUpdateUser(fitbitUserID, displayName, avatarURL string) (*User, error) { - ur.mu.Lock() - defer ur.mu.Unlock() - - user, exists := ur.Users[fitbitUserID] - if exists { - // Update existing user - user.DisplayName = displayName - user.AvatarURL = avatarURL - } else { - // Create new user - user = &User{ - FitbitUserID: fitbitUserID, - DisplayName: displayName, - AvatarURL: avatarURL, - CreatedAt: time.Now(), - } - ur.Users[fitbitUserID] = user - } - - // Save without locking (we already have the lock) - if err := ur.saveUnlocked(); err != nil { return nil, err } - // Create user directory - userDir := fmt.Sprintf("data/users/%s", fitbitUserID) + // Create user directory for KML files + userDir := fmt.Sprintf("data/users/%s/kml", fitbitUserID) if err := os.MkdirAll(userDir, 0755); err != nil { return nil, err } - kmlDir := fmt.Sprintf("data/users/%s/kml", fitbitUserID) - if err := os.MkdirAll(kmlDir, 0755); err != nil { - return nil, err - } - return user, nil + return GetUser(fitbitUserID) } // FetchFitbitUserProfile fetches the user's profile from Fitbit API