package localdb import ( "fmt" "log/slog" "os" "path/filepath" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) // ConnectionSettings stores MariaDB connection credentials type ConnectionSettings struct { ID uint `gorm:"primaryKey"` Host string `gorm:"not null"` Port int `gorm:"not null;default:3306"` Database string `gorm:"not null"` User string `gorm:"not null"` PasswordEncrypted string `gorm:"not null"` // AES encrypted UpdatedAt time.Time `gorm:"autoUpdateTime"` } func (ConnectionSettings) TableName() string { return "connection_settings" } // LocalDB manages the local SQLite database for settings type LocalDB struct { db *gorm.DB path string } // New creates a new LocalDB instance func New(dbPath string) (*LocalDB, error) { // Ensure directory exists dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("creating data directory: %w", err) } db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return nil, fmt.Errorf("opening sqlite database: %w", err) } // Auto-migrate all local tables if err := db.AutoMigrate( &ConnectionSettings{}, &LocalConfiguration{}, &LocalPricelist{}, &LocalPricelistItem{}, &LocalComponent{}, &AppSetting{}, &PendingChange{}, ); err != nil { return nil, fmt.Errorf("migrating sqlite database: %w", err) } slog.Info("local SQLite database initialized", "path", dbPath) return &LocalDB{ db: db, path: dbPath, }, nil } // HasSettings returns true if connection settings exist func (l *LocalDB) HasSettings() bool { var count int64 l.db.Model(&ConnectionSettings{}).Count(&count) return count > 0 } // GetSettings retrieves the connection settings with decrypted password func (l *LocalDB) GetSettings() (*ConnectionSettings, error) { var settings ConnectionSettings if err := l.db.First(&settings).Error; err != nil { return nil, fmt.Errorf("getting settings: %w", err) } // Decrypt password password, err := Decrypt(settings.PasswordEncrypted) if err != nil { return nil, fmt.Errorf("decrypting password: %w", err) } settings.PasswordEncrypted = password // Return decrypted password in this field return &settings, nil } // SaveSettings saves connection settings with encrypted password func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error { // Encrypt password encrypted, err := Encrypt(password) if err != nil { return fmt.Errorf("encrypting password: %w", err) } settings := ConnectionSettings{ ID: 1, // Always use ID=1 for single settings row Host: host, Port: port, Database: database, User: user, PasswordEncrypted: encrypted, } // Upsert: create or update result := l.db.Save(&settings) if result.Error != nil { return fmt.Errorf("saving settings: %w", result.Error) } slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user) return nil } // DeleteSettings removes all connection settings func (l *LocalDB) DeleteSettings() error { return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error } // GetDSN returns the MariaDB DSN string func (l *LocalDB) GetDSN() (string, error) { settings, err := l.GetSettings() if err != nil { return "", err } dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", settings.User, settings.PasswordEncrypted, // Contains decrypted password after GetSettings settings.Host, settings.Port, settings.Database, ) return dsn, nil } // DB returns the underlying gorm.DB for advanced operations func (l *LocalDB) DB() *gorm.DB { return l.db } // Close closes the database connection func (l *LocalDB) Close() error { sqlDB, err := l.db.DB() if err != nil { return err } return sqlDB.Close() } // GetDBUser returns the database username from settings func (l *LocalDB) GetDBUser() string { settings, err := l.GetSettings() if err != nil { return "" } return settings.User } // Configuration methods // SaveConfiguration saves a configuration to local SQLite func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error { return l.db.Save(config).Error } // GetConfigurations returns all local configurations func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) { var configs []LocalConfiguration err := l.db.Order("created_at DESC").Find(&configs).Error return configs, err } // GetConfigurationByUUID returns a configuration by UUID func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) { var config LocalConfiguration err := l.db.Where("uuid = ?", uuid).First(&config).Error return &config, err } // DeleteConfiguration deletes a configuration by UUID func (l *LocalDB) DeleteConfiguration(uuid string) error { return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error } // CountConfigurations returns the number of local configurations func (l *LocalDB) CountConfigurations() int64 { var count int64 l.db.Model(&LocalConfiguration{}).Count(&count) return count } // Pricelist methods // GetLastSyncTime returns the last sync timestamp func (l *LocalDB) GetLastSyncTime() *time.Time { var setting struct { Value string } if err := l.db.Table("app_settings"). Where("key = ?", "last_pricelist_sync"). First(&setting).Error; err != nil { return nil } t, err := time.Parse(time.RFC3339, setting.Value) if err != nil { return nil } return &t } // SetLastSyncTime sets the last sync timestamp func (l *LocalDB) SetLastSyncTime(t time.Time) error { // Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions return l.db.Exec(` INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error } // CountLocalPricelists returns the number of local pricelists func (l *LocalDB) CountLocalPricelists() int64 { var count int64 l.db.Model(&LocalPricelist{}).Count(&count) return count } // GetLatestLocalPricelist returns the most recently synced pricelist func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { var pricelist LocalPricelist if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil { return nil, err } return &pricelist, nil } // GetLocalPricelistByServerID returns a local pricelist by its server ID func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) { var pricelist LocalPricelist if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil { return nil, err } return &pricelist, nil } // GetLocalPricelistByID returns a local pricelist by its local ID func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) { var pricelist LocalPricelist if err := l.db.First(&pricelist, id).Error; err != nil { return nil, err } return &pricelist, nil } // SaveLocalPricelist saves a pricelist to local SQLite func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error { return l.db.Save(pricelist).Error } // GetLocalPricelists returns all local pricelists func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) { var pricelists []LocalPricelist if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil { return nil, err } return pricelists, nil } // CountLocalPricelistItems returns the number of items for a pricelist func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 { var count int64 l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count) return count } // SaveLocalPricelistItems saves pricelist items to local SQLite func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error { if len(items) == 0 { return nil } // Batch insert batchSize := 500 for i := 0; i < len(items); i += batchSize { end := i + batchSize if end > len(items) { end = len(items) } if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil { return err } } return nil } // GetLocalPricelistItems returns items for a local pricelist func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) { var items []LocalPricelistItem if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil { return nil, err } return items, nil } // GetLocalPriceForLot returns the price for a lot from a local pricelist func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) { var item LocalPricelistItem if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName). First(&item).Error; err != nil { return 0, err } return item.Price, nil } // MarkPricelistAsUsed marks a pricelist as used by a configuration func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error { return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID). Update("is_used", isUsed).Error } // DeleteLocalPricelist deletes a pricelist and its items func (l *LocalDB) DeleteLocalPricelist(id uint) error { // Delete items first if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil { return err } // Delete pricelist return l.db.Delete(&LocalPricelist{}, id).Error } // PendingChange methods // AddPendingChange adds a change to the sync queue func (l *LocalDB) AddPendingChange(entityType, entityUUID, operation, payload string) error { change := PendingChange{ EntityType: entityType, EntityUUID: entityUUID, Operation: operation, Payload: payload, CreatedAt: time.Now(), Attempts: 0, } return l.db.Create(&change).Error } // GetPendingChanges returns all pending changes ordered by creation time func (l *LocalDB) GetPendingChanges() ([]PendingChange, error) { var changes []PendingChange err := l.db.Order("created_at ASC").Find(&changes).Error return changes, err } // GetPendingChangesByEntity returns pending changes for a specific entity func (l *LocalDB) GetPendingChangesByEntity(entityType, entityUUID string) ([]PendingChange, error) { var changes []PendingChange err := l.db.Where("entity_type = ? AND entity_uuid = ?", entityType, entityUUID). Order("created_at ASC").Find(&changes).Error return changes, err } // DeletePendingChange removes a change from the sync queue after successful sync func (l *LocalDB) DeletePendingChange(id int64) error { return l.db.Delete(&PendingChange{}, id).Error } // IncrementPendingChangeAttempts updates the attempt counter and last error func (l *LocalDB) IncrementPendingChangeAttempts(id int64, errorMsg string) error { return l.db.Model(&PendingChange{}).Where("id = ?", id).Updates(map[string]interface{}{ "attempts": gorm.Expr("attempts + 1"), "last_error": errorMsg, }).Error } // CountPendingChanges returns the total number of pending changes func (l *LocalDB) CountPendingChanges() int64 { var count int64 l.db.Model(&PendingChange{}).Count(&count) return count } // CountPendingChangesByType returns the number of pending changes by entity type func (l *LocalDB) CountPendingChangesByType(entityType string) int64 { var count int64 l.db.Model(&PendingChange{}).Where("entity_type = ?", entityType).Count(&count) return count } // MarkChangesSynced marks multiple pending changes as synced by deleting them func (l *LocalDB) MarkChangesSynced(ids []int64) error { if len(ids) == 0 { return nil } return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error } // GetPendingCount returns the total number of pending changes (alias for CountPendingChanges) func (l *LocalDB) GetPendingCount() int64 { return l.CountPendingChanges() }