package db import ( "database/sql" "errors" "fmt" "log/slog" "os" "path/filepath" "track-gopher/models" _ "github.com/mattn/go-sqlite3" ) // ErrNotFound is returned when a requested record is not found var ErrNotFound = errors.New("record not found") // DB represents the database connection type DB struct { *sql.DB logger *slog.Logger } // NewDB creates a new database connection func New(dbPath string) (*DB, error) { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, })) // Ensure directory exists if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { return nil, fmt.Errorf("failed to create database directory: %w", err) } // Open database connection sqlDB, err := sql.Open("sqlite3", dbPath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // Create database instance db := &DB{ DB: sqlDB, logger: logger, } // Initialize schema if err := db.initSchema(); err != nil { db.Close() return nil, fmt.Errorf("failed to initialize schema: %w", err) } return db, nil } // initSchema creates the database tables if they don't exist func (db *DB) initSchema() error { // Create groups table _, err := db.Exec(` CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { return fmt.Errorf("failed to create groups table: %w", err) } // Create racers table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS racers ( id INTEGER PRIMARY KEY AUTOINCREMENT, first_name TEXT NOT NULL, last_name TEXT NOT NULL, car_number TEXT NOT NULL UNIQUE, car_weight REAL NOT NULL, group_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (group_id) REFERENCES groups(id) ) `) if err != nil { return fmt.Errorf("failed to create racers table: %w", err) } // Create races table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS races ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { return fmt.Errorf("failed to create races table: %w", err) } // Create race_results table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS race_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, race_id INTEGER NOT NULL, racer_id INTEGER NOT NULL, lane INTEGER NOT NULL, time REAL, place INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (race_id) REFERENCES races(id), FOREIGN KEY (racer_id) REFERENCES racers(id), UNIQUE(race_id, lane) ) `) if err != nil { return fmt.Errorf("failed to create race_results table: %w", err) } // Create heats table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS heats ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, heat_num INTEGER NOT NULL, lane1_id INTEGER, lane2_id INTEGER, lane3_id INTEGER, lane4_id INTEGER, FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, FOREIGN KEY (lane1_id) REFERENCES racers(id) ON DELETE SET NULL, FOREIGN KEY (lane2_id) REFERENCES racers(id) ON DELETE SET NULL, FOREIGN KEY (lane3_id) REFERENCES racers(id) ON DELETE SET NULL, FOREIGN KEY (lane4_id) REFERENCES racers(id) ON DELETE SET NULL ) `) if err != nil { return fmt.Errorf("failed to create heats table: %w", err) } // Create settings table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `) if err != nil { return fmt.Errorf("failed to create settings table: %w", err) } // Create heat_results table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS heat_results ( group_id INTEGER NOT NULL, heat_number INTEGER NOT NULL, lane1_time REAL NOT NULL, lane1_position INTEGER NOT NULL, lane2_time REAL NOT NULL, lane2_position INTEGER NOT NULL, lane3_time REAL NOT NULL, lane3_position INTEGER NOT NULL, lane4_time REAL NOT NULL, lane4_position INTEGER NOT NULL, PRIMARY KEY (group_id, heat_number), FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE ); `) if err != nil { return fmt.Errorf("failed to create heat_results table: %w", err) } // Add default settings if they don't exist _, err = db.Exec(` INSERT OR IGNORE INTO settings (key, value) VALUES ('current_racing_group', '1'); `) if err != nil { return fmt.Errorf("failed to add default settings: %w", err) } return nil } // Close closes the database connection func (db *DB) Close() error { return db.DB.Close() } // SaveHeats saves the heats for a group, replacing any existing heats func (db *DB) SaveHeats(groupID int64, heats []models.Heat) error { // Start a transaction tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() // Delete existing heats for this group _, err = tx.Exec("DELETE FROM heats WHERE group_id = ?", groupID) if err != nil { return fmt.Errorf("failed to delete existing heats: %w", err) } // Insert new heats stmt, err := tx.Prepare("INSERT INTO heats (group_id, heat_num, lane1_id, lane2_id, lane3_id, lane4_id) VALUES (?, ?, ?, ?, ?, ?)") if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for i, heat := range heats { _, err = stmt.Exec(groupID, i+1, heat.Lane1ID, heat.Lane2ID, heat.Lane3ID, heat.Lane4ID) if err != nil { return fmt.Errorf("failed to insert heat: %w", err) } } // Commit transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // GetHeats retrieves the saved heats for a group func (db *DB) GetHeats(groupID int64) ([]models.Heat, error) { rows, err := db.Query(` SELECT heat_num, lane1_id, lane2_id, lane3_id, lane4_id FROM heats WHERE group_id = ? ORDER BY heat_num `, groupID) if err != nil { return nil, fmt.Errorf("failed to query heats: %w", err) } defer rows.Close() var heats []models.Heat for rows.Next() { var heat models.Heat err := rows.Scan(&heat.HeatNum, &heat.Lane1ID, &heat.Lane2ID, &heat.Lane3ID, &heat.Lane4ID) if err != nil { return nil, fmt.Errorf("failed to scan heat: %w", err) } heats = append(heats, heat) } return heats, nil } // HasSavedHeats checks if a group has saved heats func (db *DB) HasSavedHeats(groupID int64) (bool, error) { var count int err := db.QueryRow("SELECT COUNT(*) FROM heats WHERE group_id = ?", groupID).Scan(&count) if err != nil { return false, fmt.Errorf("failed to check for saved heats: %w", err) } return count > 0, nil } // DeleteHeats deletes all heats for a group func (db *DB) DeleteHeats(groupID int64) error { _, err := db.Exec("DELETE FROM heats WHERE group_id = ?", groupID) return err } // GetCurrentRacingGroup gets the currently racing group func (db *DB) GetCurrentRacingGroup() (models.Group, error) { var group models.Group // Get the current racing group ID from settings var groupID int64 err := db.QueryRow("SELECT value FROM settings WHERE key = 'current_racing_group'").Scan(&groupID) if err != nil { if err == sql.ErrNoRows { // If no group is set, get the first group err = db.QueryRow("SELECT id, name, description FROM groups ORDER BY id LIMIT 1").Scan( &group.ID, &group.Name, &group.Description) if err != nil { return group, err } // Set this as the current racing group _, err = db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('current_racing_group', ?)", group.ID) return group, err } return group, err } // Get the group details err = db.QueryRow("SELECT id, name, description FROM groups WHERE id = ?", groupID).Scan( &group.ID, &group.Name, &group.Description) return group, err } // SetCurrentRacingGroup sets the currently racing group func (db *DB) SetCurrentRacingGroup(groupID int64) error { _, err := db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('current_racing_group', ?)", groupID) return err } // GetCurrentHeatNumber gets the current heat number for a group func (db *DB) GetCurrentHeatNumber(groupID int64) (int, error) { var heatNum int key := fmt.Sprintf("current_heat_%d", groupID) err := db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&heatNum) if err != nil { if err == sql.ErrNoRows { // If no heat is set, default to 1 _, err = db.Exec("INSERT INTO settings (key, value) VALUES (?, 1)", key) return 1, err } return 0, err } return heatNum, nil } // SetCurrentHeatNumber sets the current heat number for a group func (db *DB) SetCurrentHeatNumber(groupID int64, heatNum int) error { key := fmt.Sprintf("current_heat_%d", groupID) _, err := db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, heatNum) return err } func (db *DB) SaveLaneResult(groupID int64, heatNum int, lane int, time float64, position int) error { db.logger.Info("Saving lane result", "groupID", groupID, "heatNum", heatNum, "lane", lane, "time", time, "position", position) var count int err := db.QueryRow("SELECT COUNT(*) FROM heat_results WHERE group_id = ? AND heat_number = ?", groupID, heatNum).Scan(&count) if err != nil { return err } if count > 0 { switch lane { case 1: _, err = db.Exec("UPDATE heat_results SET lane1_time = ?, lane1_position = ? WHERE group_id = ? AND heat_number = ?", time, position, groupID, heatNum) case 2: _, err = db.Exec("UPDATE heat_results SET lane2_time = ?, lane2_position = ? WHERE group_id = ? AND heat_number = ?", time, position, groupID, heatNum) case 3: _, err = db.Exec("UPDATE heat_results SET lane3_time = ?, lane3_position = ? WHERE group_id = ? AND heat_number = ?", time, position, groupID, heatNum) case 4: _, err = db.Exec("UPDATE heat_results SET lane4_time = ?, lane4_position = ? WHERE group_id = ? AND heat_number = ?", time, position, groupID, heatNum) } } else { switch lane { case 1: _, err = db.Exec("INSERT INTO heat_results (group_id, heat_number, lane1_time, lane1_position) VALUES (?, ?, ?, ?)", groupID, heatNum, time, position) case 2: _, err = db.Exec("INSERT INTO heat_results (group_id, heat_number, lane2_time, lane2_position) VALUES (?, ?, ?, ?)", groupID, heatNum, time, position) case 3: _, err = db.Exec("INSERT INTO heat_results (group_id, heat_number, lane3_time, lane3_position) VALUES (?, ?, ?, ?)", groupID, heatNum, time, position) case 4: _, err = db.Exec("INSERT INTO heat_results (group_id, heat_number, lane4_time, lane4_position) VALUES (?, ?, ?, ?)", groupID, heatNum, time, position) } } if err != nil { db.logger.Error("Error saving lane result", "error", err) } else { db.logger.Info("Saved lane result", "groupID", groupID, "heatNum", heatNum, "lane", lane, "time", time, "position", position) } return err } // SaveHeatResult saves the result of a heat func (db *DB) SaveHeatResult(result models.HeatResult) error { db.logger.Info("Saving heat result", "result", result) // Check if result already exists var count int err := db.QueryRow("SELECT COUNT(*) FROM heat_results WHERE group_id = ? AND heat_number = ?", result.GroupID, result.HeatNumber).Scan(&count) if err != nil { return err } if count > 0 { // Update existing result _, err = db.Exec(` UPDATE heat_results SET lane1_time = ?, lane1_position = ?, lane2_time = ?, lane2_position = ?, lane3_time = ?, lane3_position = ?, lane4_time = ?, lane4_position = ? WHERE group_id = ? AND heat_number = ?`, result.Lane1Time, result.Lane1Position, result.Lane2Time, result.Lane2Position, result.Lane3Time, result.Lane3Position, result.Lane4Time, result.Lane4Position, result.GroupID, result.HeatNumber) } else { // Insert new result _, err = db.Exec(` INSERT INTO heat_results ( group_id, heat_number, lane1_time, lane1_position, lane2_time, lane2_position, lane3_time, lane3_position, lane4_time, lane4_position ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, result.GroupID, result.HeatNumber, result.Lane1Time, result.Lane1Position, result.Lane2Time, result.Lane2Position, result.Lane3Time, result.Lane3Position, result.Lane4Time, result.Lane4Position) } db.logger.Info("Saved heat result", "result", result) return err } // GetHeatResult gets the result of a specific heat func (db *DB) GetHeatResult(groupID int64, heatNum int) (*models.HeatResult, error) { result := &models.HeatResult{} err := db.QueryRow(` SELECT group_id, heat_number, lane1_time, lane1_position, lane2_time, lane2_position, lane3_time, lane3_position, lane4_time, lane4_position FROM heat_results WHERE group_id = ? AND heat_number = ?`, groupID, heatNum).Scan( &result.GroupID, &result.HeatNumber, &result.Lane1Time, &result.Lane1Position, &result.Lane2Time, &result.Lane2Position, &result.Lane3Time, &result.Lane3Position, &result.Lane4Time, &result.Lane4Position) if err != nil { if err == sql.ErrNoRows { return nil, ErrNotFound } return nil, err } return result, nil } // GetHeatResults gets all heat results for a group func (db *DB) GetHeatResults(groupID int64) ([]models.HeatResult, error) { db.logger.Info("Getting heat results for group", "groupID", groupID) rows, err := db.Query(` SELECT group_id, heat_number, lane1_time, lane1_position, lane2_time, lane2_position, lane3_time, lane3_position, lane4_time, lane4_position FROM heat_results WHERE group_id = ? ORDER BY heat_number`, groupID) if err != nil { return nil, err } defer rows.Close() var results []models.HeatResult for rows.Next() { var result models.HeatResult err := rows.Scan( &result.GroupID, &result.HeatNumber, &result.Lane1Time, &result.Lane1Position, &result.Lane2Time, &result.Lane2Position, &result.Lane3Time, &result.Lane3Position, &result.Lane4Time, &result.Lane4Position) if err != nil { return nil, err } results = append(results, result) } db.logger.Info("Heat results", "results", results) return results, nil } // DeleteHeatResult deletes a heat result func (db *DB) DeleteHeatResult(groupID int64, heatNum int) error { _, err := db.Exec("DELETE FROM heat_results WHERE group_id = ? AND heat_number = ?", groupID, heatNum) return err } func (db *DB) GetHeatData(groupID int64, heatNum int) (*models.HeatData, error) { db.logger.Info("Getting heat data for group", "groupID", groupID, "heatNum", heatNum) heatData := &models.HeatData{} lane1 := int64(0) lane2 := int64(0) lane3 := int64(0) lane4 := int64(0) err := db.QueryRow(` SELECT group_id, heat_num, g.name as group_name, (SELECT COUNT(*) FROM heats WHERE group_id = ?) as total_heats, h.lane1_id, h.lane2_id, h.lane3_id, h.lane4_id FROM heats h JOIN groups g ON g.id = h.group_id WHERE h.group_id = ? AND h.heat_num = ?`, groupID, groupID, heatNum).Scan( &heatData.Group.ID, &heatData.HeatNumber, &heatData.Group.Name, &heatData.TotalHeats, &lane1, &lane2, &lane3, &lane4) if err != nil { return nil, fmt.Errorf("error getting heat data: %v", err) } // Get racer data for each lane if lane1 != 0 { lane1Data, err := db.getLaneData(1, lane1, heatNum, groupID) if err != nil { return nil, err } heatData.Lane1 = lane1Data } if lane2 != 0 { lane2Data, err := db.getLaneData(2, lane2, heatNum, groupID) if err != nil { return nil, err } heatData.Lane2 = lane2Data } if lane3 != 0 { lane3Data, err := db.getLaneData(3, lane3, heatNum, groupID) if err != nil { return nil, err } heatData.Lane3 = lane3Data } if lane4 != 0 { lane4Data, err := db.getLaneData(4, lane4, heatNum, groupID) if err != nil { return nil, err } heatData.Lane4 = lane4Data } db.logger.Info("Heat data", "heatData", heatData) return heatData, nil } // getLaneData gets the racer data for a specific lane func (db *DB) getLaneData(lane int, racerID int64, heatNum int, groupID int64) (*models.LaneData, error) { // Get racer data var racer models.Racer err := db.QueryRow(` SELECT id, first_name, last_name, car_number, car_weight, group_id FROM racers WHERE id = ?`, racerID).Scan( &racer.ID, &racer.FirstName, &racer.LastName, &racer.CarNumber, &racer.CarWeight, &racer.GroupID) if err != nil { if err == sql.ErrNoRows { return nil, ErrNotFound } return nil, err } // Create lane data with racer info laneData := &models.LaneData{ Lane: lane, RacerID: racerID, Name: racer.FirstName + " " + racer.LastName, CarNum: racer.CarNumber, CarWeight: racer.CarWeight, Time: 0, Place: 0, } heatResult, err := db.GetHeatResult(groupID, heatNum) if err != nil { return laneData, nil } switch lane { case 1: laneData.Time = heatResult.Lane1Time laneData.Place = heatResult.Lane1Position case 2: laneData.Time = heatResult.Lane2Time laneData.Place = heatResult.Lane2Position case 3: laneData.Time = heatResult.Lane3Time laneData.Place = heatResult.Lane3Position case 4: laneData.Time = heatResult.Lane4Time laneData.Place = heatResult.Lane4Position } return laneData, nil }