Root cause: Projects with duplicate (code, variant) pairs fail to sync due to unique constraint on server. Example: multiple "OPS-1934" projects with variant="Dell" where one already exists on server. Fixes: 1. Sync service now detects duplicate (code, variant) on server and links local project to existing server project instead of failing 2. Local repair checks for duplicate (code, variant) pairs and deduplicates by appending UUID suffix to variant 3. Modal now scrollable with fixed header/footer (max-h-90vh) This allows users to sync projects that were created offline with conflicting codes/variants without losing data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1250 lines
38 KiB
Go
1250 lines
38 KiB
Go
package localdb
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
|
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
|
"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
|
|
}
|
|
|
|
// ResetData clears local data tables while keeping connection settings.
|
|
// It does not drop schema or connection_settings.
|
|
func ResetData(dbPath string) error {
|
|
if strings.TrimSpace(dbPath) == "" {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(dbPath); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("stat local db: %w", err)
|
|
}
|
|
|
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("opening sqlite database: %w", err)
|
|
}
|
|
|
|
// Order does not matter because we use DELETEs without FK constraints in SQLite.
|
|
tables := []string{
|
|
"local_projects",
|
|
"local_configurations",
|
|
"local_configuration_versions",
|
|
"local_pricelists",
|
|
"local_pricelist_items",
|
|
"local_components",
|
|
"local_remote_migrations_applied",
|
|
"local_sync_guard_state",
|
|
"pending_changes",
|
|
"app_settings",
|
|
}
|
|
for _, table := range tables {
|
|
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
|
|
return fmt.Errorf("clear %s: %w", table, err)
|
|
}
|
|
}
|
|
|
|
slog.Info("local database data reset", "path", dbPath)
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
|
|
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
|
return nil, fmt.Errorf("backup local data: %w", err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("resolve config path: %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)
|
|
}
|
|
|
|
if err := ensureLocalProjectsTable(db); err != nil {
|
|
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
|
}
|
|
|
|
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
|
|
if db.Migrator().HasTable(&LocalProject{}) {
|
|
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
|
|
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
|
|
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
|
|
}
|
|
}
|
|
var ids []uint
|
|
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
|
|
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
|
|
}
|
|
for _, id := range ids {
|
|
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
|
|
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-migrate all local tables
|
|
if err := db.AutoMigrate(
|
|
&ConnectionSettings{},
|
|
&LocalConfiguration{},
|
|
&LocalConfigurationVersion{},
|
|
&LocalPricelist{},
|
|
&LocalPricelistItem{},
|
|
&LocalComponent{},
|
|
&AppSetting{},
|
|
&LocalRemoteMigrationApplied{},
|
|
&LocalSyncGuardState{},
|
|
&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
|
|
}
|
|
|
|
func ensureLocalProjectsTable(db *gorm.DB) error {
|
|
if db.Migrator().HasTable(&LocalProject{}) {
|
|
return nil
|
|
}
|
|
|
|
if err := db.Exec(`
|
|
CREATE TABLE local_projects (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
uuid TEXT NOT NULL UNIQUE,
|
|
server_id INTEGER NULL,
|
|
owner_username TEXT NOT NULL,
|
|
code TEXT NOT NULL,
|
|
variant TEXT NOT NULL DEFAULT '',
|
|
name TEXT NULL,
|
|
tracker_url TEXT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
is_system INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME,
|
|
updated_at DATETIME,
|
|
synced_at DATETIME NULL,
|
|
sync_status TEXT DEFAULT 'local'
|
|
)`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
|
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
|
return 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: "",
|
|
Code: "Без проекта",
|
|
Name: ptrString("Без проекта"),
|
|
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: "",
|
|
Code: "Без проекта",
|
|
Name: ptrString("Без проекта"),
|
|
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
|
|
}
|
|
|
|
func ptrString(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination.
|
|
func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) {
|
|
query := l.db.Model(&LocalConfiguration{})
|
|
switch status {
|
|
case "active":
|
|
query = query.Where("local_configurations.is_active = ?", true)
|
|
case "archived":
|
|
query = query.Where("local_configurations.is_active = ?", false)
|
|
case "all", "":
|
|
// no-op
|
|
default:
|
|
query = query.Where("local_configurations.is_active = ?", true)
|
|
}
|
|
|
|
search = strings.TrimSpace(search)
|
|
if search != "" {
|
|
needle := "%" + strings.ToLower(search) + "%"
|
|
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
|
|
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
|
|
|
|
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
|
|
args := []interface{}{needle}
|
|
|
|
if hasProjectsTable {
|
|
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
|
|
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
|
|
args = append(args, needle)
|
|
}
|
|
|
|
if hasServerModel {
|
|
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
|
|
args = append(args, needle)
|
|
}
|
|
|
|
query = query.Where(strings.Join(conditions, " OR "), args...)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var configs []LocalConfiguration
|
|
if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return configs, total, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// CountProjects returns the number of local projects
|
|
func (l *LocalDB) CountProjects() int64 {
|
|
var count int64
|
|
l.db.Model(&LocalProject{}).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.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
|
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
|
var pricelist LocalPricelist
|
|
if err := l.db.Where("source = ?", source).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 = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
|
|
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
|
|
var pricelist LocalPricelist
|
|
if err := l.db.Where("source = ? AND version = ?", source, 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{}{
|
|
"source": pricelist.Source,
|
|
"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
|
|
}
|
|
|
|
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
|
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
|
var count int64
|
|
if err := l.db.Model(&LocalPricelistItem{}).
|
|
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
|
|
Count(&count).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
|
|
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
|
|
return l.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
batchSize := 500
|
|
for i := 0; i < len(items); i += batchSize {
|
|
end := i + batchSize
|
|
if end > len(items) {
|
|
end = len(items)
|
|
}
|
|
if err := tx.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
|
|
}
|
|
|
|
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
|
// Missing lots are not included in the map; caller is responsible for strict validation.
|
|
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
|
result := make(map[string]string, len(lotNames))
|
|
if serverPricelistID == 0 || len(lotNames) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type row struct {
|
|
LotName string `gorm:"column:lot_name"`
|
|
LotCategory string `gorm:"column:lot_category"`
|
|
}
|
|
var rows []row
|
|
if err := l.db.Model(&LocalPricelistItem{}).
|
|
Select("lot_name, lot_category").
|
|
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
result[r.LotName] = r.LotCategory
|
|
}
|
|
return result, 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
|
|
}
|
|
|
|
// DeleteUnusedLocalPricelistsMissingOnServer removes local pricelists that are absent on server
|
|
// and not referenced by active local configurations.
|
|
func (l *LocalDB) DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs []uint) (int, error) {
|
|
returned := 0
|
|
err := l.db.Transaction(func(tx *gorm.DB) error {
|
|
var candidates []LocalPricelist
|
|
query := tx.Model(&LocalPricelist{})
|
|
if len(serverPricelistIDs) > 0 {
|
|
query = query.Where("server_id NOT IN ?", serverPricelistIDs)
|
|
}
|
|
if err := query.Find(&candidates).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := range candidates {
|
|
pl := candidates[i]
|
|
var refs int64
|
|
if err := tx.Model(&LocalConfiguration{}).
|
|
Where("pricelist_id = ? AND is_active = 1", pl.ServerID).
|
|
Count(&refs).Error; err != nil {
|
|
return err
|
|
}
|
|
if refs > 0 {
|
|
continue
|
|
}
|
|
if err := tx.Where("pricelist_id = ?", pl.ID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Delete(&LocalPricelist{}, pl.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
returned++
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return returned, nil
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
|
|
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
|
|
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
|
var erroredChanges []PendingChange
|
|
if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
|
|
return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
|
|
}
|
|
|
|
if len(erroredChanges) == 0 {
|
|
return 0, nil, nil
|
|
}
|
|
|
|
repaired := 0
|
|
var remainingErrors []string
|
|
|
|
for _, change := range erroredChanges {
|
|
var repairErr error
|
|
switch change.EntityType {
|
|
case "project":
|
|
repairErr = l.repairProjectChange(&change)
|
|
case "configuration":
|
|
repairErr = l.repairConfigurationChange(&change)
|
|
default:
|
|
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
|
|
}
|
|
|
|
if repairErr != nil {
|
|
remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
|
|
change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
|
|
continue
|
|
}
|
|
|
|
// Clear error and reset attempts
|
|
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
|
|
"last_error": "",
|
|
"attempts": 0,
|
|
}).Error; err != nil {
|
|
remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
|
|
continue
|
|
}
|
|
|
|
repaired++
|
|
}
|
|
|
|
return repaired, remainingErrors, nil
|
|
}
|
|
|
|
// repairProjectChange validates and fixes project data.
|
|
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
|
|
// are handled by sync service layer with deduplication logic.
|
|
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
|
project, err := l.GetProjectByUUID(change.EntityUUID)
|
|
if err != nil {
|
|
return fmt.Errorf("project not found locally: %w", err)
|
|
}
|
|
|
|
modified := false
|
|
|
|
// Fix Code: must be non-empty
|
|
if strings.TrimSpace(project.Code) == "" {
|
|
if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
|
|
project.Code = strings.TrimSpace(*project.Name)
|
|
} else {
|
|
project.Code = project.UUID[:8]
|
|
}
|
|
modified = true
|
|
}
|
|
|
|
// Fix Name: use Code if empty
|
|
if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
|
|
name := project.Code
|
|
project.Name = &name
|
|
modified = true
|
|
}
|
|
|
|
// Fix OwnerUsername: must be non-empty
|
|
if strings.TrimSpace(project.OwnerUsername) == "" {
|
|
project.OwnerUsername = l.GetDBUser()
|
|
if project.OwnerUsername == "" {
|
|
return fmt.Errorf("cannot determine owner username")
|
|
}
|
|
modified = true
|
|
}
|
|
|
|
// Check for local duplicates with same (code, variant)
|
|
var duplicate LocalProject
|
|
err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
|
|
First(&duplicate).Error
|
|
if err == nil {
|
|
// Found local duplicate - deduplicate by appending UUID suffix to variant
|
|
if project.Variant == "" {
|
|
project.Variant = project.UUID[:8]
|
|
} else {
|
|
project.Variant = project.Variant + "-" + project.UUID[:8]
|
|
}
|
|
modified = true
|
|
}
|
|
|
|
if modified {
|
|
if err := l.SaveProject(project); err != nil {
|
|
return fmt.Errorf("saving repaired project: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// repairConfigurationChange validates and fixes configuration data
|
|
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
|
config, err := l.GetConfigurationByUUID(change.EntityUUID)
|
|
if err != nil {
|
|
return fmt.Errorf("configuration not found locally: %w", err)
|
|
}
|
|
|
|
modified := false
|
|
|
|
// Check if referenced project exists
|
|
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
|
_, err := l.GetProjectByUUID(*config.ProjectUUID)
|
|
if err != nil {
|
|
// Project doesn't exist locally - use default system project
|
|
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
|
|
if sysErr != nil {
|
|
return fmt.Errorf("getting system project: %w", sysErr)
|
|
}
|
|
config.ProjectUUID = &systemProject.UUID
|
|
modified = true
|
|
}
|
|
}
|
|
|
|
if modified {
|
|
if err := l.SaveConfiguration(config); err != nil {
|
|
return fmt.Errorf("saving repaired configuration: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
|
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
|
var migration LocalRemoteMigrationApplied
|
|
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &migration, nil
|
|
}
|
|
|
|
// UpsertRemoteMigrationApplied writes applied migration metadata.
|
|
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
|
|
record := &LocalRemoteMigrationApplied{
|
|
ID: id,
|
|
Checksum: checksum,
|
|
AppVersion: appVersion,
|
|
AppliedAt: appliedAt,
|
|
}
|
|
return l.db.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "id"}},
|
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
|
"checksum": checksum,
|
|
"app_version": appVersion,
|
|
"applied_at": appliedAt,
|
|
}),
|
|
}).Create(record).Error
|
|
}
|
|
|
|
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
|
|
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
|
|
var record LocalRemoteMigrationApplied
|
|
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
|
|
return "", err
|
|
}
|
|
return record.ID, nil
|
|
}
|
|
|
|
// GetSyncGuardState returns the latest readiness guard state.
|
|
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
|
|
var state LocalSyncGuardState
|
|
if err := l.db.Order("id DESC").First(&state).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
// SetSyncGuardState upserts readiness guard state (single-row logical table).
|
|
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
|
|
state := &LocalSyncGuardState{
|
|
ID: 1,
|
|
Status: status,
|
|
ReasonCode: reasonCode,
|
|
ReasonText: reasonText,
|
|
RequiredMinAppVersion: requiredMinAppVersion,
|
|
LastCheckedAt: checkedAt,
|
|
}
|
|
return l.db.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "id"}},
|
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
|
"status": status,
|
|
"reason_code": reasonCode,
|
|
"reason_text": reasonText,
|
|
"required_min_app_version": requiredMinAppVersion,
|
|
"last_checked_at": checkedAt,
|
|
"updated_at": time.Now(),
|
|
}),
|
|
}).Create(state).Error
|
|
}
|