Files
worktimeBot/internal/db/store.go

662 lines
19 KiB
Go

// 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
}
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, &notnull, &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, &note, &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)
}
}
}
}
// 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 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()
}