From 4edff71bfd3120aae7214b813b802bafe976fd9e Mon Sep 17 00:00:00 2001 From: db123 Date: Wed, 24 Jun 2026 19:20:48 +0330 Subject: [PATCH] replace manual SQL migration with goose (v3); embed migrations in binary; remove schema.sql / SCHEMA_PATH --- .env.example | 1 - Dockerfile | 1 - README.md | 29 +++++---- docker-compose.yml | 1 - go.mod | 13 ++-- go.sum | 16 +++++ internal/db/migrations/001_init.sql | 59 ++++++++++++++++++ internal/db/store.go | 96 +++++++++++++---------------- 8 files changed, 140 insertions(+), 76 deletions(-) create mode 100644 internal/db/migrations/001_init.sql diff --git a/.env.example b/.env.example index d410649..a2bf9c5 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,6 @@ BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN HTTP_PROXY= HTTPS_PROXY= DB_PATH=db.sqlite3 -SCHEMA_PATH= BOT_WEBHOOK_URL= BOT_LISTEN=:8080 BOT_TLS_CERT= diff --git a/Dockerfile b/Dockerfile index c1ecc32..7a63e0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN apk add --no-cache ca-certificates tzdata WORKDIR /data COPY --from=builder /usr/local/bin/worktime-bot /usr/local/bin/worktime-bot -COPY db/schema.sql /app/db/schema.sql VOLUME ["/data"] CMD ["worktime-bot"] diff --git a/README.md b/README.md index ecd1db7..ef4182d 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,17 @@ docker compose up -d ## Environment Variables -| Variable | Default | Purpose | -| ------------------- | -------------------- | ----------------------------------------------------- | -| `BOT_TOKEN` | — | Telegram Bot API token **(required)** | -| `BOT_ALLOWED_USERS` | (all) | Comma-separated Telegram user IDs (empty = allow all) | -| `HTTP_PROXY` | — | Outbound HTTP proxy for Telegram API | -| `HTTPS_PROXY` | — | Outbound HTTPS proxy for Telegram API | -| `DB_PATH` | `db.sqlite3` | Path to SQLite database file | -| `SCHEMA_PATH` | `/app/db/schema.sql` | Path to SQL schema file | -| `BOT_WEBHOOK_URL` | — | Set for webhook mode (omit for long-polling) | -| `BOT_LISTEN` | `:8080` | Listen address for webhook mode | -| `BOT_TLS_CERT` | — | TLS certificate path (webhook HTTPS) | -| `BOT_TLS_KEY` | — | TLS key path (webhook HTTPS) | +| Variable | Default | Purpose | +| ------------------- | ------------ | ----------------------------------------------------- | +| `BOT_TOKEN` | — | Telegram Bot API token **(required)** | +| `BOT_ALLOWED_USERS` | (all) | Comma-separated Telegram user IDs (empty = allow all) | +| `HTTP_PROXY` | — | Outbound HTTP proxy for Telegram API | +| `HTTPS_PROXY` | — | Outbound HTTPS proxy for Telegram API | +| `DB_PATH` | `db.sqlite3` | Path to SQLite database file | +| `BOT_WEBHOOK_URL` | — | Set for webhook mode (omit for long-polling) | +| `BOT_LISTEN` | `:8080` | Listen address for webhook mode | +| `BOT_TLS_CERT` | — | TLS certificate path (webhook HTTPS) | +| `BOT_TLS_KEY` | — | TLS key path (webhook HTTPS) | ## Calendar Support @@ -101,9 +100,9 @@ Four color themes are available in settings: **Ocean**, **Beach**, **Rose**, **C │ │ ├── export.go Excel (.xlsx) report generation │ │ └── dateutil.go Gregorian/Jalali/Hijri conversions & calendars │ └── db/ -│ └── store.go SQLite store with auto-migration -├── db/ -│ └── schema.sql Database schema +│ ├── store.go SQLite store +│ └── migrations/ Goose-managed SQL migrations +│ └── 001_init.sql Initial schema ├── .gitea/workflows/ │ └── ci.yml Gitea Actions CI ├── Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 2d8bac8..0f1b259 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,6 @@ services: - HTTP_PROXY=${HTTP_PROXY-} - HTTPS_PROXY=${HTTPS_PROXY-} - DB_PATH=/data/db.sqlite3 - - SCHEMA_PATH=${SCHEMA_PATH:-/app/db/schema.sql} - TZ=Asia/Tehran volumes: - worktime_data:/data diff --git a/go.mod b/go.mod index 4c3d762..bbf03ce 100644 --- a/go.mod +++ b/go.mod @@ -12,18 +12,23 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pressly/goose/v3 v3.27.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.36.0 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 91b3a3d..b1bd26b 100644 --- a/go.sum +++ b/go.sum @@ -14,16 +14,24 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= @@ -34,14 +42,20 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -49,6 +63,8 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/db/migrations/001_init.sql b/internal/db/migrations/001_init.sql new file mode 100644 index 0000000..940b631 --- /dev/null +++ b/internal/db/migrations/001_init.sql @@ -0,0 +1,59 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS work_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +INSERT OR IGNORE INTO work_types (id, name) VALUES (1, 'onsite'); +INSERT OR IGNORE INTO work_types (id, name) VALUES (2, 'remote'); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL UNIQUE, + timezone TEXT NOT NULL DEFAULT 'Asia/Tehran', + is_active INTEGER NOT NULL DEFAULT 1, + report_enabled INTEGER NOT NULL DEFAULT 1, + report_time TEXT NOT NULL DEFAULT '23:00', + last_report_date TEXT DEFAULT NULL, + export_accent TEXT NOT NULL DEFAULT 'ocean' + CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')), + calendar TEXT NOT NULL DEFAULT 'gregorian' + CHECK (calendar IN ('gregorian', 'jalali', 'hijri')), + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS days ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)), + is_day_off INTEGER NOT NULL DEFAULT 0, + day_off_reason TEXT NOT NULL DEFAULT '', + current_work_type_id INTEGER NOT NULL DEFAULT 1 REFERENCES work_types(id), + min_break_threshold INTEGER NOT NULL DEFAULT 300, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(user_id, date) +); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + day_id INTEGER NOT NULL REFERENCES days(id), + event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')), + work_type_id INTEGER REFERENCES work_types(id), + occurred_at INTEGER NOT NULL CHECK (occurred_at > 0), + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id); +CREATE INDEX IF NOT EXISTS idx_events_user_occurred ON events(user_id, occurred_at); +CREATE INDEX IF NOT EXISTS idx_days_user_date ON days(user_id, date); + +-- +goose Down +DROP TABLE IF EXISTS events; +DROP TABLE IF EXISTS days; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS work_types; diff --git a/internal/db/store.go b/internal/db/store.go index cc87ebe..bab7918 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -3,23 +3,18 @@ package db import ( "database/sql" + "embed" "log/slog" - "os" "time" + "github.com/pressly/goose/v3" _ "modernc.org/sqlite" ) const driverName = "sqlite" -var schemaPath string - -func init() { - schemaPath = os.Getenv("SCHEMA_PATH") - if schemaPath == "" { - schemaPath = "/app/db/schema.sql" - } -} +//go:embed migrations/*.sql +var migrationFS embed.FS // Store wraps a SQLite database connection and provides methods for all data access. type Store struct { @@ -83,10 +78,7 @@ func NewStore(path string) (*Store, error) { return nil, err } db.Exec("PRAGMA journal_mode=WAL") - if err := migrate(db); err != nil { - return nil, err - } - if err := execSchema(db, schemaPath); err != nil { + if err := runMigrations(db); err != nil { return nil, err } return &Store{db: db}, nil @@ -102,22 +94,49 @@ func (s *Store) DB() *sql.DB { return s.db } -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 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 } -func hasColumn(db *sql.DB, table, column string) bool { +// 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 @@ -138,37 +157,6 @@ func hasColumn(db *sql.DB, table, column string) bool { return false } -func migrate(db *sql.DB) error { - if hasTable(db, "users") { - if !hasColumn(db, "users", "calendar") { - slog.Info("migrating: adding calendar column to users") - db.Exec("ALTER TABLE users ADD COLUMN calendar TEXT NOT NULL DEFAULT 'gregorian' CHECK (calendar IN ('gregorian', 'jalali', 'hijri'))") - } - } - if hasTable(db, "events_old") || hasTable(db, "events") && !hasColumn(db, "events", "user_id") { - slog.Info("migrating from old schema to new schema") - if err := migrateFromOldSchema(db); err != nil { - return err - } - } - return nil -} - -func migrateFromOldSchema(db *sql.DB) error { - // Rename old tables so CREATE TABLE IF NOT EXISTS can create new ones - for _, t := range []string{"events", "days_off", "settings"} { - if hasTable(db, t) { - db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old") - } - } - - if err := execSchema(db, schemaPath); err != nil { - return err - } - - return migrateFromOldSchemaData(db) -} - func migrateFromOldSchemaData(db *sql.DB) error { migrateUsers(db) migrateDaysOff(db)