feat: add projects flow and consolidate default project handling
This commit is contained in:
@@ -22,17 +22,20 @@ type ConfigurationGetter interface {
|
||||
|
||||
type ConfigurationService struct {
|
||||
configRepo *repository.ConfigurationRepository
|
||||
projectRepo *repository.ProjectRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
quoteService *QuoteService
|
||||
}
|
||||
|
||||
func NewConfigurationService(
|
||||
configRepo *repository.ConfigurationRepository,
|
||||
projectRepo *repository.ProjectRepository,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
quoteService *QuoteService,
|
||||
) *ConfigurationService {
|
||||
return &ConfigurationService{
|
||||
configRepo: configRepo,
|
||||
projectRepo: projectRepo,
|
||||
componentRepo: componentRepo,
|
||||
quoteService: quoteService,
|
||||
}
|
||||
@@ -41,6 +44,7 @@ func NewConfigurationService(
|
||||
type CreateConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
@@ -48,6 +52,11 @@ type CreateConfigRequest struct {
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
@@ -58,6 +67,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
config := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -101,6 +111,11 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
@@ -109,6 +124,7 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
@@ -156,10 +172,21 @@ func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create copy with new UUID and name
|
||||
total := original.Items.Total()
|
||||
@@ -172,6 +199,7 @@ func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, ne
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -229,12 +257,18 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
@@ -275,10 +309,21 @@ func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*model
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.configRepo.GetByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
@@ -288,6 +333,7 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -304,6 +350,26 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
_ = ownerUsername
|
||||
if s.projectRepo == nil {
|
||||
return projectUUID, nil
|
||||
}
|
||||
if projectUUID == nil || *projectUUID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
project, err := s.projectRepo.GetByUUID(*projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
|
||||
@@ -55,6 +55,11 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
}
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -63,6 +68,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
cfg := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -118,6 +124,11 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -125,6 +136,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
|
||||
// Update fields
|
||||
localCfg.Name = req.Name
|
||||
localCfg.ProjectUUID = projectUUID
|
||||
localCfg.Items = localdb.LocalConfigItems{}
|
||||
for _, item := range req.Items {
|
||||
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
|
||||
@@ -210,10 +222,21 @@ func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, ne
|
||||
|
||||
// Clone clones a configuration
|
||||
func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
@@ -223,6 +246,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -362,12 +386,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
localCfg.Name = req.Name
|
||||
localCfg.ProjectUUID = projectUUID
|
||||
localCfg.Items = localdb.LocalConfigItems{}
|
||||
for _, item := range req.Items {
|
||||
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
|
||||
@@ -440,10 +470,21 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
@@ -453,6 +494,7 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -471,24 +513,59 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// SetProjectNoAuth moves configuration to a different project without ownership check.
|
||||
func (s *LocalConfigurationService) SetProjectNoAuth(uuid string, projectUUID string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
var resolved *string
|
||||
trimmed := strings.TrimSpace(projectUUID)
|
||||
if trimmed == "" {
|
||||
resolved, err = s.resolveProjectUUID(localCfg.OriginalUsername, &projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
project, getErr := s.localDB.GetProjectByUUID(trimmed)
|
||||
if getErr != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
resolved = &project.UUID
|
||||
}
|
||||
|
||||
localCfg.ProjectUUID = resolved
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
return s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter
|
||||
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
return s.ListAllWithStatus(page, perPage, "active")
|
||||
return s.ListAllWithStatus(page, perPage, "active", "")
|
||||
}
|
||||
|
||||
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
|
||||
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string) ([]models.Configuration, int64, error) {
|
||||
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) {
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
configs := make([]models.Configuration, len(localConfigs))
|
||||
configs = configs[:0]
|
||||
for _, lc := range localConfigs {
|
||||
if !matchesConfigStatus(lc.IsActive, status) {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(lc.Name), search) {
|
||||
continue
|
||||
}
|
||||
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
|
||||
@@ -960,6 +1037,7 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
|
||||
EventID: uuid.New().String(),
|
||||
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
|
||||
ConfigurationUUID: localCfg.UUID,
|
||||
ProjectUUID: localCfg.ProjectUUID,
|
||||
Operation: operation,
|
||||
CurrentVersionID: version.ID,
|
||||
CurrentVersionNo: version.VersionNo,
|
||||
@@ -1013,3 +1091,28 @@ func matchesConfigStatus(isActive bool, status string) bool {
|
||||
return isActive
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
if ownerUsername == "" {
|
||||
ownerUsername = s.localDB.GetDBUser()
|
||||
}
|
||||
|
||||
if projectUUID == nil || strings.TrimSpace(*projectUUID) == "" {
|
||||
project, err := s.localDB.EnsureDefaultProject(ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
requested := strings.TrimSpace(*projectUUID)
|
||||
project, err := s.localDB.GetProjectByUUID(requested)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
296
internal/services/project.go
Normal file
296
internal/services/project.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
return &ProjectService{localDB: localDB}
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ProjectConfigurationsResult struct {
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Configs []models.Configuration `json:"configurations"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localProject := &localdb.LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enqueueProjectPendingChange(localProject, "create"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, error) {
|
||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if localProject.OwnerUsername != ownerUsername {
|
||||
return nil, ErrProjectForbidden
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
}
|
||||
|
||||
localProject.Name = name
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enqueueProjectPendingChange(localProject, "update"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||
}
|
||||
|
||||
func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||
}
|
||||
|
||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var project localdb.LocalProject
|
||||
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if project.OwnerUsername != ownerUsername {
|
||||
return ErrProjectForbidden
|
||||
}
|
||||
if project.IsActive == isActive {
|
||||
return nil
|
||||
}
|
||||
|
||||
project.IsActive = isActive
|
||||
project.UpdatedAt = time.Now()
|
||||
project.SyncStatus = "pending"
|
||||
if err := tx.Save(&project).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.enqueueProjectPendingChangeTx(tx, &project, boolToOp(isActive, "reactivate", "archive")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var configs []localdb.LocalConfiguration
|
||||
if err := tx.Where("project_uuid = ?", projectUUID).Find(&configs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range configs {
|
||||
cfg := configs[i]
|
||||
cfg.IsActive = isActive
|
||||
cfg.SyncStatus = "pending"
|
||||
cfg.UpdatedAt = time.Now()
|
||||
if err := tx.Save(&cfg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modelCfg := localdb.LocalToConfiguration(&cfg)
|
||||
payload, err := json.Marshal(modelCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change := &localdb.PendingChange{
|
||||
EntityType: "configuration",
|
||||
EntityUUID: cfg.UUID,
|
||||
Operation: "update",
|
||||
Payload: string(payload),
|
||||
CreatedAt: time.Now(),
|
||||
Attempts: 0,
|
||||
}
|
||||
if err := tx.Create(change).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListByUser(ownerUsername string, includeArchived bool) ([]models.Project, error) {
|
||||
localProjects, err := s.localDB.GetAllProjects(includeArchived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projects := make([]models.Project, 0, len(localProjects))
|
||||
for i := range localProjects {
|
||||
projects = append(projects, *localdb.LocalToProject(&localProjects[i]))
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.Project, error) {
|
||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !project.IsActive && status == "active" {
|
||||
return &ProjectConfigurationsResult{
|
||||
ProjectUUID: projectUUID,
|
||||
Configs: []models.Configuration{},
|
||||
Total: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configs := make([]models.Configuration, 0, len(localConfigs))
|
||||
total := 0.0
|
||||
for i := range localConfigs {
|
||||
localCfg := localConfigs[i]
|
||||
if localCfg.ProjectUUID == nil || *localCfg.ProjectUUID != projectUUID {
|
||||
continue
|
||||
}
|
||||
switch status {
|
||||
case "active", "":
|
||||
if !localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
case "archived":
|
||||
if localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
case "all":
|
||||
default:
|
||||
if !localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
cfg := localdb.LocalToConfiguration(&localCfg)
|
||||
if cfg.TotalPrice != nil {
|
||||
total += *cfg.TotalPrice
|
||||
}
|
||||
configs = append(configs, *cfg)
|
||||
}
|
||||
|
||||
return &ProjectConfigurationsResult{
|
||||
ProjectUUID: projectUUID,
|
||||
Configs: configs,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
if projectUUID == nil || strings.TrimSpace(*projectUUID) == "" {
|
||||
project, err := s.localDB.EnsureDefaultProject(ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
project, err := s.localDB.GetProjectByUUID(strings.TrimSpace(*projectUUID))
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if project.OwnerUsername != ownerUsername {
|
||||
return nil, ErrProjectForbidden
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, fmt.Errorf("project is archived")
|
||||
}
|
||||
|
||||
resolved := project.UUID
|
||||
return &resolved, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
||||
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
||||
}
|
||||
|
||||
func (s *ProjectService) enqueueProjectPendingChangeTx(tx *gorm.DB, project *localdb.LocalProject, operation string) error {
|
||||
payload := sync.ProjectChangePayload{
|
||||
EventID: uuid.NewString(),
|
||||
ProjectUUID: project.UUID,
|
||||
Operation: operation,
|
||||
Snapshot: *localdb.LocalToProject(project),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
IdempotencyKey: fmt.Sprintf("%s:%d:%s", project.UUID, project.UpdatedAt.UnixNano(), operation),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change := &localdb.PendingChange{
|
||||
EntityType: "project",
|
||||
EntityUUID: project.UUID,
|
||||
Operation: operation,
|
||||
Payload: string(raw),
|
||||
CreatedAt: time.Now(),
|
||||
Attempts: 0,
|
||||
}
|
||||
return tx.Create(change).Error
|
||||
}
|
||||
|
||||
func boolToOp(v bool, whenTrue, whenFalse string) string {
|
||||
if v {
|
||||
return whenTrue
|
||||
}
|
||||
return whenFalse
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -19,8 +20,9 @@ var ErrOffline = errors.New("database is offline")
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
@@ -31,6 +33,14 @@ func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Servic
|
||||
}
|
||||
}
|
||||
|
||||
// NewServiceWithDB creates sync service that uses a direct DB handle (used in tests).
|
||||
func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
|
||||
return &Service{
|
||||
localDB: localDB,
|
||||
directDB: mariaDB,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncStatus represents the current sync status
|
||||
type SyncStatus struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
@@ -52,6 +62,7 @@ type ConfigurationChangePayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
ConfigurationUUID string `json:"configuration_uuid"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
|
||||
CurrentVersionID string `json:"current_version_id,omitempty"`
|
||||
CurrentVersionNo int `json:"current_version_no,omitempty"`
|
||||
@@ -61,10 +72,19 @@ type ConfigurationChangePayload struct {
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
type ProjectChangePayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Operation string `json:"operation"`
|
||||
Snapshot models.Project `json:"snapshot"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
|
||||
// Existing local configs with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
@@ -130,9 +150,9 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
|
||||
// Count server pricelists (only if already connected, don't reconnect)
|
||||
serverCount := 0
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
connStatus := s.getConnectionStatus()
|
||||
if connStatus.IsConnected {
|
||||
if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
|
||||
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
activeCount, err := pricelistRepo.CountActive()
|
||||
if err == nil {
|
||||
@@ -170,13 +190,13 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
}
|
||||
|
||||
// Check if there are new pricelists on server (only if already connected)
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
connStatus := s.getConnectionStatus()
|
||||
if !connStatus.IsConnected {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
@@ -208,7 +228,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -301,7 +321,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -418,8 +438,9 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
slog.Info("pushing pending changes", "count", len(changes))
|
||||
pushed := 0
|
||||
var syncedIDs []int64
|
||||
sortedChanges := prioritizeProjectChanges(changes)
|
||||
|
||||
for _, change := range changes {
|
||||
for _, change := range sortedChanges {
|
||||
err := s.pushSingleChange(&change)
|
||||
if err != nil {
|
||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||
@@ -446,6 +467,8 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
// pushSingleChange pushes a single pending change to the server
|
||||
func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
||||
switch change.EntityType {
|
||||
case "project":
|
||||
return s.pushProjectChange(change)
|
||||
case "configuration":
|
||||
return s.pushConfigurationChange(change)
|
||||
default:
|
||||
@@ -453,6 +476,95 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
||||
}
|
||||
}
|
||||
|
||||
func prioritizeProjectChanges(changes []localdb.PendingChange) []localdb.PendingChange {
|
||||
if len(changes) < 2 {
|
||||
return changes
|
||||
}
|
||||
|
||||
projectChanges := make([]localdb.PendingChange, 0, len(changes))
|
||||
otherChanges := make([]localdb.PendingChange, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
if change.EntityType == "project" {
|
||||
projectChanges = append(projectChanges, change)
|
||||
continue
|
||||
}
|
||||
otherChanges = append(otherChanges, change)
|
||||
}
|
||||
|
||||
sorted := make([]localdb.PendingChange, 0, len(changes))
|
||||
sorted = append(sorted, projectChanges...)
|
||||
sorted = append(sorted, otherChanges...)
|
||||
return sorted
|
||||
}
|
||||
|
||||
func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
payload, err := decodeProjectChangePayload(change)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode project payload: %w", err)
|
||||
}
|
||||
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
project := payload.Snapshot
|
||||
project.UUID = payload.ProjectUUID
|
||||
|
||||
serverProject, err := projectRepo.GetByUUID(project.UUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if createErr := projectRepo.Create(&project); createErr != nil {
|
||||
return fmt.Errorf("create project on server: %w", createErr)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("get project on server: %w", err)
|
||||
}
|
||||
} else {
|
||||
project.ID = serverProject.ID
|
||||
if updateErr := projectRepo.Update(&project); updateErr != nil {
|
||||
return fmt.Errorf("update project on server: %w", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
if localErr == nil {
|
||||
if project.ID > 0 {
|
||||
serverID := project.ID
|
||||
localProject.ServerID = &serverID
|
||||
}
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProject(localProject)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
||||
var payload ProjectChangePayload
|
||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
||||
if payload.Operation == "" {
|
||||
payload.Operation = change.Operation
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
var project models.Project
|
||||
if err := json.Unmarshal([]byte(change.Payload), &project); err != nil {
|
||||
return ProjectChangePayload{}, fmt.Errorf("unmarshal legacy project payload: %w", err)
|
||||
}
|
||||
|
||||
return ProjectChangePayload{
|
||||
ProjectUUID: project.UUID,
|
||||
Operation: change.Operation,
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", project.UUID, change.Operation),
|
||||
Snapshot: project,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pushConfigurationChange pushes a configuration change to the server
|
||||
func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
switch change.Operation {
|
||||
@@ -485,7 +597,7 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -495,6 +607,9 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration owner: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration project: %w", err)
|
||||
}
|
||||
|
||||
// Create on server
|
||||
if err := configRepo.Create(&cfg); err != nil {
|
||||
@@ -540,7 +655,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -550,6 +665,9 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration owner: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration project: %w", err)
|
||||
}
|
||||
|
||||
// Ensure we have a server ID before updating
|
||||
// If the payload doesn't have ID, get it from local configuration
|
||||
@@ -620,6 +738,69 @@ func (s *Service) ensureConfigurationOwner(mariaDB *gorm.DB, cfg *models.Configu
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Configuration) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("configuration is nil")
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
|
||||
if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" {
|
||||
_, err := projectRepo.GetByUUID(*cfg.ProjectUUID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
||||
if localErr != nil {
|
||||
return err
|
||||
}
|
||||
modelProject := localdb.LocalToProject(localProject)
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.Create(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
serverID := modelProject.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProject(localProject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
systemProject := &models.Project{}
|
||||
err := mariaDB.
|
||||
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(systemProject).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
systemProject = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
}
|
||||
if createErr := projectRepo.Create(systemProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ProjectUUID = &systemProject.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
|
||||
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
@@ -703,6 +884,7 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
|
||||
EventID: "",
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
Operation: change.Operation,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: cfg,
|
||||
@@ -759,7 +941,7 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
||||
// pushConfigurationDelete deletes a configuration from the server
|
||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -783,3 +965,23 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
slog.Info("configuration deleted on server", "uuid", change.EntityUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) getDB() (*gorm.DB, error) {
|
||||
if s.directDB != nil {
|
||||
return s.directDB, nil
|
||||
}
|
||||
if s.connMgr == nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
return s.connMgr.GetDB()
|
||||
}
|
||||
|
||||
func (s *Service) getConnectionStatus() db.ConnectionStatus {
|
||||
if s.directDB != nil {
|
||||
return db.ConnectionStatus{IsConnected: true}
|
||||
}
|
||||
if s.connMgr == nil {
|
||||
return db.ConnectionStatus{IsConnected: false}
|
||||
}
|
||||
return s.connMgr.GetStatus()
|
||||
}
|
||||
|
||||
25
internal/services/sync/service_order_test.go
Normal file
25
internal/services/sync/service_order_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
func TestPrioritizeProjectChanges(t *testing.T) {
|
||||
changes := []localdb.PendingChange{
|
||||
{ID: 1, EntityType: "configuration"},
|
||||
{ID: 2, EntityType: "project"},
|
||||
{ID: 3, EntityType: "configuration"},
|
||||
{ID: 4, EntityType: "project"},
|
||||
}
|
||||
|
||||
sorted := prioritizeProjectChanges(changes)
|
||||
if len(sorted) != 4 {
|
||||
t.Fatalf("unexpected sorted length: %d", len(sorted))
|
||||
}
|
||||
|
||||
if sorted[0].EntityType != "project" || sorted[1].EntityType != "project" {
|
||||
t.Fatalf("expected project changes first, got order: %s, %s", sorted[0].EntityType, sorted[1].EntityType)
|
||||
}
|
||||
}
|
||||
273
internal/services/sync/service_projects_push_test.go
Normal file
273
internal/services/sync/service_projects_push_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package sync_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
localSync := syncsvc.NewService(nil, local)
|
||||
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"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "Cfg A",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: &project.UUID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
pushed, err := pushService.PushPendingChanges()
|
||||
if err != nil {
|
||||
t.Fatalf("push pending changes: %v", err)
|
||||
}
|
||||
if pushed < 2 {
|
||||
t.Fatalf("expected at least 2 pushed changes, got %d", pushed)
|
||||
}
|
||||
|
||||
var serverProject models.Project
|
||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
||||
t.Fatalf("project not pushed to server: %v", err)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
|
||||
t.Fatalf("configuration not pushed to server: %v", err)
|
||||
}
|
||||
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
|
||||
}
|
||||
|
||||
if got := local.CountPendingChanges(); got != 0 {
|
||||
t.Fatalf("expected pending queue to be empty, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
localSync := syncsvc.NewService(nil, local)
|
||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
|
||||
created, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "Cfg v1",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("initial push: %v", err)
|
||||
}
|
||||
|
||||
if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{
|
||||
Name: "Cfg v2",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: created.ProjectUUID,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
|
||||
localCfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local config: %v", err)
|
||||
}
|
||||
cfgSnapshot := localdb.LocalToConfiguration(localCfg)
|
||||
stalePayload := syncsvc.ConfigurationChangePayload{
|
||||
EventID: "stale-event",
|
||||
IdempotencyKey: fmt.Sprintf("%s:v1:update", created.UUID),
|
||||
ConfigurationUUID: created.UUID,
|
||||
ProjectUUID: cfgSnapshot.ProjectUUID,
|
||||
Operation: "update",
|
||||
CurrentVersionID: "stale-v1",
|
||||
CurrentVersionNo: 1,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: *cfgSnapshot,
|
||||
CreatedAt: time.Now().UTC().Add(-2 * time.Second),
|
||||
}
|
||||
raw, err := json.Marshal(stalePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stale payload: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.PendingChange{
|
||||
EntityType: "configuration",
|
||||
EntityUUID: created.UUID,
|
||||
Operation: "update",
|
||||
Payload: string(raw),
|
||||
CreatedAt: time.Now().Add(-1 * time.Second),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert stale pending change: %v", err)
|
||||
}
|
||||
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push pending with stale event: %v", err)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
|
||||
t.Fatalf("get server config: %v", err)
|
||||
}
|
||||
if serverCfg.Name != "Cfg v2" {
|
||||
t.Fatalf("expected latest name to win, got %q", serverCfg.Name)
|
||||
}
|
||||
if got := local.CountPendingChanges(); got != 0 {
|
||||
t.Fatalf("expected empty pending queue, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesCreateIsIdempotent(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
localSync := syncsvc.NewService(nil, local)
|
||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
|
||||
created, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "Cfg Idempotent",
|
||||
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 500}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("initial push: %v", err)
|
||||
}
|
||||
|
||||
localCfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local config: %v", err)
|
||||
}
|
||||
currentVersionNo, currentVersionID := getCurrentVersionInfo(t, local, created.UUID, localCfg.CurrentVersionID)
|
||||
cfgSnapshot := localdb.LocalToConfiguration(localCfg)
|
||||
duplicatePayload := syncsvc.ConfigurationChangePayload{
|
||||
EventID: "duplicate-create-event",
|
||||
IdempotencyKey: fmt.Sprintf("%s:v%d:create", created.UUID, currentVersionNo),
|
||||
ConfigurationUUID: created.UUID,
|
||||
ProjectUUID: cfgSnapshot.ProjectUUID,
|
||||
Operation: "create",
|
||||
CurrentVersionID: currentVersionID,
|
||||
CurrentVersionNo: currentVersionNo,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: *cfgSnapshot,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
raw, err := json.Marshal(duplicatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal duplicate payload: %v", err)
|
||||
}
|
||||
if err := local.AddPendingChange("configuration", created.UUID, "create", string(raw)); err != nil {
|
||||
t.Fatalf("add duplicate create pending change: %v", err)
|
||||
}
|
||||
|
||||
if pushed, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push duplicate create: %v", err)
|
||||
} else if pushed != 1 {
|
||||
t.Fatalf("expected 1 pushed change for duplicate create, got %d", pushed)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := serverDB.Model(&models.Configuration{}).Where("uuid = ?", created.UUID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count server configs: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one server row after idempotent create, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB {
|
||||
t.Helper()
|
||||
localPath := filepath.Join(t.TempDir(), "local.db")
|
||||
local, err := localdb.New(localPath)
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
return local
|
||||
}
|
||||
|
||||
func newServerDBForSyncTest(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
serverPath := filepath.Join(t.TempDir(), "server.db")
|
||||
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open server sqlite: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
project_uuid TEXT NULL,
|
||||
app_version TEXT NULL,
|
||||
name TEXT NOT NULL,
|
||||
items TEXT NOT NULL,
|
||||
total_price REAL NULL,
|
||||
custom_price REAL NULL,
|
||||
notes TEXT NULL,
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
server_count INTEGER NOT NULL DEFAULT 1,
|
||||
price_updated_at DATETIME NULL,
|
||||
created_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_configurations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||
t.Helper()
|
||||
if currentVersionID == nil || *currentVersionID == "" {
|
||||
t.Fatalf("current version id is empty for %s", configurationUUID)
|
||||
}
|
||||
|
||||
var version localdb.LocalConfigurationVersion
|
||||
if err := local.DB().
|
||||
Where("id = ? AND configuration_uuid = ?", *currentVersionID, configurationUUID).
|
||||
First(&version).Error; err != nil {
|
||||
t.Fatalf("get current version info: %v", err)
|
||||
}
|
||||
|
||||
return version.VersionNo, version.ID
|
||||
}
|
||||
Reference in New Issue
Block a user