package db import ( "database/sql" "log/slog" "os" _ "modernc.org/sqlite" ) const driverName = "sqlite" var schemaPath = "/app/db/schema.sql" 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 := migrate(db); err != nil { return nil, err } if err := execSchema(db, schemaPath); err != nil { return nil, err } return &Store{db: db}, nil } func (s *Store) Close() error { return s.db.Close() } func execSchema(db *sql.DB, path string) error { data, err := os.ReadFile(path) if err != nil { return err } _, err = db.Exec(string(data)) return err } func migrate(db *sql.DB) error { if hasColumn(db, "events", "chat_id") { return nil } slog.Info("migrating database to per-chat schema") for _, t := range []string{"days_off", "settings", "events"} { db.Exec("DROP TABLE IF EXISTS " + t) } return execSchema(db, schemaPath) } func hasColumn(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 (s *Store) CreateEvent(chatID int64, eventType string, occurredAt int64, note string) error { _, err := s.db.Exec( "INSERT INTO events (chat_id, event_type, occurred_at, note) VALUES (?, ?, ?, ?)", chatID, eventType, occurredAt, note, ) return err } func (s *Store) GetLastEvent(chatID int64) (*Event, error) { row, err := s.db.Query(` SELECT id, chat_id, event_type, occurred_at, note, created_at FROM events WHERE chat_id = ? ORDER BY occurred_at DESC LIMIT 1 `, chatID) if err != nil { return nil, err } defer row.Close() if !row.Next() { return nil, sql.ErrNoRows } var e Event if err := row.Scan(&e.ID, &e.ChatID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } return &e, nil } func (s *Store) GetSetting(chatID int64, key string) (string, error) { var value string err := s.db.QueryRow("SELECT value FROM settings WHERE chat_id = ? AND key = ?", chatID, key).Scan(&value) if err != nil { return "", err } return value, nil } func (s *Store) SetSetting(chatID int64, key, value string) error { _, err := s.db.Exec(` INSERT INTO settings (chat_id, key, value) VALUES (?, ?, ?) ON CONFLICT(chat_id, key) DO UPDATE SET value=excluded.value `, chatID, key, value) return err } func (s *Store) SetDayOff(chatID int64, date, reason string) error { _, err := s.db.Exec(` INSERT INTO days_off (chat_id, date, reason) VALUES (?, ?, ?) ON CONFLICT(chat_id, date) DO UPDATE SET reason=excluded.reason `, chatID, date, reason) return err } func (s *Store) RemoveDayOff(chatID int64, date string) error { _, err := s.db.Exec("DELETE FROM days_off WHERE chat_id = ? AND date = ?", chatID, date) return err } func (s *Store) IsDayOff(chatID int64, date string) (bool, error) { var exists int err := s.db.QueryRow("SELECT COUNT(1) FROM days_off WHERE chat_id = ? AND date = ?", chatID, date).Scan(&exists) if err != nil { return false, err } return exists > 0, nil } func (s *Store) EventsForDay(chatID int64, day string) ([]Event, error) { rows, err := s.db.Query(` SELECT id, chat_id, event_type, occurred_at, note, created_at FROM events WHERE chat_id = ? AND strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ? ORDER BY occurred_at ASC `, chatID, day) if err != nil { return nil, err } defer rows.Close() var events []Event for rows.Next() { var e Event if err := rows.Scan(&e.ID, &e.ChatID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } events = append(events, e) } return events, rows.Err() } type Event struct { ID int64 ChatID int64 EventType string OccurredAt int64 Note string CreatedAt int64 }