Add project variants and UI updates

This commit is contained in:
Mikhail Chusavitin
2026-02-13 19:27:48 +03:00
parent 4e1a46bd71
commit 9b5d57902d
23 changed files with 1113 additions and 147 deletions

View File

@@ -66,7 +66,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
// Try to load project name from database
username := middleware.GetUsername(c)
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
projectName = project.Name
projectName = derefString(project.Name)
}
}
if projectName == "" {
@@ -90,6 +90,13 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
}
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
items := make([]services.ExportItem, len(req.Items))
var total float64
@@ -171,7 +178,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
projectName := config.Name // fallback: use config name if no project
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
projectName = project.Name
projectName = derefString(project.Name)
}
}

View File

@@ -106,6 +106,8 @@ func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{
UUID: project.UUID,
OwnerUsername: project.OwnerUsername,
Code: project.Code,
Variant: project.Variant,
Name: project.Name,
TrackerURL: project.TrackerURL,
IsActive: project.IsActive,
@@ -125,6 +127,8 @@ func LocalToProject(local *LocalProject) *models.Project {
project := &models.Project{
UUID: local.UUID,
OwnerUsername: local.OwnerUsername,
Code: local.Code,
Variant: local.Variant,
Name: local.Name,
TrackerURL: local.TrackerURL,
IsActive: local.IsActive,

View File

@@ -42,6 +42,49 @@ type LocalDB struct {
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
@@ -65,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
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{},
&LocalProject{},
&LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{},
@@ -93,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
}, 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
@@ -267,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
project = &LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
@@ -295,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
canonical = LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
@@ -376,6 +474,10 @@ WHERE (
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 {

View File

@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
if err != nil {
t.Fatalf("get system project: %v", err)
}
if project.Name != "Без проекта" {
t.Fatalf("expected system project name, got %q", project.Name)
if project.Name == nil || *project.Name != "Без проекта" {
t.Fatalf("expected system project name, got %v", project.Name)
}
if !project.IsSystem {
t.Fatalf("expected system project flag")

View File

@@ -88,6 +88,21 @@ var localMigrations = []localMigration{
name: "Add support_code to local_configurations",
run: addLocalConfigurationSupportCode,
},
{
id: "2026_02_13_local_project_code",
name: "Add project code to local_projects and backfill",
run: addLocalProjectCode,
},
{
id: "2026_02_13_local_project_variant",
name: "Add project variant to local_projects and backfill",
run: addLocalProjectVariant,
},
{
id: "2026_02_13_local_project_name_nullable",
name: "Allow NULL project names in local_projects",
run: allowLocalProjectNameNull,
},
}
func runLocalMigrations(db *gorm.DB) error {
@@ -224,7 +239,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
project = LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
@@ -238,6 +254,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
return &project, nil
}
func addLocalProjectCode(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
!strings.Contains(strings.ToLower(err.Error()), "exists") {
return err
}
}
// Drop unique index if it already exists to allow de-duplication updates.
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
return err
}
// Copy code from current project name.
if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
return err
}
// Ensure any remaining blanks have a unique fallback.
if err := tx.Exec(`
UPDATE local_projects
SET code = 'P-' || uuid
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
return err
}
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
if err := tx.Exec(`
WITH ranked AS (
SELECT id, code,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
FROM local_projects
)
UPDATE local_projects
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
return err
}
// Create unique index for project codes (ignore if exists).
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
return err
}
return nil
}
func addLocalProjectVariant(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
!strings.Contains(strings.ToLower(err.Error()), "exists") {
return err
}
}
// Drop legacy code index if present.
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
return err
}
// Reset code from name and clear variant.
if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, '')),
variant = ''`).Error; err != nil {
return err
}
// De-duplicate by assigning variant numbers: 2,3...
if err := tx.Exec(`
WITH ranked AS (
SELECT id, code,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
FROM local_projects
)
UPDATE local_projects
SET variant = CASE
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
END`).Error; err != nil {
return err
}
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
return err
}
return nil
}
func allowLocalProjectNameNull(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
return err
}
if err := tx.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
}
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
if err := tx.Exec(`
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
FROM local_projects_old`).Error; err != nil {
return err
}
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
return nil
}
func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
@@ -279,6 +428,7 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
return candidate
}
func fixLocalPricelistIndexes(tx *gorm.DB) error {
type indexRow struct {
Name string `gorm:"column:name"`

View File

@@ -123,7 +123,9 @@ type LocalProject struct {
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id,omitempty"`
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
Name string `gorm:"not null" json:"name"`
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`

View File

@@ -6,7 +6,9 @@ type Project struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"`
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
Name *string `gorm:"size:200" json:"name,omitempty"`
TrackerURL string `gorm:"size:500" json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`

