package repo import ( "database/sql" "errors" "fmt" "log/slog" "time" _ "modernc.org/sqlite" "worktimeBot/internal/domain" ) const driverName = "sqlite" type Store struct { db *sql.DB } func NewStore(path string) (*Store, error) { db, err := sql.Open(driverName, path) if err != nil { return nil, fmt.Errorf("open: %w", err) } if err := db.Ping(); err != nil { return nil, fmt.Errorf("ping: %w", 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, fmt.Errorf("migrate: %w", err) } return &Store{db: db}, nil } func (s *Store) DeleteUserData(userID int64) error { tx, err := s.db.Begin() if err != nil { return fmt.Errorf("delete user data begin tx: %w", err) } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM events WHERE day_id IN (SELECT id FROM days WHERE user_id=?)`, userID); err != nil { return fmt.Errorf("delete user data events: %w", err) } if _, err := tx.Exec(`DELETE FROM days WHERE user_id=?`, userID); err != nil { return fmt.Errorf("delete user data days: %w", err) } if _, err := tx.Exec(`DELETE FROM user_achievements WHERE user_id=?`, userID); err != nil { return fmt.Errorf("delete user data achievements: %w", err) } if _, err := tx.Exec(`DELETE FROM daily_xp WHERE user_id=?`, userID); err != nil { return fmt.Errorf("delete user data daily_xp: %w", err) } if _, err := tx.Exec(`DELETE FROM user_rpg WHERE user_id=?`, userID); err != nil { return fmt.Errorf("delete user data rpg: %w", err) } if _, err := tx.Exec(`DELETE FROM user_settings WHERE user_id=?`, userID); err != nil { return fmt.Errorf("delete user data settings: %w", err) } if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil { return fmt.Errorf("delete user data users: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("delete user data commit: %w", err) } return nil } func (s *Store) Close() error { return s.db.Close() } 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))] } func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) { if _, err := s.db.Exec( "INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)", chatID, randomUsername(), ); err != nil { return nil, fmt.Errorf("get or create user: %w", err) } return s.GetUserByChatID(chatID) } func (s *Store) GetUserByChatID(chatID int64) (*domain.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 domain.User if err := row.Scan( &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, fmt.Errorf("get user by chat id: %w", err) } return &u, nil } func (s *Store) UpdateUser(user *domain.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, ) if err != nil { return fmt.Errorf("update user: %w", err) } return nil } func (s *Store) GetAllUsers() ([]domain.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 []domain.User for rows.Next() { var u domain.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() } // Settings func (s *Store) GetOrCreateSettings(userID int64) (*domain.UserSettings, error) { if _, err := s.db.Exec( "INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", userID, ); err != nil { return nil, err } return s.getSettings(userID) } func (s *Store) getSettings(userID int64) (*domain.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 domain.UserSettings if 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, ); err != nil { return nil, err } return &st, nil } func (s *Store) UpdateSettings(st *domain.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 } 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) } // RPG Stats func (s *Store) GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error) { if _, err := s.db.Exec( "INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)", userID, ); err != nil { return nil, err } return s.getRPGStats(userID) } func (s *Store) getRPGStats(userID int64) (*domain.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 domain.RPGStats if err := row.Scan( &st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak, &st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt, ); err != nil { return nil, err } return &st, nil } func (s *Store) UpdateRPGStats(st *domain.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 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 } func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error { _, err := s.db.Exec(` INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds) VALUES (?, ?, 0, ?) ON CONFLICT(user_id, date) DO UPDATE SET work_seconds = ? `, userID, date, workSeconds, workSeconds) return err } func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) { var total int64 err := s.db.QueryRow( "SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID, ).Scan(&total) return total, err } func (s *Store) GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error) { rows, err := s.db.Query( `SELECT id, user_id, date, xp_earned, work_seconds, created_at FROM daily_xp WHERE user_id=? AND date >= ? AND date <= ? ORDER BY date`, userID, start, end, ) if err != nil { return nil, err } defer rows.Close() var result []domain.DailyXP for rows.Next() { var x domain.DailyXP if err := rows.Scan(&x.ID, &x.UserID, &x.Date, &x.XPEarned, &x.WorkSeconds, &x.CreatedAt); err != nil { return nil, err } result = append(result, x) } return result, rows.Err() } func (s *Store) SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error) { // Each day has a current_work_type_id. Daily XP work_seconds are per day. // Join with days to get the day's primary work type, then sum by type. rows, err := s.db.Query(` SELECT COALESCE(d.current_work_type_id, 1), COALESCE(SUM(dx.work_seconds), 0) FROM daily_xp dx JOIN days d ON d.user_id = dx.user_id AND d.date = dx.date WHERE dx.user_id = ? AND dx.work_seconds > 0 GROUP BY d.current_work_type_id `, userID) if err != nil { return 0, 0, err } defer rows.Close() for rows.Next() { var wtID int64 var secs int64 if err := rows.Scan(&wtID, &secs); err != nil { return 0, 0, err } switch wtID { case 2: remoteSeconds += secs default: onsiteSeconds += secs } } return remoteSeconds, onsiteSeconds, rows.Err() } // Achievements func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) { var a domain.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 } func (s *Store) GetUserAchievements(userID int64) ([]domain.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 []domain.UserAchievement for rows.Next() { var ua domain.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() } 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 } 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 func (s *Store) GetLeagueRankings(orderBy string) ([]domain.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 []domain.LeagueEntry for rows.Next() { var e domain.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() } // Work Types func (s *Store) GetWorkTypes() ([]domain.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 []domain.WorkType for rows.Next() { var wt domain.WorkType if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil { return nil, err } wts = append(wts, wt) } return wts, rows.Err() } func (s *Store) GetWorkType(id int64) (*domain.WorkType, error) { var wt domain.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 } // Days func (s *Store) GetOrCreateDay(userID int64, date string) (*domain.Day, error) { if _, err := s.db.Exec( "INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date, ); err != nil { return nil, err } return s.GetDay(userID, date) } func (s *Store) GetDay(userID int64, date string) (*domain.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 domain.Day if err := row.Scan( &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, ); err != nil { return nil, err } return &d, nil } func (s *Store) UpdateDay(day *domain.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 } func (s *Store) SetDayOff(userID int64, date string, reason string) error { if _, err := s.GetOrCreateDay(userID, date); 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 } 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 } func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]domain.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 []domain.Day for rows.Next() { var d domain.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() } func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) { rows, err := s.db.Query(` SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id = ? ORDER BY d.date ASC `, userID) if err != nil { return nil, err } defer rows.Close() var dates []string for rows.Next() { var date string if err := rows.Scan(&date); err != nil { return nil, err } dates = append(dates, date) } return dates, rows.Err() } func (s *Store) GetDistinctEventDates(userID int64, startDate, endDate string) ([]string, error) { rows, err := s.db.Query( "SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id=? AND d.date >= ? AND d.date <= ? ORDER BY d.date", userID, startDate, endDate, ) if err != nil { return nil, err } defer rows.Close() var dates []string for rows.Next() { var d string if err := rows.Scan(&d); err != nil { return nil, err } dates = append(dates, d) } return dates, rows.Err() } // Events 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 } func (s *Store) UpdateEventWorkType(eventID, workTypeID int64) error { _, err := s.db.Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) return err } func (s *Store) UpdateEventTime(eventID, occurredAt int64) error { _, err := s.db.Exec("UPDATE events SET occurred_at=? WHERE id=?", occurredAt, eventID) return err } func (s *Store) DeleteEvent(eventID int64) error { _, err := s.db.Exec("DELETE FROM events WHERE id=?", eventID) return err } func (s *Store) GetEventByID(eventID, userID int64) (*domain.Event, error) { row := s.db.QueryRow( "SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?", eventID, userID, ) var e domain.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 } func (s *Store) GetLastEvent(userID int64) (*domain.Event, error) { row := s.db.QueryRow(` 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) var e domain.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 { if errors.Is(err, sql.ErrNoRows) { return nil, sql.ErrNoRows } return nil, err } if wt.Valid { e.WorkTypeID = &wt.Int64 } return &e, nil } func (s *Store) EventsForDayByDate(userID int64, date string) ([]domain.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 []domain.Event for rows.Next() { var e domain.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() } func (s *Store) EventsForDayByDayID(dayID int64) ([]domain.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 []domain.Event for rows.Next() { var e domain.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() } func (s *Store) SetEventNote(eventID int64, note string) error { _, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID) return err }