1119 lines
33 KiB
Go
1119 lines
33 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
|
"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"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
var (
|
|
ErrConfigVersionNotFound = errors.New("configuration version not found")
|
|
ErrInvalidVersionNumber = errors.New("invalid version number")
|
|
ErrVersionConflict = errors.New("configuration version conflict")
|
|
)
|
|
|
|
// LocalConfigurationService handles configurations in local-first mode
|
|
// All operations go through SQLite, MariaDB is used only for sync
|
|
type LocalConfigurationService struct {
|
|
localDB *localdb.LocalDB
|
|
syncService *sync.Service
|
|
quoteService *QuoteService
|
|
isOnline func() bool // Function to check if we're online
|
|
}
|
|
|
|
// NewLocalConfigurationService creates a new local-first configuration service
|
|
func NewLocalConfigurationService(
|
|
localDB *localdb.LocalDB,
|
|
syncService *sync.Service,
|
|
quoteService *QuoteService,
|
|
isOnline func() bool,
|
|
) *LocalConfigurationService {
|
|
return &LocalConfigurationService{
|
|
localDB: localDB,
|
|
syncService: syncService,
|
|
quoteService: quoteService,
|
|
isOnline: isOnline,
|
|
}
|
|
}
|
|
|
|
// Create creates a new configuration in local SQLite and queues it for sync
|
|
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
|
// If online, check for new pricelists first
|
|
if s.isOnline() {
|
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
|
// Log but don't fail - we can still use local pricelists
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
cfg := &models.Configuration{
|
|
UUID: uuid.New().String(),
|
|
OwnerUsername: ownerUsername,
|
|
ProjectUUID: projectUUID,
|
|
Name: req.Name,
|
|
Items: req.Items,
|
|
TotalPrice: &total,
|
|
CustomPrice: req.CustomPrice,
|
|
Notes: req.Notes,
|
|
IsTemplate: req.IsTemplate,
|
|
ServerCount: req.ServerCount,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// Convert to local model
|
|
localCfg := localdb.ConfigurationToLocal(cfg)
|
|
|
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
|
return nil, fmt.Errorf("create configuration with version: %w", err)
|
|
}
|
|
|
|
// Record usage stats
|
|
_ = s.quoteService.RecordUsage(req.Items)
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// GetByUUID returns a configuration from local SQLite
|
|
func (s *LocalConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
if !localCfg.IsActive {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
|
|
// Convert to models.Configuration
|
|
cfg := localdb.LocalToConfiguration(localCfg)
|
|
|
|
// Allow access if user owns config or it's a template
|
|
if !s.isOwner(localCfg, ownerUsername) && !cfg.IsTemplate {
|
|
return nil, ErrConfigForbidden
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Update updates a configuration in local SQLite and queues it for sync
|
|
func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
|
|
if !s.isOwner(localCfg, ownerUsername) {
|
|
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)
|
|
}
|
|
|
|
// 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{
|
|
LotName: item.LotName,
|
|
Quantity: item.Quantity,
|
|
UnitPrice: item.UnitPrice,
|
|
})
|
|
}
|
|
localCfg.TotalPrice = &total
|
|
localCfg.CustomPrice = req.CustomPrice
|
|
localCfg.Notes = req.Notes
|
|
localCfg.IsTemplate = req.IsTemplate
|
|
localCfg.ServerCount = req.ServerCount
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
|
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update configuration with version: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Delete deletes a configuration from local SQLite and queues it for sync
|
|
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return ErrConfigNotFound
|
|
}
|
|
|
|
if !s.isOwner(localCfg, ownerUsername) {
|
|
return ErrConfigForbidden
|
|
}
|
|
|
|
localCfg.IsActive = false
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", ownerUsername)
|
|
return err
|
|
}
|
|
|
|
// Reactivate restores an archived configuration and creates a new version.
|
|
func (s *LocalConfigurationService) Reactivate(uuid string, ownerUsername string) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
if !s.isOwner(localCfg, ownerUsername) {
|
|
return nil, ErrConfigForbidden
|
|
}
|
|
if localCfg.IsActive {
|
|
return localdb.LocalToConfiguration(localCfg), nil
|
|
}
|
|
|
|
localCfg.IsActive = true
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
return s.saveWithVersionAndPending(localCfg, "reactivate", ownerUsername)
|
|
}
|
|
|
|
// Rename renames a configuration
|
|
func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
|
|
if !s.isOwner(localCfg, ownerUsername) {
|
|
return nil, ErrConfigForbidden
|
|
}
|
|
|
|
localCfg.Name = newName
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
|
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rename configuration with version: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// 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 {
|
|
total *= float64(original.ServerCount)
|
|
}
|
|
|
|
clone := &models.Configuration{
|
|
UUID: uuid.New().String(),
|
|
OwnerUsername: ownerUsername,
|
|
ProjectUUID: resolvedProjectUUID,
|
|
Name: newName,
|
|
Items: original.Items,
|
|
TotalPrice: &total,
|
|
CustomPrice: original.CustomPrice,
|
|
Notes: original.Notes,
|
|
IsTemplate: false,
|
|
ServerCount: original.ServerCount,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
localCfg := localdb.ConfigurationToLocal(clone)
|
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
|
return nil, fmt.Errorf("clone configuration with version: %w", err)
|
|
}
|
|
|
|
return clone, nil
|
|
}
|
|
|
|
// ListByUser returns all configurations for a user from local SQLite
|
|
func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
|
|
return s.listByUserWithStatus(ownerUsername, page, perPage, "active")
|
|
}
|
|
|
|
func (s *LocalConfigurationService) listByUserWithStatus(ownerUsername string, page, perPage int, status string) ([]models.Configuration, int64, error) {
|
|
// Get all local configurations
|
|
localConfigs, err := s.localDB.GetConfigurations()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Filter by user
|
|
var userConfigs []models.Configuration
|
|
for _, lc := range localConfigs {
|
|
if !matchesConfigStatus(lc.IsActive, status) {
|
|
continue
|
|
}
|
|
if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
|
|
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
|
|
}
|
|
}
|
|
|
|
total := int64(len(userConfigs))
|
|
|
|
// Apply pagination
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 || perPage > 100 {
|
|
perPage = 20
|
|
}
|
|
offset := (page - 1) * perPage
|
|
|
|
start := offset
|
|
if start > len(userConfigs) {
|
|
start = len(userConfigs)
|
|
}
|
|
end := start + perPage
|
|
if end > len(userConfigs) {
|
|
end = len(userConfigs)
|
|
}
|
|
|
|
return userConfigs[start:end], total, nil
|
|
}
|
|
|
|
// RefreshPrices updates all component prices in the configuration from local cache
|
|
func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
|
|
// Get configuration from local SQLite
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
|
|
// Check ownership
|
|
if !s.isOwner(localCfg, ownerUsername) {
|
|
return nil, ErrConfigForbidden
|
|
}
|
|
|
|
// Update prices for all items
|
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
|
for i, item := range localCfg.Items {
|
|
// Get current component price from local cache
|
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
|
if err != nil || component.CurrentPrice == nil {
|
|
// Keep original item if component not found or no price available
|
|
updatedItems[i] = item
|
|
continue
|
|
}
|
|
|
|
// Update item with current price from local cache
|
|
updatedItems[i] = localdb.LocalConfigItem{
|
|
LotName: item.LotName,
|
|
Quantity: item.Quantity,
|
|
UnitPrice: *component.CurrentPrice,
|
|
}
|
|
}
|
|
|
|
// Update configuration
|
|
localCfg.Items = updatedItems
|
|
total := updatedItems.Total()
|
|
|
|
// If server count is greater than 1, multiply the total by server count
|
|
if localCfg.ServerCount > 1 {
|
|
total *= float64(localCfg.ServerCount)
|
|
}
|
|
|
|
localCfg.TotalPrice = &total
|
|
|
|
// Set price update timestamp and mark for sync
|
|
now := time.Now()
|
|
localCfg.PriceUpdatedAt = &now
|
|
localCfg.UpdatedAt = now
|
|
localCfg.SyncStatus = "pending"
|
|
|
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh prices with version: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// GetByUUIDNoAuth returns configuration without ownership check
|
|
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
if !localCfg.IsActive {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
return localdb.LocalToConfiguration(localCfg), nil
|
|
}
|
|
|
|
// UpdateNoAuth updates configuration without ownership check
|
|
func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
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{
|
|
LotName: item.LotName,
|
|
Quantity: item.Quantity,
|
|
UnitPrice: item.UnitPrice,
|
|
})
|
|
}
|
|
localCfg.TotalPrice = &total
|
|
localCfg.CustomPrice = req.CustomPrice
|
|
localCfg.Notes = req.Notes
|
|
localCfg.IsTemplate = req.IsTemplate
|
|
localCfg.ServerCount = req.ServerCount
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
|
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update configuration without auth with version: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// DeleteNoAuth deletes configuration without ownership check
|
|
func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return ErrConfigNotFound
|
|
}
|
|
localCfg.IsActive = false
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", "")
|
|
return err
|
|
}
|
|
|
|
// ReactivateNoAuth restores an archived configuration without ownership check.
|
|
func (s *LocalConfigurationService) ReactivateNoAuth(uuid string) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
if localCfg.IsActive {
|
|
return localdb.LocalToConfiguration(localCfg), nil
|
|
}
|
|
|
|
localCfg.IsActive = true
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
return s.saveWithVersionAndPending(localCfg, "reactivate", "")
|
|
}
|
|
|
|
// RenameNoAuth renames configuration without ownership check
|
|
func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
|
|
localCfg.Name = newName
|
|
localCfg.UpdatedAt = time.Now()
|
|
localCfg.SyncStatus = "pending"
|
|
|
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rename configuration without auth with version: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// 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 {
|
|
total *= float64(original.ServerCount)
|
|
}
|
|
|
|
clone := &models.Configuration{
|
|
UUID: uuid.New().String(),
|
|
OwnerUsername: ownerUsername,
|
|
ProjectUUID: resolvedProjectUUID,
|
|
Name: newName,
|
|
Items: original.Items,
|
|
TotalPrice: &total,
|
|
CustomPrice: original.CustomPrice,
|
|
Notes: original.Notes,
|
|
IsTemplate: false,
|
|
ServerCount: original.ServerCount,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
localCfg := localdb.ConfigurationToLocal(clone)
|
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
|
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
|
|
}
|
|
|
|
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", "")
|
|
}
|
|
|
|
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
|
|
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))
|
|
}
|
|
|
|
total := int64(len(configs))
|
|
|
|
// Apply pagination
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 || perPage > 100 {
|
|
perPage = 20
|
|
}
|
|
offset := (page - 1) * perPage
|
|
|
|
start := offset
|
|
if start > len(configs) {
|
|
start = len(configs)
|
|
}
|
|
end := start + perPage
|
|
if end > len(configs) {
|
|
end = len(configs)
|
|
}
|
|
|
|
return configs[start:end], total, nil
|
|
}
|
|
|
|
// ListTemplates returns all template configurations
|
|
func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
|
localConfigs, err := s.localDB.GetConfigurations()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var templates []models.Configuration
|
|
for _, lc := range localConfigs {
|
|
if !lc.IsActive {
|
|
continue
|
|
}
|
|
if lc.IsTemplate {
|
|
templates = append(templates, *localdb.LocalToConfiguration(&lc))
|
|
}
|
|
}
|
|
|
|
total := int64(len(templates))
|
|
|
|
// Apply pagination
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 || perPage > 100 {
|
|
perPage = 20
|
|
}
|
|
offset := (page - 1) * perPage
|
|
|
|
start := offset
|
|
if start > len(templates) {
|
|
start = len(templates)
|
|
}
|
|
end := start + perPage
|
|
if end > len(templates) {
|
|
end = len(templates)
|
|
}
|
|
|
|
return templates[start:end], total, nil
|
|
}
|
|
|
|
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
|
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
|
// Get configuration from local SQLite
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
|
if err != nil {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
|
|
// Update prices for all items
|
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
|
for i, item := range localCfg.Items {
|
|
// Get current component price from local cache
|
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
|
if err != nil || component.CurrentPrice == nil {
|
|
// Keep original item if component not found or no price available
|
|
updatedItems[i] = item
|
|
continue
|
|
}
|
|
|
|
// Update item with current price from local cache
|
|
updatedItems[i] = localdb.LocalConfigItem{
|
|
LotName: item.LotName,
|
|
Quantity: item.Quantity,
|
|
UnitPrice: *component.CurrentPrice,
|
|
}
|
|
}
|
|
|
|
// Update configuration
|
|
localCfg.Items = updatedItems
|
|
total := updatedItems.Total()
|
|
|
|
// If server count is greater than 1, multiply the total by server count
|
|
if localCfg.ServerCount > 1 {
|
|
total *= float64(localCfg.ServerCount)
|
|
}
|
|
|
|
localCfg.TotalPrice = &total
|
|
|
|
// Set price update timestamp and mark for sync
|
|
now := time.Now()
|
|
localCfg.PriceUpdatedAt = &now
|
|
localCfg.UpdatedAt = now
|
|
localCfg.SyncStatus = "pending"
|
|
|
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
|
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
|
return s.syncService.ImportConfigurationsToLocal()
|
|
}
|
|
|
|
// GetCurrentVersion returns the currently active version row for configuration UUID.
|
|
func (s *LocalConfigurationService) GetCurrentVersion(configurationUUID string) (*localdb.LocalConfigurationVersion, error) {
|
|
var cfg localdb.LocalConfiguration
|
|
if err := s.localDB.DB().Where("uuid = ?", configurationUUID).First(&cfg).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
return nil, fmt.Errorf("get configuration for current version: %w", err)
|
|
}
|
|
|
|
var version localdb.LocalConfigurationVersion
|
|
if cfg.CurrentVersionID != nil && *cfg.CurrentVersionID != "" {
|
|
if err := s.localDB.DB().
|
|
Where("id = ? AND configuration_uuid = ?", *cfg.CurrentVersionID, configurationUUID).
|
|
First(&version).Error; err == nil {
|
|
return &version, nil
|
|
}
|
|
}
|
|
|
|
if err := s.localDB.DB().
|
|
Where("configuration_uuid = ?", configurationUUID).
|
|
Order("version_no DESC").
|
|
First(&version).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrConfigVersionNotFound
|
|
}
|
|
return nil, fmt.Errorf("get latest version for current pointer fallback: %w", err)
|
|
}
|
|
|
|
return &version, nil
|
|
}
|
|
|
|
// ListVersions returns versions by configuration UUID in descending order by version number.
|
|
func (s *LocalConfigurationService) ListVersions(configurationUUID string, limit, offset int) ([]localdb.LocalConfigurationVersion, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
if offset < 0 {
|
|
return nil, ErrInvalidVersionNumber
|
|
}
|
|
|
|
var cfgCount int64
|
|
if err := s.localDB.DB().Model(&localdb.LocalConfiguration{}).
|
|
Where("uuid = ?", configurationUUID).
|
|
Count(&cfgCount).Error; err != nil {
|
|
return nil, fmt.Errorf("check configuration before list versions: %w", err)
|
|
}
|
|
if cfgCount == 0 {
|
|
return nil, ErrConfigNotFound
|
|
}
|
|
|
|
var versions []localdb.LocalConfigurationVersion
|
|
if err := s.localDB.DB().
|
|
Where("configuration_uuid = ?", configurationUUID).
|
|
Order("version_no DESC").
|
|
Limit(limit).
|
|
Offset(offset).
|
|
Find(&versions).Error; err != nil {
|
|
return nil, fmt.Errorf("list versions: %w", err)
|
|
}
|
|
|
|
return versions, nil
|
|
}
|
|
|
|
// GetVersion returns one version by configuration UUID and version number.
|
|
func (s *LocalConfigurationService) GetVersion(configurationUUID string, versionNo int) (*localdb.LocalConfigurationVersion, error) {
|
|
if versionNo <= 0 {
|
|
return nil, ErrInvalidVersionNumber
|
|
}
|
|
|
|
var version localdb.LocalConfigurationVersion
|
|
if err := s.localDB.DB().
|
|
Where("configuration_uuid = ? AND version_no = ?", configurationUUID, versionNo).
|
|
First(&version).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrConfigVersionNotFound
|
|
}
|
|
return nil, fmt.Errorf("get version %d for %s: %w", versionNo, configurationUUID, err)
|
|
}
|
|
|
|
return &version, nil
|
|
}
|
|
|
|
// RollbackToVersion creates a new version from target snapshot and marks it current.
|
|
func (s *LocalConfigurationService) RollbackToVersion(configurationUUID string, targetVersionNo int, userID string) (*models.Configuration, error) {
|
|
return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, "")
|
|
}
|
|
|
|
// RollbackToVersionWithNote same as RollbackToVersion, with optional user note.
|
|
func (s *LocalConfigurationService) RollbackToVersionWithNote(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) {
|
|
return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, note)
|
|
}
|
|
|
|
func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, ownerUsername string) bool {
|
|
if cfg == nil || ownerUsername == "" {
|
|
return false
|
|
}
|
|
if cfg.OriginalUsername != "" {
|
|
return cfg.OriginalUsername == ownerUsername
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(localCfg).Error; err != nil {
|
|
return fmt.Errorf("create local configuration: %w", err)
|
|
}
|
|
|
|
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
|
|
if err != nil {
|
|
return fmt.Errorf("append create version: %w", err)
|
|
}
|
|
|
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
|
Where("uuid = ?", localCfg.UUID).
|
|
Update("current_version_id", version.ID).Error; err != nil {
|
|
return fmt.Errorf("set current version id: %w", err)
|
|
}
|
|
localCfg.CurrentVersionID = &version.ID
|
|
|
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
|
return fmt.Errorf("enqueue create pending change: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
|
|
var cfg *models.Configuration
|
|
|
|
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
|
var locked localdb.LocalConfiguration
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("uuid = ?", localCfg.UUID).
|
|
First(&locked).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrConfigNotFound
|
|
}
|
|
return fmt.Errorf("lock configuration row: %w", err)
|
|
}
|
|
|
|
if err := tx.Save(localCfg).Error; err != nil {
|
|
return fmt.Errorf("save local configuration: %w", err)
|
|
}
|
|
|
|
version, err := s.appendVersionTx(tx, localCfg, operation, createdBy)
|
|
if err != nil {
|
|
return fmt.Errorf("append %s version: %w", operation, err)
|
|
}
|
|
|
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
|
Where("uuid = ?", localCfg.UUID).
|
|
Update("current_version_id", version.ID).Error; err != nil {
|
|
return fmt.Errorf("update current version id: %w", err)
|
|
}
|
|
localCfg.CurrentVersionID = &version.ID
|
|
|
|
cfg = localdb.LocalToConfiguration(localCfg)
|
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
|
return fmt.Errorf("enqueue %s pending change: %w", operation, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func (s *LocalConfigurationService) appendVersionTx(
|
|
tx *gorm.DB,
|
|
localCfg *localdb.LocalConfiguration,
|
|
operation string,
|
|
createdBy string,
|
|
) (*localdb.LocalConfigurationVersion, error) {
|
|
snapshot, err := s.buildConfigurationSnapshot(localCfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build snapshot: %w", err)
|
|
}
|
|
changeNote := fmt.Sprintf("%s via local-first flow", operation)
|
|
|
|
var createdByPtr *string
|
|
if createdBy != "" {
|
|
createdByPtr = &createdBy
|
|
}
|
|
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
var maxVersion int
|
|
if err := tx.Model(&localdb.LocalConfigurationVersion{}).
|
|
Where("configuration_uuid = ?", localCfg.UUID).
|
|
Select("COALESCE(MAX(version_no), 0)").
|
|
Scan(&maxVersion).Error; err != nil {
|
|
return nil, fmt.Errorf("read max version: %w", err)
|
|
}
|
|
|
|
versionID := uuid.New().String()
|
|
version := &localdb.LocalConfigurationVersion{
|
|
ID: versionID,
|
|
ConfigurationUUID: localCfg.UUID,
|
|
VersionNo: maxVersion + 1,
|
|
Data: snapshot,
|
|
ChangeNote: &changeNote,
|
|
CreatedBy: createdByPtr,
|
|
AppVersion: appmeta.Version(),
|
|
}
|
|
|
|
if err := tx.Create(version).Error; err != nil {
|
|
// SQLite equivalent safety: serialized writer tx + UNIQUE(configuration_uuid, version_no) + retry.
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("insert configuration version: %w", err)
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
|
|
}
|
|
|
|
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
|
|
return localdb.BuildConfigurationSnapshot(localCfg)
|
|
}
|
|
|
|
func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) {
|
|
if targetVersionNo <= 0 {
|
|
return nil, ErrInvalidVersionNumber
|
|
}
|
|
|
|
var resultCfg *models.Configuration
|
|
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
|
var current localdb.LocalConfiguration
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("uuid = ?", configurationUUID).
|
|
First(¤t).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrConfigNotFound
|
|
}
|
|
return fmt.Errorf("lock configuration for rollback: %w", err)
|
|
}
|
|
|
|
var target localdb.LocalConfigurationVersion
|
|
if err := tx.Where("configuration_uuid = ? AND version_no = ?", configurationUUID, targetVersionNo).
|
|
First(&target).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrConfigVersionNotFound
|
|
}
|
|
return fmt.Errorf("load target rollback version: %w", err)
|
|
}
|
|
|
|
rollbackData, err := s.decodeConfigurationSnapshot(target.Data)
|
|
if err != nil {
|
|
return fmt.Errorf("decode target rollback snapshot: %w", err)
|
|
}
|
|
|
|
// Keep stable identity/sync linkage; restore editable config content from target snapshot.
|
|
current.Name = rollbackData.Name
|
|
current.Items = rollbackData.Items
|
|
current.TotalPrice = rollbackData.TotalPrice
|
|
current.CustomPrice = rollbackData.CustomPrice
|
|
current.Notes = rollbackData.Notes
|
|
current.IsTemplate = rollbackData.IsTemplate
|
|
current.ServerCount = rollbackData.ServerCount
|
|
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
|
current.UpdatedAt = time.Now()
|
|
current.SyncStatus = "pending"
|
|
current.IsActive = rollbackData.IsActive
|
|
|
|
if rollbackData.OriginalUsername != "" {
|
|
current.OriginalUsername = rollbackData.OriginalUsername
|
|
}
|
|
if rollbackData.OriginalUserID != 0 {
|
|
current.OriginalUserID = rollbackData.OriginalUserID
|
|
}
|
|
|
|
if err := tx.Save(¤t).Error; err != nil {
|
|
return fmt.Errorf("save rolled back configuration: %w", err)
|
|
}
|
|
|
|
var maxVersion int
|
|
if err := tx.Model(&localdb.LocalConfigurationVersion{}).
|
|
Where("configuration_uuid = ?", configurationUUID).
|
|
Select("COALESCE(MAX(version_no), 0)").
|
|
Scan(&maxVersion).Error; err != nil {
|
|
return fmt.Errorf("read max version before rollback append: %w", err)
|
|
}
|
|
|
|
changeNote := fmt.Sprintf("rollback to v%d", targetVersionNo)
|
|
if trimmed := strings.TrimSpace(note); trimmed != "" {
|
|
changeNote = fmt.Sprintf("%s (%s)", changeNote, trimmed)
|
|
}
|
|
|
|
version := &localdb.LocalConfigurationVersion{
|
|
ID: uuid.New().String(),
|
|
ConfigurationUUID: configurationUUID,
|
|
VersionNo: maxVersion + 1,
|
|
Data: target.Data,
|
|
ChangeNote: &changeNote,
|
|
CreatedBy: stringPtrOrNil(userID),
|
|
AppVersion: appmeta.Version(),
|
|
}
|
|
|
|
if err := tx.Create(version).Error; err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") {
|
|
return ErrVersionConflict
|
|
}
|
|
return fmt.Errorf("create rollback version: %w", err)
|
|
}
|
|
|
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
|
Where("uuid = ?", configurationUUID).
|
|
Update("current_version_id", version.ID).Error; err != nil {
|
|
return fmt.Errorf("update current version after rollback: %w", err)
|
|
}
|
|
current.CurrentVersionID = &version.ID
|
|
|
|
resultCfg = localdb.LocalToConfiguration(¤t)
|
|
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "rollback", version, userID); err != nil {
|
|
return fmt.Errorf("enqueue rollback pending change: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return resultCfg, nil
|
|
}
|
|
|
|
func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
|
|
tx *gorm.DB,
|
|
localCfg *localdb.LocalConfiguration,
|
|
operation string,
|
|
version *localdb.LocalConfigurationVersion,
|
|
createdBy string,
|
|
) error {
|
|
cfg := localdb.LocalToConfiguration(localCfg)
|
|
payload := sync.ConfigurationChangePayload{
|
|
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,
|
|
ConflictPolicy: "last_write_wins",
|
|
Snapshot: *cfg,
|
|
CreatedAt: time.Now().UTC(),
|
|
CreatedBy: stringPtrOrNil(createdBy),
|
|
}
|
|
|
|
rawPayload, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal pending payload: %w", err)
|
|
}
|
|
|
|
change := &localdb.PendingChange{
|
|
EntityType: "configuration",
|
|
EntityUUID: localCfg.UUID,
|
|
Operation: operation,
|
|
Payload: string(rawPayload),
|
|
CreatedAt: time.Now(),
|
|
Attempts: 0,
|
|
}
|
|
if err := tx.Create(change).Error; err != nil {
|
|
return fmt.Errorf("insert pending change: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*localdb.LocalConfiguration, error) {
|
|
return localdb.DecodeConfigurationSnapshot(data)
|
|
}
|
|
|
|
func stringPtrOrNil(value string) *string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
return &trimmed
|
|
}
|
|
|
|
func matchesConfigStatus(isActive bool, status string) bool {
|
|
switch status {
|
|
case "active", "":
|
|
return isActive
|
|
case "archived":
|
|
return !isActive
|
|
case "all":
|
|
return true
|
|
default:
|
|
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
|
|
}
|