package localdb import ( "errors" "fmt" "log/slog" "net" "os" "path/filepath" "strconv" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/appmeta" "github.com/glebarez/sqlite" mysqlDriver "github.com/go-sql-driver/mysql" uuidpkg "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" "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{}, &LocalProject{}, &LocalConfiguration{}, &LocalConfigurationVersion{}, &LocalPricelist{}, &LocalPricelistItem{}, &LocalComponent{}, &AppSetting{}, &PendingChange{}, ); err != nil { return nil, fmt.Errorf("migrating sqlite database: %w", err) } if err := runLocalMigrations(db); err != nil { return nil, fmt.Errorf("running local sqlite migrations: %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 } cfg := mysqlDriver.NewConfig() cfg.User = settings.User cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings cfg.Net = "tcp" cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port)) cfg.DBName = settings.Database cfg.ParseTime = true cfg.Loc = time.Local // Add aggressive timeouts for offline-first architecture. cfg.Timeout = 3 * time.Second cfg.ReadTimeout = 3 * time.Second cfg.WriteTimeout = 3 * time.Second cfg.Params = map[string]string{ "charset": "utf8mb4", } return cfg.FormatDSN(), 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 // Project methods func (l *LocalDB) SaveProject(project *LocalProject) error { return l.db.Save(project).Error } func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) { var projects []LocalProject query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername) if !includeArchived { query = query.Where("is_active = ?", true) } err := query.Order("created_at DESC").Find(&projects).Error return projects, err } func (l *LocalDB) GetAllProjects(includeArchived bool) ([]LocalProject, error) { var projects []LocalProject query := l.db.Model(&LocalProject{}) if !includeArchived { query = query.Where("is_active = ?", true) } err := query.Order("created_at DESC").Find(&projects).Error return projects, err } func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) { var project LocalProject if err := l.db.Where("uuid = ?", uuid).First(&project).Error; err != nil { return nil, err } return &project, nil } func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) { var project LocalProject if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil { return nil, err } return &project, nil } func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) { var configs []LocalConfiguration err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true). Order("created_at DESC"). Find(&configs).Error return configs, err } func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, error) { project := &LocalProject{} err := l.db. Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true). Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC"). First(project).Error if err == nil { return project, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } now := time.Now() project = &LocalProject{ UUID: uuidpkg.NewString(), OwnerUsername: "", Name: "Без проекта", IsActive: true, IsSystem: true, CreatedAt: now, UpdatedAt: now, SyncStatus: "pending", } if err := l.SaveProject(project); err != nil { return nil, err } return project, nil } // ConsolidateSystemProjects merges all "Без проекта" projects into one shared canonical project. // Configurations are reassigned to canonical UUID, duplicate projects are deleted. func (l *LocalDB) ConsolidateSystemProjects() (int64, error) { var removed int64 err := l.db.Transaction(func(tx *gorm.DB) error { var canonical LocalProject err := tx. Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true). Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC"). First(&canonical).Error if errors.Is(err, gorm.ErrRecordNotFound) { now := time.Now() canonical = LocalProject{ UUID: uuidpkg.NewString(), OwnerUsername: "", Name: "Без проекта", IsActive: true, IsSystem: true, CreatedAt: now, UpdatedAt: now, SyncStatus: "pending", } if err := tx.Create(&canonical).Error; err != nil { return err } } else if err != nil { return err } if err := tx.Model(&LocalProject{}). Where("uuid = ?", canonical.UUID). Updates(map[string]any{ "name": "Без проекта", "is_system": true, "is_active": true, }).Error; err != nil { return err } var duplicates []LocalProject if err := tx.Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND uuid <> ?", "Без проекта", canonical.UUID). Find(&duplicates).Error; err != nil { return err } for i := range duplicates { p := duplicates[i] if err := tx.Model(&LocalConfiguration{}). Where("project_uuid = ?", p.UUID). Update("project_uuid", canonical.UUID).Error; err != nil { return err } // Remove stale pending project events for deleted UUIDs. if err := tx.Where("entity_type = ? AND entity_uuid = ?", "project", p.UUID). Delete(&PendingChange{}).Error; err != nil { return err } res := tx.Where("uuid = ?", p.UUID).Delete(&LocalProject{}) if res.Error != nil { return res.Error } removed += res.RowsAffected } // Backfill orphaned local configurations to canonical project. if err := tx.Model(&LocalConfiguration{}). Where("project_uuid IS NULL OR TRIM(COALESCE(project_uuid, '')) = ''"). Update("project_uuid", canonical.UUID).Error; err != nil { return err } return nil }) return removed, err } // PurgeEmptyNamelessProjects removes service-trash projects that have no linked configurations: // 1) projects with empty names; // 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed). func (l *LocalDB) PurgeEmptyNamelessProjects() (int64, error) { tx := l.db.Exec(` DELETE FROM local_projects WHERE ( TRIM(COALESCE(name, '')) = '' OR LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта') ) AND uuid NOT IN ( SELECT DISTINCT project_uuid FROM local_configurations WHERE project_uuid IS NOT NULL AND project_uuid <> '' )`) return tx.RowsAffected, tx.Error } // BackfillConfigurationProjects ensures every configuration has project_uuid set. // If missing, it assigns system project "Без проекта" for configuration owner. func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error { configs, err := l.GetConfigurations() if err != nil { return err } for i := range configs { cfg := configs[i] if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" { continue } owner := strings.TrimSpace(cfg.OriginalUsername) if owner == "" { owner = strings.TrimSpace(defaultOwner) } if owner == "" { continue } project, err := l.EnsureDefaultProject(owner) if err != nil { return err } cfg.ProjectUUID = &project.UUID if saveErr := l.SaveConfiguration(&cfg); saveErr != nil { return saveErr } } return nil } // 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.DeactivateConfiguration(uuid) } // DeactivateConfiguration marks configuration as inactive and appends one snapshot version. func (l *LocalDB) DeactivateConfiguration(uuid string) error { return l.db.Transaction(func(tx *gorm.DB) error { var cfg LocalConfiguration if err := tx.Where("uuid = ?", uuid).First(&cfg).Error; err != nil { return err } if !cfg.IsActive { return nil } cfg.IsActive = false cfg.UpdatedAt = time.Now() cfg.SyncStatus = "pending" if err := tx.Save(&cfg).Error; err != nil { return fmt.Errorf("save inactive configuration: %w", err) } var maxVersion int if err := tx.Model(&LocalConfigurationVersion{}). Where("configuration_uuid = ?", cfg.UUID). Select("COALESCE(MAX(version_no), 0)"). Scan(&maxVersion).Error; err != nil { return fmt.Errorf("read max version for deactivate: %w", err) } snapshot, err := BuildConfigurationSnapshot(&cfg) if err != nil { return fmt.Errorf("build deactivate snapshot: %w", err) } note := "deactivate via local delete" version := &LocalConfigurationVersion{ ID: uuidpkg.NewString(), ConfigurationUUID: cfg.UUID, VersionNo: maxVersion + 1, Data: snapshot, ChangeNote: ¬e, AppVersion: appmeta.Version(), CreatedAt: time.Now(), } if err := tx.Create(version).Error; err != nil { return fmt.Errorf("insert deactivate version: %w", err) } if err := tx.Model(&LocalConfiguration{}). Where("uuid = ?", cfg.UUID). Update("current_version_id", version.ID).Error; err != nil { return fmt.Errorf("set current version after deactivate: %w", err) } return nil }) } // 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 } // GetLocalPricelistByVersion returns a local pricelist by version string. func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) { var pricelist LocalPricelist if err := l.db.Where("version = ?", version).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.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "server_id"}}, DoUpdates: clause.Assignments(map[string]interface{}{ "version": pricelist.Version, "name": pricelist.Name, "created_at": pricelist.CreatedAt, "synced_at": pricelist.SyncedAt, "is_used": pricelist.IsUsed, }), }).Create(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 } // RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations. func (l *LocalDB) RecalculateAllLocalPricelistUsage() error { return l.db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil { return err } return tx.Exec(` UPDATE local_pricelists SET is_used = 1 WHERE server_id IN ( SELECT DISTINCT pricelist_id FROM local_configurations WHERE pricelist_id IS NOT NULL AND is_active = 1 ) `).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 } // CountErroredChanges returns the number of pending changes with errors func (l *LocalDB) CountErroredChanges() int64 { var count int64 l.db.Model(&PendingChange{}).Where("last_error != ?", "").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 } // PurgeOrphanConfigurationPendingChanges removes configuration pending changes // whose entity_uuid no longer exists in local_configurations. func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) { tx := l.db.Where( "entity_type = ? AND entity_uuid NOT IN (SELECT uuid FROM local_configurations)", "configuration", ).Delete(&PendingChange{}) return tx.RowsAffected, tx.Error } // GetPendingCount returns the total number of pending changes (alias for CountPendingChanges) func (l *LocalDB) GetPendingCount() int64 { return l.CountPendingChanges() }