Files
worktimeBot/internal/repo/store.go
db123 5670b0455d
All checks were successful
CI / build (push) Successful in 37s
feat: add delete account button in settings, implement all remaining achievements
- Add delete all data button with red danger style and confirmation prompt
- Add DeleteUserData repo method that wipes user from all tables in a transaction
- Add helper methods deleteAccountPrompt and deleteAccountConfirm
- Implement consistency_master, perfect_week, remote_veteran, onsite_master achievements
- Add GetDailyXPInRange and SumWorkSecondsByWorkType repo methods
- Update Dockerfile to alpine3.23, docker-compose to use env vars for proxy/paths
2026-06-25 12:27:03 +03:30

691 lines
19 KiB
Go

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, 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
}
func (s *Store) DeleteUserData(userID int64) error {
tx, err := s.db.Begin()
if err != nil {
return 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 err
}
if _, err := tx.Exec(`DELETE FROM days WHERE user_id=?`, userID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM user_achievements WHERE user_id=?`, userID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM daily_xp WHERE user_id=?`, userID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM user_rpg WHERE user_id=?`, userID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM user_settings WHERE user_id=?`, userID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil {
return err
}
return tx.Commit()
}
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, 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, 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,
)
return err
}
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
}