View File

@@ -27,6 +27,8 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{
"owner_username",
"code",
"variant",
"name",
"tracker_url",
"is_active",

View File

@@ -191,7 +191,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
project := &localdb.LocalProject{
UUID: "project-keep",
OwnerUsername: "tester",
Name: "Keep Project",
Code: "TEST-KEEP",
Name: ptrString("Keep Project"),
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -227,6 +228,10 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
}
}
func ptrString(value string) *string {
return &value
}
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
t.Helper()

View File

@@ -18,7 +18,7 @@ import (
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectNameExists = errors.New("project name already exists")
ErrProjectCodeExists = errors.New("project code and variant already exist")
)
type ProjectService struct {
@@ -30,12 +30,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
}
type CreateProjectRequest struct {
Name string `json:"name"`
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"`
}
type UpdateProjectRequest struct {
Name string `json:"name"`
Code *string `json:"code,omitempty"`
Variant *string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL *string `json:"tracker_url,omitempty"`
}
@@ -46,11 +50,19 @@ type ProjectConfigurationsResult struct {
}
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("project name is required")
var namePtr *string
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name != "" {
namePtr = &name
}
}
if err := s.ensureUniqueProjectName("", name); err != nil {
code := strings.TrimSpace(req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
}
variant := strings.TrimSpace(req.Variant)
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err
}
@@ -58,8 +70,10 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
localProject := &localdb.LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: name,
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
Code: code,
Variant: variant,
Name: namePtr,
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
IsActive: true,
IsSystem: false,
CreatedAt: now,
@@ -81,19 +95,32 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
return nil, ErrProjectNotFound
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("project name is required")
if req.Code != nil {
code := strings.TrimSpace(*req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
}
localProject.Code = code
}
if err := s.ensureUniqueProjectName(projectUUID, name); err != nil {
if req.Variant != nil {
localProject.Variant = strings.TrimSpace(*req.Variant)
}
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err
}
localProject.Name = name
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
localProject.Name = nil
} else {
localProject.Name = &name
}
}
if req.TrackerURL != nil {
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
}
localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending"
@@ -106,10 +133,11 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
return localdb.LocalToProject(localProject), nil
}
func (s *ProjectService) ensureUniqueProjectName(excludeUUID, name string) error {
normalized := normalizeProjectName(name)
if normalized == "" {
return fmt.Errorf("project name is required")
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
normalizedCode := normalizeProjectCode(code)
normalizedVariant := normalizeProjectVariant(variant)
if normalizedCode == "" {
return fmt.Errorf("project code is required")
}
projects, err := s.localDB.GetAllProjects(true)
@@ -121,15 +149,20 @@ func (s *ProjectService) ensureUniqueProjectName(excludeUUID, name string) error
if excludeUUID != "" && project.UUID == excludeUUID {
continue
}
if normalizeProjectName(project.Name) == normalized {
return ErrProjectNameExists
if normalizeProjectCode(project.Code) == normalizedCode &&
normalizeProjectVariant(project.Variant) == normalizedVariant {
return ErrProjectCodeExists
}
}
return nil
}
func normalizeProjectName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
func normalizeProjectCode(code string) string {
return strings.ToLower(strings.TrimSpace(code))
}
func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant))
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {

View File

@@ -200,6 +200,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
}
existing.OwnerUsername = project.OwnerUsername
existing.Code = project.Code
existing.Name = project.Name
existing.TrackerURL = project.TrackerURL
existing.IsActive = project.IsActive
@@ -848,6 +849,12 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
projectRepo := repository.NewProjectRepository(mariaDB)
project := payload.Snapshot
project.UUID = payload.ProjectUUID
if strings.TrimSpace(project.Code) == "" {
project.Code = strings.TrimSpace(derefString(project.Name))
if project.Code == "" {
project.Code = project.UUID
}
}
if err := projectRepo.UpsertByUUID(&project); err != nil {
return fmt.Errorf("upsert project on server: %w", err)
@@ -868,6 +875,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
return nil
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func ptrString(value string) *string {
return &value
}
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
var payload ProjectChangePayload
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
@@ -1138,7 +1156,8 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
systemProject = &models.Project{
UUID: uuid.NewString(),
OwnerUsername: "",
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
}
@@ -1302,6 +1321,21 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
}
}
if currentVersionNo == 0 {
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
}
var latest localdb.LocalConfigurationVersion
err = s.localDB.DB().
Where("configuration_uuid = ?", configurationUUID).
Order("version_no DESC").
First(&latest).Error
if err == nil {
currentVersionNo = latest.VersionNo
currentVersionID = latest.ID
}
}
if currentVersionNo == 0 {
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
}
@@ -1309,6 +1343,64 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
return cfg, currentVersionID, currentVersionNo, nil
}
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
if localCfg == nil {
return fmt.Errorf("local configuration is nil")
}
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var cfg localdb.LocalConfiguration
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
return fmt.Errorf("load local configuration: %w", err)
}
// If versions exist, just make sure current_version_id is set.
var latest localdb.LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
Order("version_no DESC").
First(&latest).Error; err == nil {
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", latest.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("load latest version: %w", err)
}
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
if err != nil {
return fmt.Errorf("build configuration snapshot: %w", err)
}
note := "Auto-repaired missing local version"
version := localdb.LocalConfigurationVersion{
ID: uuid.NewString(),
ConfigurationUUID: cfg.UUID,
VersionNo: 1,
Data: snapshot,
ChangeNote: &note,
AppVersion: appmeta.Version(),
CreatedAt: time.Now(),
}
if err := tx.Create(&version).Error; err != nil {
return fmt.Errorf("create initial version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
return nil
})
}
// NOTE: prepared for future conflict resolution:
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
// against remote version and branch into custom strategies. For now use last-write-wins.

View File

@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
projectService := services.NewProjectService(local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
if err != nil {
t.Fatalf("create project: %v", err)
}
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local)
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
if err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
t.Fatalf("update project: %v", err)
}
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
t.Fatalf("project not pushed to server: %v", err)
}
if serverProject.Name != "Project v2" {
t.Fatalf("expected latest project name, got %q", serverProject.Name)
if serverProject.Name == nil || *serverProject.Name != "Project v2" {
t.Fatalf("expected latest project name, got %v", serverProject.Name)
}
var serverCfg models.Configuration
@@ -324,6 +324,8 @@ CREATE TABLE qt_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
@@ -333,6 +335,9 @@ CREATE TABLE qt_projects (
);`).Error; err != nil {
t.Fatalf("create qt_projects: %v", err)
}
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
t.Fatalf("create qt_projects index: %v", err)
}
if err := db.Exec(`
CREATE TABLE qt_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -364,6 +369,10 @@ CREATE TABLE qt_configurations (
return db
}
func ptrString(value string) *string {
return &value
}
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
t.Helper()
if currentVersionID == nil || *currentVersionID == "" {