From 548a256d0447ed5d0fd75abafd789872e6e9d457 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 5 Feb 2026 15:07:23 +0300 Subject: [PATCH] Drop qt_users dependency for configs and track app version --- cmd/migrate/main.go | 20 +++++++------- cmd/qfs/main.go | 2 ++ internal/appmeta/version.go | 26 +++++++++++++++++++ internal/localdb/converters.go | 14 ++++++++-- internal/localdb/localdb.go | 2 ++ internal/localdb/migrations.go | 1 + internal/localdb/models.go | 1 + internal/models/configuration.go | 3 ++- internal/repository/configuration.go | 13 ++++------ internal/services/local_configuration.go | 3 +++ internal/services/sync/service.go | 14 ++++------ .../006_add_local_configuration_versions.sql | 6 ++++- ...07_detach_configurations_from_qt_users.sql | 25 ++++++++++++++++++ .../008_add_app_version_to_configurations.sql | 4 +++ 14 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 internal/appmeta/version.go create mode 100644 migrations/007_detach_configurations_from_qt_users.sql create mode 100644 migrations/008_add_app_version_to_configurations.sql diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index f45284a..40a1611 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -66,7 +66,7 @@ func main() { // Get all configurations from MariaDB var configs []models.Configuration - if err := mariaDB.Preload("User").Find(&configs).Error; err != nil { + if err := mariaDB.Find(&configs).Error; err != nil { log.Fatalf("Failed to fetch configurations: %v", err) } @@ -74,13 +74,10 @@ func main() { localCount := local.CountConfigurations() log.Printf("Found %d configurations in local SQLite", localCount) - if *dryRun { + if *dryRun { log.Println("\n[DRY RUN] Would migrate the following configurations:") for _, c := range configs { userName := c.OwnerUsername - if userName == "" && c.User != nil { - userName = c.User.Username - } if userName == "" { userName = "unknown" } @@ -131,14 +128,10 @@ func main() { UpdatedAt: now, SyncedAt: &now, SyncStatus: "synced", - OriginalUserID: c.UserID, + OriginalUserID: derefUint(c.UserID), OriginalUsername: c.OwnerUsername, } - if localConfig.OriginalUsername == "" && c.User != nil { - localConfig.OriginalUsername = c.User.Username - } - if err := local.SaveConfiguration(localConfig); err != nil { log.Printf(" ERROR: %s - %v", c.Name, err) errors++ @@ -173,3 +166,10 @@ func main() { fmt.Println("\nDone! You can now run the server with: go run ./cmd/server") } + +func derefUint(v *uint) uint { + if v == nil { + return 0 + } + return *v +} diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index a54869b..2a5412b 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -19,6 +19,7 @@ import ( qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/appstate" + "git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/handlers" @@ -55,6 +56,7 @@ func main() { exePath, _ := os.Executable() slog.Info("starting qfs", "version", Version, "executable", exePath) + appmeta.SetVersion(Version) resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath) if err != nil { diff --git a/internal/appmeta/version.go b/internal/appmeta/version.go new file mode 100644 index 0000000..b94cff7 --- /dev/null +++ b/internal/appmeta/version.go @@ -0,0 +1,26 @@ +package appmeta + +import "sync/atomic" + +var appVersion atomic.Value + +func init() { + appVersion.Store("dev") +} + +// SetVersion configures the running application version string. +func SetVersion(v string) { + if v == "" { + v = "dev" + } + appVersion.Store(v) +} + +// Version returns the running application version string. +func Version() string { + if v, ok := appVersion.Load().(string); ok && v != "" { + return v + } + return "dev" +} + diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index 2a9e8c4..6774c8c 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -31,7 +31,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { CreatedAt: cfg.CreatedAt, UpdatedAt: time.Now(), SyncStatus: "pending", - OriginalUserID: cfg.UserID, + OriginalUserID: derefUint(cfg.UserID), OriginalUsername: cfg.OwnerUsername, } @@ -60,7 +60,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration { cfg := &models.Configuration{ UUID: local.UUID, - UserID: local.OriginalUserID, OwnerUsername: local.OriginalUsername, Name: local.Name, Items: items, @@ -76,10 +75,21 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration { if local.ServerID != nil { cfg.ID = *local.ServerID } + if local.OriginalUserID != 0 { + userID := local.OriginalUserID + cfg.UserID = &userID + } return cfg } +func derefUint(v *uint) uint { + if v == nil { + return 0 + } + return *v +} + // PricelistToLocal converts models.Pricelist to LocalPricelist func PricelistToLocal(pl *models.Pricelist) *LocalPricelist { name := pl.Notification diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 7bb469c..3a36271 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "git.mchus.pro/mchus/quoteforge/internal/appmeta" "github.com/glebarez/sqlite" uuidpkg "github.com/google/uuid" "gorm.io/gorm" @@ -238,6 +239,7 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error { VersionNo: maxVersion + 1, Data: snapshot, ChangeNote: ¬e, + AppVersion: appmeta.Version(), CreatedAt: time.Now(), } if err := tx.Create(version).Error; err != nil { diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index 6271272..3f13a46 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -103,6 +103,7 @@ func backfillConfigurationVersions(tx *gorm.DB) error { VersionNo: 1, Data: snapshot, ChangeNote: ¬e, + AppVersion: "backfill", CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()), } if err := tx.Create(&version).Error; err != nil { diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 52f6d07..e02e63f 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -94,6 +94,7 @@ type LocalConfigurationVersion struct { Data string `gorm:"type:text;not null" json:"data"` ChangeNote *string `json:"change_note,omitempty"` CreatedBy *string `json:"created_by,omitempty"` + AppVersion string `gorm:"size:64" json:"app_version,omitempty"` CreatedAt time.Time `gorm:"not null;autoCreateTime;index:idx_lcv_config_created,sort:desc,priority:2" json:"created_at"` Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"` } diff --git a/internal/models/configuration.go b/internal/models/configuration.go index 305f4e2..6948f76 100644 --- a/internal/models/configuration.go +++ b/internal/models/configuration.go @@ -42,8 +42,9 @@ func (c ConfigItems) Total() float64 { type Configuration struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` - UserID uint `gorm:"not null" json:"user_id"` // Legacy owner field (kept for backward compatibility) + UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"` + AppVersion string `gorm:"size:64" json:"app_version,omitempty"` Name string `gorm:"size:200;not null" json:"name"` Items ConfigItems `gorm:"type:json;not null" json:"items"` TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` diff --git a/internal/repository/configuration.go b/internal/repository/configuration.go index 5435d4b..dc4bc94 100644 --- a/internal/repository/configuration.go +++ b/internal/repository/configuration.go @@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error { func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) { var config models.Configuration - err := r.db.Preload("User").First(&config, id).Error + err := r.db.First(&config, id).Error if err != nil { return nil, err } @@ -28,7 +28,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) { var config models.Configuration - err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error + err := r.db.Where("uuid = ?", uuid).First(&config).Error if err != nil { return nil, err } @@ -47,12 +47,11 @@ func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit var configs []models.Configuration var total int64 - ownerScope := "owner_username = ? OR (COALESCE(owner_username, '') = '' AND user_id IN (SELECT id FROM qt_users WHERE username = ?))" + ownerScope := "owner_username = ?" - r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername, ownerUsername).Count(&total) + r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total) err := r.db. - Preload("User"). - Where(ownerScope, ownerUsername, ownerUsername). + Where(ownerScope, ownerUsername). Order("created_at DESC"). Offset(offset). Limit(limit). @@ -67,7 +66,6 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total) err := r.db. - Preload("User"). Where("is_template = ?", true). Order("created_at DESC"). Offset(offset). @@ -84,7 +82,6 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura r.db.Model(&models.Configuration{}).Count(&total) err := r.db. - Preload("User"). Order("created_at DESC"). Offset(offset). Limit(limit). diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 61c6134..3883787 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/services/sync" @@ -820,6 +821,7 @@ func (s *LocalConfigurationService) appendVersionTx( Data: snapshot, ChangeNote: &changeNote, CreatedBy: createdByPtr, + AppVersion: appmeta.Version(), } if err := tx.Create(version).Error; err != nil { @@ -915,6 +917,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string, Data: target.Data, ChangeNote: &changeNote, CreatedBy: stringPtrOrNil(userID), + AppVersion: appmeta.Version(), } if err := tx.Create(version).Error; err != nil { diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 7318f10..f8cfa7f 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -7,6 +7,7 @@ import ( "log/slog" "time" + "git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -605,15 +606,10 @@ func (s *Service) ensureConfigurationOwner(mariaDB *gorm.DB, cfg *models.Configu return fmt.Errorf("owner username is empty") } - userID, err := models.EnsureDBUser(mariaDB, ownerUsername) - if err != nil { - return err - } - if userID == 0 { - return fmt.Errorf("resolved user ID is 0 for owner %q", ownerUsername) - } - - cfg.UserID = userID + // user_id is legacy and no longer used for ownership in local-first mode. + // Keep it NULL on writes; ownership is represented by owner_username. + cfg.UserID = nil + cfg.AppVersion = appmeta.Version() return nil } diff --git a/migrations/006_add_local_configuration_versions.sql b/migrations/006_add_local_configuration_versions.sql index 0c01a6c..f4159a2 100644 --- a/migrations/006_add_local_configuration_versions.sql +++ b/migrations/006_add_local_configuration_versions.sql @@ -14,6 +14,7 @@ CREATE TABLE local_configuration_versions ( data TEXT NOT NULL, change_note TEXT NULL, created_by TEXT NULL, + app_version TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid), UNIQUE(configuration_uuid, version_no) @@ -36,6 +37,7 @@ INSERT INTO local_configuration_versions ( data, change_note, created_by, + app_version, created_at ) SELECT @@ -58,10 +60,12 @@ SELECT 'synced_at', synced_at, 'sync_status', sync_status, 'original_user_id', original_user_id, - 'original_username', original_username + 'original_username', original_username, + 'app_version', NULL ) AS data, 'Initial snapshot backfill (v1)' AS change_note, NULL AS created_by, + NULL AS app_version, COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at FROM local_configurations; diff --git a/migrations/007_detach_configurations_from_qt_users.sql b/migrations/007_detach_configurations_from_qt_users.sql new file mode 100644 index 0000000..e8669b7 --- /dev/null +++ b/migrations/007_detach_configurations_from_qt_users.sql @@ -0,0 +1,25 @@ +-- Detach qt_configurations from qt_users (ownership is owner_username text) +-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks. + +SET @fk_exists := ( + SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'qt_configurations' + AND CONSTRAINT_NAME = 'fk_qt_configurations_user' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); + +SET @drop_fk_sql := IF( + @fk_exists > 0, + 'ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_user', + 'SELECT ''fk_qt_configurations_user not found, skip'' ' +); +PREPARE stmt_drop_fk FROM @drop_fk_sql; +EXECUTE stmt_drop_fk; +DEALLOCATE PREPARE stmt_drop_fk; + +-- user_id becomes optional legacy column (can stay NULL) +ALTER TABLE qt_configurations + MODIFY COLUMN user_id BIGINT UNSIGNED NULL; + diff --git a/migrations/008_add_app_version_to_configurations.sql b/migrations/008_add_app_version_to_configurations.sql new file mode 100644 index 0000000..ecfe82d --- /dev/null +++ b/migrations/008_add_app_version_to_configurations.sql @@ -0,0 +1,4 @@ +-- Track application version used for configuration writes (create/update via sync) +ALTER TABLE qt_configurations + ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username; +