- Add chat_id to events, settings, and days_off for per-user data - Add proper graceful shutdown (signal handling, WaitGroup) - Separate polling and webhook modes with goroutine management - Add schema migration from single-user to multi-user schema - Refactor handlers with shared actionFunc pattern (msg + callback) - Fix schema path for Docker deployment (/app/db/schema.sql) - Remove emoji from output text for cleaner formatting
180 lines
4.1 KiB
Go
180 lines
4.1 KiB
Go
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
|
|
}
|