Drop qt_users dependency for configs and track app version

This commit is contained in:
Mikhail Chusavitin
2026-02-05 15:07:23 +03:00
parent 77c00de97a
commit 548a256d04
14 changed files with 103 additions and 31 deletions

View File

@@ -66,7 +66,7 @@ func main() {
// Get all configurations from MariaDB // Get all configurations from MariaDB
var configs []models.Configuration 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) log.Fatalf("Failed to fetch configurations: %v", err)
} }
@@ -78,9 +78,6 @@ func main() {
log.Println("\n[DRY RUN] Would migrate the following configurations:") log.Println("\n[DRY RUN] Would migrate the following configurations:")
for _, c := range configs { for _, c := range configs {
userName := c.OwnerUsername userName := c.OwnerUsername
if userName == "" && c.User != nil {
userName = c.User.Username
}
if userName == "" { if userName == "" {
userName = "unknown" userName = "unknown"
} }
@@ -131,14 +128,10 @@ func main() {
UpdatedAt: now, UpdatedAt: now,
SyncedAt: &now, SyncedAt: &now,
SyncStatus: "synced", SyncStatus: "synced",
OriginalUserID: c.UserID, OriginalUserID: derefUint(c.UserID),
OriginalUsername: c.OwnerUsername, OriginalUsername: c.OwnerUsername,
} }
if localConfig.OriginalUsername == "" && c.User != nil {
localConfig.OriginalUsername = c.User.Username
}
if err := local.SaveConfiguration(localConfig); err != nil { if err := local.SaveConfiguration(localConfig); err != nil {
log.Printf(" ERROR: %s - %v", c.Name, err) log.Printf(" ERROR: %s - %v", c.Name, err)
errors++ errors++
@@ -173,3 +166,10 @@ func main() {
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server") 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
}

View File

@@ -19,6 +19,7 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/appstate" "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/config"
"git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/handlers" "git.mchus.pro/mchus/quoteforge/internal/handlers"
@@ -55,6 +56,7 @@ func main() {
exePath, _ := os.Executable() exePath, _ := os.Executable()
slog.Info("starting qfs", "version", Version, "executable", exePath) slog.Info("starting qfs", "version", Version, "executable", exePath)
appmeta.SetVersion(Version)
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath) resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
if err != nil { if err != nil {

View File

@@ -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"
}

View File

@@ -31,7 +31,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUserID: cfg.UserID, OriginalUserID: derefUint(cfg.UserID),
OriginalUsername: cfg.OwnerUsername, OriginalUsername: cfg.OwnerUsername,
} }
@@ -60,7 +60,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: local.UUID, UUID: local.UUID,
UserID: local.OriginalUserID,
OwnerUsername: local.OriginalUsername, OwnerUsername: local.OriginalUsername,
Name: local.Name, Name: local.Name,
Items: items, Items: items,
@@ -76,10 +75,21 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
if local.ServerID != nil { if local.ServerID != nil {
cfg.ID = *local.ServerID cfg.ID = *local.ServerID
} }
if local.OriginalUserID != 0 {
userID := local.OriginalUserID
cfg.UserID = &userID
}
return cfg return cfg
} }
func derefUint(v *uint) uint {
if v == nil {
return 0
}
return *v
}
// PricelistToLocal converts models.Pricelist to LocalPricelist // PricelistToLocal converts models.Pricelist to LocalPricelist
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist { func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
name := pl.Notification name := pl.Notification

View File

@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
uuidpkg "github.com/google/uuid" uuidpkg "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
@@ -238,6 +239,7 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
VersionNo: maxVersion + 1, VersionNo: maxVersion + 1,
Data: snapshot, Data: snapshot,
ChangeNote: &note, ChangeNote: &note,
AppVersion: appmeta.Version(),
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := tx.Create(version).Error; err != nil { if err := tx.Create(version).Error; err != nil {

View File

@@ -103,6 +103,7 @@ func backfillConfigurationVersions(tx *gorm.DB) error {
VersionNo: 1, VersionNo: 1,
Data: snapshot, Data: snapshot,
ChangeNote: &note, ChangeNote: &note,
AppVersion: "backfill",
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()), CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
} }
if err := tx.Create(&version).Error; err != nil { if err := tx.Create(&version).Error; err != nil {

View File

@@ -94,6 +94,7 @@ type LocalConfigurationVersion struct {
Data string `gorm:"type:text;not null" json:"data"` Data string `gorm:"type:text;not null" json:"data"`
ChangeNote *string `json:"change_note,omitempty"` ChangeNote *string `json:"change_note,omitempty"`
CreatedBy *string `json:"created_by,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"` 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"` Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
} }

View File

@@ -42,8 +42,9 @@ func (c ConfigItems) Total() float64 {
type Configuration struct { type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` 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"` 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"` Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"` Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`

View File

@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) { func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
var config models.Configuration var config models.Configuration
err := r.db.Preload("User").First(&config, id).Error err := r.db.First(&config, id).Error
if err != nil { if err != nil {
return nil, err 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) { func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
var config models.Configuration 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 { if err != nil {
return nil, err return nil, err
} }
@@ -47,12 +47,11 @@ func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit
var configs []models.Configuration var configs []models.Configuration
var total int64 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. err := r.db.
Preload("User"). Where(ownerScope, ownerUsername).
Where(ownerScope, ownerUsername, ownerUsername).
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). 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) r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
err := r.db. err := r.db.
Preload("User").
Where("is_template = ?", true). Where("is_template = ?", true).
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
@@ -84,7 +82,6 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
r.db.Model(&models.Configuration{}).Count(&total) r.db.Model(&models.Configuration{}).Count(&total)
err := r.db. err := r.db.
Preload("User").
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
@@ -820,6 +821,7 @@ func (s *LocalConfigurationService) appendVersionTx(
Data: snapshot, Data: snapshot,
ChangeNote: &changeNote, ChangeNote: &changeNote,
CreatedBy: createdByPtr, CreatedBy: createdByPtr,
AppVersion: appmeta.Version(),
} }
if err := tx.Create(version).Error; err != nil { if err := tx.Create(version).Error; err != nil {
@@ -915,6 +917,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
Data: target.Data, Data: target.Data,
ChangeNote: &changeNote, ChangeNote: &changeNote,
CreatedBy: stringPtrOrNil(userID), CreatedBy: stringPtrOrNil(userID),
AppVersion: appmeta.Version(),
} }
if err := tx.Create(version).Error; err != nil { if err := tx.Create(version).Error; err != nil {

View File

@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "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") return fmt.Errorf("owner username is empty")
} }
userID, err := models.EnsureDBUser(mariaDB, ownerUsername) // user_id is legacy and no longer used for ownership in local-first mode.
if err != nil { // Keep it NULL on writes; ownership is represented by owner_username.
return err cfg.UserID = nil
} cfg.AppVersion = appmeta.Version()
if userID == 0 {
return fmt.Errorf("resolved user ID is 0 for owner %q", ownerUsername)
}
cfg.UserID = userID
return nil return nil
} }

View File

@@ -14,6 +14,7 @@ CREATE TABLE local_configuration_versions (
data TEXT NOT NULL, data TEXT NOT NULL,
change_note TEXT NULL, change_note TEXT NULL,
created_by TEXT NULL, created_by TEXT NULL,
app_version TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid), FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
UNIQUE(configuration_uuid, version_no) UNIQUE(configuration_uuid, version_no)
@@ -36,6 +37,7 @@ INSERT INTO local_configuration_versions (
data, data,
change_note, change_note,
created_by, created_by,
app_version,
created_at created_at
) )
SELECT SELECT
@@ -58,10 +60,12 @@ SELECT
'synced_at', synced_at, 'synced_at', synced_at,
'sync_status', sync_status, 'sync_status', sync_status,
'original_user_id', original_user_id, 'original_user_id', original_user_id,
'original_username', original_username 'original_username', original_username,
'app_version', NULL
) AS data, ) AS data,
'Initial snapshot backfill (v1)' AS change_note, 'Initial snapshot backfill (v1)' AS change_note,
NULL AS created_by, NULL AS created_by,
NULL AS app_version,
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
FROM local_configurations; FROM local_configurations;

View File

@@ -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;

View File

@@ -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;