feat: multi-user isolation and graceful shutdown
- 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
This commit is contained in:
@@ -2,22 +2,20 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const driverName = "sqlite"
|
||||
|
||||
var schemaPath = filepath.Join("db", "schema.sql")
|
||||
var schemaPath = "/app/db/schema.sql"
|
||||
|
||||
// Store wraps the sql.DB connection.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore opens the SQLite DB and runs the schema file.
|
||||
func NewStore(path string) (*Store, error) {
|
||||
db, err := sql.Open(driverName, path)
|
||||
if err != nil {
|
||||
@@ -26,12 +24,19 @@ func NewStore(path string) (*Store, error) {
|
||||
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 {
|
||||
@@ -41,24 +46,54 @@ func execSchema(db *sql.DB, path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateEvent inserts a new event.
|
||||
func (s *Store) CreateEvent(eventType string, occurredAt int64, note string) error {
|
||||
query := `
|
||||
INSERT INTO events (event_type, occurred_at, note)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
_, err := s.db.Exec(query, eventType, occurredAt, note)
|
||||
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
|
||||
}
|
||||
|
||||
// GetLastEvent returns the most recent event.
|
||||
func (s *Store) GetLastEvent() (*Event, error) {
|
||||
func (s *Store) GetLastEvent(chatID int64) (*Event, error) {
|
||||
row, err := s.db.Query(`
|
||||
SELECT id, event_type, occurred_at, note, created_at
|
||||
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
|
||||
}
|
||||
@@ -67,66 +102,58 @@ func (s *Store) GetLastEvent() (*Event, error) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
var e Event
|
||||
if err := row.Scan(&e.ID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
|
||||
if err := row.Scan(&e.ID, &e.ChatID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// GetSetting returns a setting value by key.
|
||||
func (s *Store) GetSetting(key string) (string, error) {
|
||||
func (s *Store) GetSetting(chatID int64, key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
|
||||
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
|
||||
}
|
||||
|
||||
// SetSetting stores or updates a setting.
|
||||
func (s *Store) SetSetting(key, value string) error {
|
||||
query := `
|
||||
INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
||||
`
|
||||
_, err := s.db.Exec(query, key, value)
|
||||
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
|
||||
}
|
||||
|
||||
// SetDayOff adds or updates a day‑off record.
|
||||
func (s *Store) SetDayOff(date, reason string) error {
|
||||
query := `
|
||||
INSERT INTO days_off (date, reason) VALUES (?, ?)
|
||||
ON CONFLICT(date) DO UPDATE SET reason=excluded.reason
|
||||
`
|
||||
_, err := s.db.Exec(query, date, reason)
|
||||
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
|
||||
}
|
||||
|
||||
// RemoveDayOff deletes a day‑off record.
|
||||
func (s *Store) RemoveDayOff(date string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM days_off WHERE date = ?`, date)
|
||||
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
|
||||
}
|
||||
|
||||
// IsDayOff returns true if the given date (YYYY-MM-DD) is marked as day off.
|
||||
func (s *Store) IsDayOff(date string) (bool, error) {
|
||||
func (s *Store) IsDayOff(chatID int64, date string) (bool, error) {
|
||||
var exists int
|
||||
err := s.db.QueryRow(`SELECT COUNT(1) FROM days_off WHERE date = ?`, date).Scan(&exists)
|
||||
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
|
||||
}
|
||||
|
||||
// EventsForDay returns all events for a given day (ISO date string).
|
||||
func (s *Store) EventsForDay(day string) ([]Event, error) {
|
||||
func (s *Store) EventsForDay(chatID int64, day string) ([]Event, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, event_type, occurred_at, note, created_at
|
||||
SELECT id, chat_id, event_type, occurred_at, note, created_at
|
||||
FROM events
|
||||
WHERE strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ?
|
||||
WHERE chat_id = ? AND strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ?
|
||||
ORDER BY occurred_at ASC
|
||||
`, day)
|
||||
`, chatID, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -134,7 +161,7 @@ func (s *Store) EventsForDay(day string) ([]Event, error) {
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(&e.ID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
|
||||
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)
|
||||
@@ -142,11 +169,11 @@ func (s *Store) EventsForDay(day string) ([]Event, error) {
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// Event represents a row from the events table.
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ID int64
|
||||
ChatID int64
|
||||
EventType string
|
||||
OccurredAt int64
|
||||
Note string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user