// Package db provides the SQLite-backed data store for users, work types, days, and events. package db import ( "database/sql" "embed" "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 timezone, reporting preferences, and calendar type. type User struct { ID int64 ChatID int64 Timezone string IsActive bool ReportEnabled bool ReportTime string LastReportDate *string ExportAccent string Calendar string CreatedAt int64 UpdatedAt 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 } db.Exec("PRAGMA journal_mode=WAL") 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 db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists) 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 { return false } defer rows.Close() 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 { 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) { db.Exec("DROP TABLE IF EXISTS " + t) } } 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: no events table to migrate users from") return } defer rows.Close() for rows.Next() { var chatID int64 if err := rows.Scan(&chatID); err != nil { continue } db.Exec( "INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')", chatID, ) } } 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 { return } defer rows.Close() for rows.Next() { var chatID int64 var date, reason string if err := rows.Scan(&chatID, &date, &reason); err != nil { continue } var userID int64 err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) if err != nil { continue } 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, ) } } 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 { return } defer rows.Close() 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 { continue } var userID int64 err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) if err != nil { continue } date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02") db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date) var dayID int64 db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID) db.Exec( "INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)", userID, dayID, eventType, occurredAt, note, createdAt, ) } } 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 { return } defer rows.Close() for rows.Next() { var chatID int64 var key, value string if err := rows.Scan(&chatID, &key, &value); err != nil { continue } if key == "remote_flag" { var userID int64 err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) if err != nil { continue } wt := int64(1) if value == "remote" { wt = 2 } db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02")) } } } // 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) VALUES (?)", chatID, ) 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, timezone, is_active, report_enabled, report_time, last_report_date, export_accent, calendar, created_at, updated_at FROM users WHERE chat_id = ? `, chatID) var u User var lastReport sql.NullString err := row.Scan( &u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, &lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { return nil, err } if lastReport.Valid { u.LastReportDate = &lastReport.String } return &u, nil } // UpdateUser persists all fields of the given user record. func (s *Store) UpdateUser(user *User) error { _, err := s.db.Exec( "UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, calendar=?, updated_at=unixepoch() WHERE id=?", user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID, ) return err } // MarkReportSent records the last report date for a user to prevent duplicate daily reports. func (s *Store) MarkReportSent(userID int64, date string) error { _, err := s.db.Exec( "UPDATE users SET last_report_date=?, updated_at=unixepoch() WHERE id=?", date, userID, ) return err } // GetAllUsers returns all users in the database. func (s *Store) GetAllUsers() ([]User, error) { rows, err := s.db.Query(` SELECT id, chat_id, timezone, is_active, report_enabled, report_time, last_report_date, export_accent, 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 var lastReport sql.NullString if err := rows.Scan( &u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, &lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, err } if lastReport.Valid { u.LastReportDate = &lastReport.String } users = append(users, u) } return users, 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 row.Close() 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() }