fix(qfs): project ui, config naming, sync timestamps - v1.5.4

This commit is contained in:
Mikhail Chusavitin
2026-03-16 08:32:15 +03:00
parent c599897142
commit 35c5600b36
16 changed files with 815 additions and 93 deletions

View File

@@ -611,6 +611,46 @@ func (l *LocalDB) SaveProject(project *LocalProject) error {
return l.db.Save(project).Error
}
// SaveProjectPreservingUpdatedAt stores a project without replacing UpdatedAt
// with the current local sync time.
func (l *LocalDB) SaveProjectPreservingUpdatedAt(project *LocalProject) error {
if project == nil {
return fmt.Errorf("project is nil")
}
if project.ID == 0 && strings.TrimSpace(project.UUID) != "" {
var existing LocalProject
err := l.db.Where("uuid = ?", project.UUID).First(&existing).Error
if err == nil {
project.ID = existing.ID
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if project.ID == 0 {
return l.db.Create(project).Error
}
return l.db.Model(&LocalProject{}).
Where("id = ?", project.ID).
UpdateColumns(map[string]interface{}{
"uuid": project.UUID,
"server_id": project.ServerID,
"owner_username": project.OwnerUsername,
"code": project.Code,
"variant": project.Variant,
"name": project.Name,
"tracker_url": project.TrackerURL,
"is_active": project.IsActive,
"is_system": project.IsSystem,
"created_at": project.CreatedAt,
"updated_at": project.UpdatedAt,
"synced_at": project.SyncedAt,
"sync_status": project.SyncStatus,
}).Error
}
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)

View File

@@ -0,0 +1,53 @@
package localdb
import (
"path/filepath"
"testing"
"time"
)
func TestSaveProjectPreservingUpdatedAtKeepsProvidedTimestamp(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "project_sync_timestamp.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
createdAt := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
updatedAt := time.Date(2026, 2, 3, 12, 30, 0, 0, time.UTC)
project := &LocalProject{
UUID: "project-1",
OwnerUsername: "tester",
Code: "OPS-1",
Variant: "Lenovo",
IsActive: true,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
SyncStatus: "synced",
}
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
t.Fatalf("save project: %v", err)
}
syncedAt := time.Date(2026, 3, 16, 8, 45, 0, 0, time.UTC)
project.SyncedAt = &syncedAt
project.SyncStatus = "synced"
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
t.Fatalf("save project second time: %v", err)
}
stored, err := local.GetProjectByUUID(project.UUID)
if err != nil {
t.Fatalf("get project: %v", err)
}
if !stored.UpdatedAt.Equal(updatedAt) {
t.Fatalf("updated_at changed during sync save: got %s want %s", stored.UpdatedAt, updatedAt)
}
if stored.SyncedAt == nil || !stored.SyncedAt.Equal(syncedAt) {
t.Fatalf("synced_at not updated correctly: got %+v want %s", stored.SyncedAt, syncedAt)
}
}

View File

@@ -20,6 +20,7 @@ var (
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
)
type ProjectService struct {
@@ -63,6 +64,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
return nil, fmt.Errorf("project code is required")
}
variant := strings.TrimSpace(req.Variant)
if err := validateProjectVariantName(variant); err != nil {
return nil, err
}
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err
}
@@ -105,6 +109,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
}
if req.Variant != nil {
localProject.Variant = strings.TrimSpace(*req.Variant)
if err := validateProjectVariantName(localProject.Variant); err != nil {
return nil, err
}
}
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err
@@ -166,6 +173,13 @@ func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant))
}
func validateProjectVariantName(variant string) error {
if normalizeProjectVariant(variant) == "main" {
return ErrReservedMainVariant
}
return nil
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
return s.setProjectActive(projectUUID, ownerUsername, false)
}

View File

@@ -0,0 +1,60 @@
package services
import (
"errors"
"path/filepath"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
_, err = service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "main",
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
}
}
func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
created, err := service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "Lenovo",
})
if err != nil {
t.Fatalf("create project: %v", err)
}
mainName := "main"
_, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{
Variant: &mainName,
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
}
}
func newProjectTestLocalDB(t *testing.T) (*localdb.LocalDB, error) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "project_test.db")
local, err := localdb.New(dbPath)
if err != nil {
return nil, err
}
t.Cleanup(func() { _ = local.Close() })
return local, nil
}

View File

@@ -215,7 +215,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
existing.SyncStatus = "synced"
existing.SyncedAt = &now
if err := s.localDB.SaveProject(existing); err != nil {
if err := s.localDB.SaveProjectPreservingUpdatedAt(existing); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Updated++
@@ -225,7 +225,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
localProject := localdb.ProjectToLocal(&project)
localProject.SyncStatus = "synced"
localProject.SyncedAt = &now
if err := s.localDB.SaveProject(localProject); err != nil {
if err := s.localDB.SaveProjectPreservingUpdatedAt(localProject); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Imported++
@@ -1008,7 +1008,7 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProject(localProject)
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
@@ -1278,7 +1278,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProject(localProject)
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
}