// Package db provides the SQLite-backed data store for users, work types, days, and events. package db import ( "database/sql" "embed" "fmt" "log/slog" "time" "github.com/pressly/goose/v3" _ "modernc.org/sqlite" ) const driverName = "sqlite" //go:embed migrations/*.sql var migrationFS embed.FS // Store wraps a SQLite database connection and provides methods for all data access. type Store struct { db *sql.DB } // User represents a bot user with identity, timezone, and calendar preferences. type User struct { ID int64 ChatID int64 Username string Timezone string IsActive bool Calendar string CreatedAt int64 UpdatedAt int64 } // UserSettings holds all user-configurable preferences. type UserSettings struct { UserID int64 ExportAccent string ReportEnabled bool ReportTime string LastReportDate string RPGEnabled bool LeagueOptIn bool Currency string HourlyRate float64 CreatedAt int64 UpdatedAt int64 } // RPGStats holds the user's RPG progression data. type RPGStats struct { UserID int64 XP int64 Level int CurrentStreak int LongestStreak int TotalWorkSeconds int64 CreatedAt int64 UpdatedAt int64 } // Achievement is a predefined achievement definition. type Achievement struct { ID int64 Code string Name string Description string Icon string } // UserAchievement records when a user unlocked an achievement. type UserAchievement struct { ID int64 UserID int64 AchievementID int64 Code string Name string Description string UnlockedAt int64 } // DailyXP records XP earned per day per user. type DailyXP struct { ID int64 UserID int64 Date string XPEarned int64 WorkSeconds int64 CreatedAt int64 } // WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day. type WorkType struct { ID int64 Name string CreatedAt int64 } // Day represents a single day entry for a user, including day-off status and work type. type Day struct { ID int64 UserID int64 Date string IsDayOff bool DayOffReason string CurrentWorkTypeID int64 MinBreakThreshold int64 CreatedAt int64 UpdatedAt int64 } // Event represents a single timestamped event ("in" or "out") for a user on a given day. type Event struct { ID int64 UserID int64 DayID int64 EventType string WorkTypeID *int64 OccurredAt int64 Note string CreatedAt int64 } // NewStore opens or creates the SQLite database at path, runs migrations, and returns a Store. func NewStore(path string) (*Store, error) { db, err := sql.Open(driverName, path) if err != nil { return nil, err } if err := db.Ping(); err != nil { return nil, err } if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { slog.Warn("failed to set WAL mode", "error", err) } if err := runMigrations(db); err != nil { return nil, err } return &Store{db: db}, nil } // Close closes the underlying SQLite database connection. func (s *Store) Close() error { return s.db.Close() } // DB returns the underlying *sql.DB for advanced use. func (s *Store) DB() *sql.DB { return s.db } func hasTable(db *sql.DB, name string) bool { var exists int if err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists); err != nil { slog.Warn("hasTable query failed", "table", name, "error", err) return false } return exists > 0 } // runMigrations handles all database migrations using goose. func runMigrations(db *sql.DB) error { // Step 1: Handle the ancient single-table schema (pre-goose era). // Rename old tables so goose can create the current schema cleanly. needsOldMigrate := hasTable(db, "events_old") || (hasTable(db, "events") && !hasCol(db, "events", "user_id")) if needsOldMigrate { slog.Info("migrating from old schema to new schema") for _, t := range []string{"events", "days_off", "settings"} { if hasTable(db, t) { if _, err := db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old"); err != nil { return err } } } } // Step 2: Run goose (creates goose_db_version table if needed, // applies pending migrations). goose.SetBaseFS(migrationFS) if err := goose.SetDialect("sqlite3"); err != nil { return err } if err := goose.Up(db, "migrations"); err != nil { return err } // Step 3: Copy old data into the freshly created tables. if needsOldMigrate { if err := migrateFromOldSchemaData(db); err != nil { return err } } return nil } func hasCol(db *sql.DB, table, column string) bool { rows, err := db.Query("PRAGMA table_info(" + table + ")") if err != nil { slog.Warn("hasCol query failed", "table", table, "error", err) return false } defer func() { if err := rows.Close(); err != nil { slog.Warn("hasCol rows.Close failed", "table", table, "error", err) } }() for rows.Next() { var cid int var name, ctype string var notnull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { slog.Warn("hasCol scan failed", "table", table, "error", err) return false } if name == column { return true } } return false } func migrateFromOldSchemaData(db *sql.DB) error { migrateUsers(db) migrateDaysOff(db) migrateEvents(db) migrateSettings(db) for _, t := range []string{"days_off_old", "settings_old", "events_old"} { if hasTable(db, t) { if _, err := db.Exec("DROP TABLE IF EXISTS " + t); err != nil { slog.Warn("failed to drop old table", "table", t, "error", err) } } } slog.Info("migration complete") return nil } func migrateUsers(db *sql.DB) { if !hasTable(db, "events_old") { return } rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old") if err != nil { slog.Warn("migration: failed to query events_old for users", "error", err) return } defer func() { if err := rows.Close(); err != nil { slog.Warn("migration: rows.Close failed in migrateUsers", "error", err) } }() for rows.Next() { var chatID int64 if err := rows.Scan(&chatID); err != nil { slog.Warn("migration: scan failed in migrateUsers", "error", err) continue } if _, err := db.Exec( "INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')", chatID, ); err != nil { slog.Warn("migration: failed to insert user", "chat_id", chatID, "error", err) } } } func migrateDaysOff(db *sql.DB) { if !hasTable(db, "days_off_old") { return } rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old") if err != nil { slog.Warn("migration: failed to query days_off_old", "error", err) return } defer func() { if err := rows.Close(); err != nil { slog.Warn("migration: rows.Close failed in migrateDaysOff", "error", err) } }() for rows.Next() { var chatID int64 var date, reason string if err := rows.Scan(&chatID, &date, &reason); err != nil { slog.Warn("migration: scan failed in migrateDaysOff", "error", err) continue } var userID int64 err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) if err != nil { slog.Warn("migration: user not found for days_off", "chat_id", chatID, "error", err) continue } if _, err := db.Exec( "INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason", userID, date, reason, ); err != nil { slog.Warn("migration: failed to insert day_off", "user_id", userID, "date", date, "error", err) } } } func migrateEvents(db *sql.DB) { if !hasTable(db, "events_old") { return } rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC") if err != nil { slog.Warn("migration: failed to query events_old", "error", err) return } defer func() { if err := rows.Close(); err != nil { slog.Warn("migration: rows.Close failed in migrateEvents", "error", err) } }() for rows.Next() { var chatID int64 var eventType, note string var occurredAt, createdAt int64 if err := rows.Scan(&chatID, &eventType, &occurredAt, ¬e, &createdAt); err != nil { slog.Warn("migration: scan failed in migrateEvents", "error", err) continue } var userID int64 err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) if err != nil { slog.Warn("migration: user not found for event", "chat_id", chatID, "error", err) continue } date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02") if _, err := db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date); err != nil { slog.Warn("migration: failed to insert day", "user_id", userID, "date", date, "error", err) continue } var dayID int64 if err := db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID); err != nil { slog.Warn("migration: day not found after insert", "user_id", userID, "date", date, "error", err) continue } if _, err := db.Exec( "INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)", userID, dayID, eventType, occurredAt, note, createdAt, ); err != nil { slog.Warn("migration: failed to insert event", "user_id", userID, "error", err) } } } func migrateSettings(db *sql.DB) { if !hasTable(db, "settings_old") { return } rows, err := db.Query("SELECT chat_id, key, value FROM settings_old") if err != nil { slog.Warn("migration: failed to query settings_old", "error", err) return } defer func() { if err := rows.Close(); err != nil { slog.Warn("migration: rows.Close failed in migrateSettings", "error", err) } }() for rows.Next() { var chatID int64 var key, value string if err := rows.Scan(&chatID, &key, &value); err != nil { slog.Warn("migration: scan failed in migrateSettings", "error", err) continue } if key == "remote_flag" { var userID int64 err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) if err != nil { slog.Warn("migration: user not found for setting", "chat_id", chatID, "error", err) continue } wt := int64(1) if value == "remote" { wt = 2 } if _, err := db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02")); err != nil { slog.Warn("migration: failed to update work type", "user_id", userID, "error", err) } } } } // usernames is a pool of fun default usernames assigned randomly to new users. var usernames = []string{ "Lazy Dev", "Solo Leveler", "Local = Prod", "Database Rat", "Refactoring Perfectionist", "PHP 5 Cultist", "Java Script Slave", "AI Slop", "OOP Cultist", "Functional Purist", "Merge Conflict Survivor", "Stack Overflow Diplomat", "NULL Pointer", "Memory Leak", "Segmentation Fault", "Infinite Looper", "TypeScript Wizard", "CSS Box Model Gambler", "Docker Compose Gambler", "YAML Engineer", "Production Pusher", "Test Skipper", "Code Review Dodger", "Commit --force User", "Git Merge Master", "Dependency Hell Dweller", "Deprecation Warning", "Legacy Code Whisperer", "Agile Scrum Lord", "Stand Up Sitter", "Sprint Burnout", "Ticket Creator Supreme", "Technical Debt Collector", "Documentation? Never Heard", "Stack Trace Reader", "Binary Search Debugger", "Rubber Duck", "Hotfix Hero", "Feature Flag Addict", } func randomUsername() string { return usernames[time.Now().UnixNano()%int64(len(usernames))] } // GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists. func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { _, err := s.db.Exec( "INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)", chatID, randomUsername(), ) if err != nil { return nil, err } return s.GetUserByChatID(chatID) } // GetUserByChatID retrieves a single user by their Telegram chat ID. func (s *Store) GetUserByChatID(chatID int64) (*User, error) { row := s.db.QueryRow(` SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at FROM users WHERE chat_id = ? `, chatID) var u User err := row.Scan( &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { return nil, err } return &u, nil } // UpdateUser persists core fields of the user record. func (s *Store) UpdateUser(user *User) error { _, err := s.db.Exec( "UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?", user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID, ) return err } // GetAllUsers returns all users in the database. func (s *Store) GetAllUsers() ([]User, error) { rows, err := s.db.Query(` SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at FROM users `) if err != nil { return nil, err } defer rows.Close() var users []User for rows.Next() { var u User if err := rows.Scan( &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, err } users = append(users, u) } return users, rows.Err() } // -- User Settings -- // GetOrCreateSettings retrieves settings for a user, creating defaults if none exist. func (s *Store) GetOrCreateSettings(userID int64) (*UserSettings, error) { _, err := s.db.Exec( "INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", userID, ) if err != nil { return nil, err } return s.GetSettings(userID) } // GetSettings retrieves the settings for a user. func (s *Store) GetSettings(userID int64) (*UserSettings, error) { row := s.db.QueryRow(` SELECT user_id, export_accent, report_enabled, report_time, last_report_date, rpg_enabled, league_opt_in, currency, hourly_rate, created_at, updated_at FROM user_settings WHERE user_id = ? `, userID) var st UserSettings err := row.Scan( &st.UserID, &st.ExportAccent, &st.ReportEnabled, &st.ReportTime, &st.LastReportDate, &st.RPGEnabled, &st.LeagueOptIn, &st.Currency, &st.HourlyRate, &st.CreatedAt, &st.UpdatedAt, ) if err != nil { return nil, err } return &st, nil } // UpdateSettings persists all fields of the user settings. func (s *Store) UpdateSettings(st *UserSettings) error { _, err := s.db.Exec(` UPDATE user_settings SET export_accent=?, report_enabled=?, report_time=?, last_report_date=?, rpg_enabled=?, league_opt_in=?, currency=?, hourly_rate=?, updated_at=unixepoch() WHERE user_id=? `, st.ExportAccent, st.ReportEnabled, st.ReportTime, st.LastReportDate, st.RPGEnabled, st.LeagueOptIn, st.Currency, st.HourlyRate, st.UserID) return err } // MarkReportSent records the last report date for a user. func (s *Store) MarkReportSent(userID int64, date string) error { st, err := s.GetOrCreateSettings(userID) if err != nil { return err } st.LastReportDate = date return s.UpdateSettings(st) } // GetReportEnabled returns whether daily reports are enabled for the user. func (s *Store) GetReportEnabled(userID int64) bool { st, err := s.GetOrCreateSettings(userID) if err != nil { return true } return st.ReportEnabled } // -- RPG Stats -- // GetOrCreateRPGStats retrieves RPG stats for a user, creating defaults if none exist. func (s *Store) GetOrCreateRPGStats(userID int64) (*RPGStats, error) { _, err := s.db.Exec( "INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)", userID, ) if err != nil { return nil, err } return s.GetRPGStats(userID) } // GetRPGStats retrieves the RPG stats for a user. func (s *Store) GetRPGStats(userID int64) (*RPGStats, error) { row := s.db.QueryRow(` SELECT user_id, xp, level, current_streak, longest_streak, total_work_seconds, created_at, updated_at FROM user_rpg WHERE user_id = ? `, userID) var st RPGStats err := row.Scan( &st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak, &st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt, ) if err != nil { return nil, err } return &st, nil } // UpdateRPGStats persists the RPG stats for a user. func (s *Store) UpdateRPGStats(st *RPGStats) error { _, err := s.db.Exec(` UPDATE user_rpg SET xp=?, level=?, current_streak=?, longest_streak=?, total_work_seconds=?, updated_at=unixepoch() WHERE user_id=? `, st.XP, st.Level, st.CurrentStreak, st.LongestStreak, st.TotalWorkSeconds, st.UserID) return err } // -- Daily XP -- // UpsertDailyXP inserts or updates the daily XP record for a user/date. func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error { _, err := s.db.Exec(` INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, date) DO UPDATE SET xp_earned = xp_earned + ?, work_seconds = work_seconds + ? `, userID, date, xpEarned, workSeconds, xpEarned, workSeconds) return err } // GetDailyXP returns the daily XP record for a user/date. func (s *Store) GetDailyXP(userID int64, date string) (*DailyXP, error) { row := s.db.QueryRow(` SELECT id, user_id, date, xp_earned, work_seconds, created_at FROM daily_xp WHERE user_id=? AND date=? `, userID, date) var dx DailyXP err := row.Scan(&dx.ID, &dx.UserID, &dx.Date, &dx.XPEarned, &dx.WorkSeconds, &dx.CreatedAt) if err != nil { return nil, err } return &dx, nil } // -- Achievements -- // GetAllAchievements returns all defined achievements. func (s *Store) GetAllAchievements() ([]Achievement, error) { rows, err := s.db.Query("SELECT id, code, name, description, icon FROM achievements ORDER BY id") if err != nil { return nil, err } defer rows.Close() var achs []Achievement for rows.Next() { var a Achievement if err := rows.Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon); err != nil { return nil, err } achs = append(achs, a) } return achs, rows.Err() } // GetAchievementByCode returns an achievement by its code string. func (s *Store) GetAchievementByCode(code string) (*Achievement, error) { var a Achievement err := s.db.QueryRow( "SELECT id, code, name, description, icon FROM achievements WHERE code=?", code, ).Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon) if err != nil { return nil, err } return &a, nil } // GetUserAchievements returns all achievements unlocked by a user. func (s *Store) GetUserAchievements(userID int64) ([]UserAchievement, error) { rows, err := s.db.Query(` SELECT ua.id, ua.user_id, ua.achievement_id, a.code, a.name, a.description, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_id = ? ORDER BY ua.unlocked_at ASC `, userID) if err != nil { return nil, err } defer rows.Close() var uas []UserAchievement for rows.Next() { var ua UserAchievement if err := rows.Scan(&ua.ID, &ua.UserID, &ua.AchievementID, &ua.Code, &ua.Name, &ua.Description, &ua.UnlockedAt); err != nil { return nil, err } uas = append(uas, ua) } return uas, rows.Err() } // UnlockAchievement grants an achievement to a user (no-op if already owned). func (s *Store) UnlockAchievement(userID int64, achievementID int64) (bool, error) { res, err := s.db.Exec( "INSERT OR IGNORE INTO user_achievements (user_id, achievement_id) VALUES (?, ?)", userID, achievementID, ) if err != nil { return false, err } n, _ := res.RowsAffected() return n > 0, nil } // HasAchievement checks whether a user has unlocked a specific achievement code. func (s *Store) HasAchievement(userID int64, code string) (bool, error) { var count int err := s.db.QueryRow(` SELECT COUNT(1) FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_id=? AND a.code=? `, userID, code).Scan(&count) return count > 0, err } // -- League / Rankings -- // LeagueEntry represents a user in a league ranking. type LeagueEntry struct { UserID int64 Username string XP int64 Level int Streak int TotalHours float64 } // GetLeagueRankings returns all users who have opted into the league, ordered by the given column. func (s *Store) GetLeagueRankings(orderBy string) ([]LeagueEntry, error) { validOrders := map[string]bool{ "xp": true, "level": true, "streak": true, "hours": true, } if !validOrders[orderBy] { orderBy = "xp" } col := "r.xp" switch orderBy { case "level": col = "r.level" case "streak": col = "r.current_streak" case "hours": col = "r.total_work_seconds" } query := fmt.Sprintf(` SELECT u.id, u.username, r.xp, r.level, r.current_streak, r.total_work_seconds FROM user_rpg r JOIN users u ON u.id = r.user_id JOIN user_settings s ON s.user_id = r.user_id WHERE s.league_opt_in = 1 ORDER BY %s DESC, u.username ASC `, col) rows, err := s.db.Query(query) if err != nil { return nil, err } defer rows.Close() var entries []LeagueEntry for rows.Next() { var e LeagueEntry if err := rows.Scan(&e.UserID, &e.Username, &e.XP, &e.Level, &e.Streak, &e.TotalHours); err != nil { return nil, err } e.TotalHours = float64(e.TotalHours) / 3600.0 entries = append(entries, e) } return entries, rows.Err() } // GetWorkTypes returns all work types ordered by ID. func (s *Store) GetWorkTypes() ([]WorkType, error) { rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC") if err != nil { return nil, err } defer rows.Close() var wts []WorkType for rows.Next() { var wt WorkType if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil { return nil, err } wts = append(wts, wt) } return wts, rows.Err() } // GetWorkType returns a single work type by its ID. func (s *Store) GetWorkType(id int64) (*WorkType, error) { var wt WorkType err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt) if err != nil { return nil, err } return &wt, nil } // GetOrCreateDay retrieves a day record for a user/date, creating one if it does not exist. func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) { _, err := s.db.Exec( "INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date, ) if err != nil { return nil, err } return s.GetDay(userID, date) } // GetDay returns a single day record for the given user and date. func (s *Store) GetDay(userID int64, date string) (*Day, error) { row := s.db.QueryRow(` SELECT id, user_id, date, is_day_off, day_off_reason, current_work_type_id, min_break_threshold, created_at, updated_at FROM days WHERE user_id=? AND date=? `, userID, date) var d Day err := row.Scan( &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, ) if err != nil { return nil, err } return &d, nil } // UpdateDay persists the given day record's editable fields. func (s *Store) UpdateDay(day *Day) error { _, err := s.db.Exec( "UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?", day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID, ) return err } // SetDayOff marks a user's day as a day off with an optional reason. func (s *Store) SetDayOff(userID int64, date string, reason string) error { _, err := s.GetOrCreateDay(userID, date) if err != nil { return err } _, err = s.db.Exec( "UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?", reason, userID, date, ) return err } // RemoveDayOff clears the day-off flag for a user on the given date. func (s *Store) RemoveDayOff(userID int64, date string) error { _, err := s.db.Exec( "UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?", userID, date, ) return err } // IsDayOff checks whether the given date is a day off for the user. func (s *Store) IsDayOff(userID int64, date string) (bool, error) { var isOff bool err := s.db.QueryRow( "SELECT is_day_off FROM days WHERE user_id=? AND date=?", userID, date, ).Scan(&isOff) if err == sql.ErrNoRows { return false, nil } if err != nil { return false, err } return isOff, nil } // GetUserDaysInRange returns all day records for a user within a date range (inclusive). func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]Day, error) { rows, err := s.db.Query(` SELECT id, user_id, date, is_day_off, day_off_reason, current_work_type_id, min_break_threshold, created_at, updated_at FROM days WHERE user_id=? AND date >= ? AND date <= ? ORDER BY date ASC `, userID, startDate, endDate) if err != nil { return nil, err } defer rows.Close() var days []Day for rows.Next() { var d Day if err := rows.Scan( &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, ); err != nil { return nil, err } days = append(days, d) } return days, rows.Err() } // CreateEvent inserts a new event record (typically "in" or "out"). func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error { _, err := s.db.Exec( "INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)", userID, dayID, eventType, workTypeID, occurredAt, note, ) return err } // GetLastEvent returns the most recent event for the user, ordered by occurrence time. func (s *Store) GetLastEvent(userID int64) (*Event, error) { row, err := s.db.Query(` SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE user_id = ? ORDER BY occurred_at DESC LIMIT 1 `, userID) if err != nil { return nil, err } defer func() { if err := row.Close(); err != nil { slog.Warn("GetLastEvent: row.Close failed", "error", err) } }() if !row.Next() { return nil, sql.ErrNoRows } var e Event var wt sql.NullInt64 if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } if wt.Valid { e.WorkTypeID = &wt.Int64 } return &e, nil } // EventsForDayByDate returns all events for a user on a specific date, ordered by occurrence. func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) { rows, err := s.db.Query(` SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at FROM events e JOIN days d ON d.id = e.day_id WHERE d.user_id = ? AND d.date = ? ORDER BY e.occurred_at ASC `, userID, date) if err != nil { return nil, err } defer rows.Close() var events []Event for rows.Next() { var e Event var wt sql.NullInt64 if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } if wt.Valid { e.WorkTypeID = &wt.Int64 } events = append(events, e) } return events, rows.Err() } // SetEventNote updates the note for an event. func (s *Store) SetEventNote(eventID int64, note string) error { _, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID) return err } // EventsForDayByDayID returns all events for a day record, ordered by occurrence. func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) { rows, err := s.db.Query(` SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE day_id = ? ORDER BY occurred_at ASC `, dayID) if err != nil { return nil, err } defer rows.Close() var events []Event for rows.Next() { var e Event var wt sql.NullInt64 if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } if wt.Valid { e.WorkTypeID = &wt.Int64 } events = append(events, e) } return events, rows.Err() }