402 lines
11 KiB
Go
402 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"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")
|
|
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 {
|
|
localDB *localdb.LocalDB
|
|
}
|
|
|
|
func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
|
return &ProjectService{localDB: localDB}
|
|
}
|
|
|
|
type CreateProjectRequest struct {
|
|
Code string `json:"code"`
|
|
Variant string `json:"variant,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
TrackerURL string `json:"tracker_url"`
|
|
}
|
|
|
|
type UpdateProjectRequest struct {
|
|
Code *string `json:"code,omitempty"`
|
|
Variant *string `json:"variant,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
TrackerURL *string `json:"tracker_url,omitempty"`
|
|
}
|
|
|
|
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) {
|
|
var namePtr *string
|
|
if req.Name != nil {
|
|
name := strings.TrimSpace(*req.Name)
|
|
if name != "" {
|
|
namePtr = &name
|
|
}
|
|
}
|
|
code := strings.TrimSpace(req.Code)
|
|
if code == "" {
|
|
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
|
|
}
|
|
|
|
now := time.Now()
|
|
localProject := &localdb.LocalProject{
|
|
UUID: uuid.NewString(),
|
|
OwnerUsername: ownerUsername,
|
|
Code: code,
|
|
Variant: variant,
|
|
Name: namePtr,
|
|
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
|
|
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 req.Code != nil {
|
|
code := strings.TrimSpace(*req.Code)
|
|
if code == "" {
|
|
return nil, fmt.Errorf("project code is required")
|
|
}
|
|
localProject.Code = code
|
|
}
|
|
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
|
|
}
|
|
|
|
if req.Name != nil {
|
|
name := strings.TrimSpace(*req.Name)
|
|
if name == "" {
|
|
localProject.Name = nil
|
|
} else {
|
|
localProject.Name = &name
|
|
}
|
|
}
|
|
if req.TrackerURL != nil {
|
|
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
|
|
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
|
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
|
|
}
|
|
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) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
|
normalizedCode := normalizeProjectCode(code)
|
|
normalizedVariant := normalizeProjectVariant(variant)
|
|
if normalizedCode == "" {
|
|
return fmt.Errorf("project code is required")
|
|
}
|
|
|
|
projects, err := s.localDB.GetAllProjects(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range projects {
|
|
project := projects[i]
|
|
if excludeUUID != "" && project.UUID == excludeUUID {
|
|
continue
|
|
}
|
|
if normalizeProjectCode(project.Code) == normalizedCode &&
|
|
normalizeProjectVariant(project.Variant) == normalizedVariant {
|
|
return ErrProjectCodeExists
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeProjectCode(code string) string {
|
|
return strings.ToLower(strings.TrimSpace(code))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
|
return s.setProjectActive(projectUUID, ownerUsername, true)
|
|
}
|
|
|
|
func (s *ProjectService) DeleteVariant(projectUUID, ownerUsername string) error {
|
|
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
|
if err != nil {
|
|
return ErrProjectNotFound
|
|
}
|
|
if strings.TrimSpace(localProject.Variant) == "" {
|
|
return ErrCannotDeleteMainVariant
|
|
}
|
|
return s.setProjectActive(projectUUID, ownerUsername, false)
|
|
}
|
|
|
|
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.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
|
|
}
|
|
|
|
query := s.localDB.DB().
|
|
Preload("CurrentVersion").
|
|
Where("project_uuid = ?", projectUUID).
|
|
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC")
|
|
|
|
switch status {
|
|
case "active", "":
|
|
query = query.Where("is_active = ?", true)
|
|
case "archived":
|
|
query = query.Where("is_active = ?", false)
|
|
case "all":
|
|
default:
|
|
query = query.Where("is_active = ?", true)
|
|
}
|
|
|
|
var localConfigs []localdb.LocalConfiguration
|
|
if err := query.Find(&localConfigs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
configs := make([]models.Configuration, 0, len(localConfigs))
|
|
total := 0.0
|
|
for i := range localConfigs {
|
|
localCfg := localConfigs[i]
|
|
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 normalizeProjectTrackerURL(projectCode, trackerURL string) string {
|
|
trimmedURL := strings.TrimSpace(trackerURL)
|
|
if trimmedURL != "" {
|
|
return trimmedURL
|
|
}
|
|
|
|
trimmedCode := strings.TrimSpace(projectCode)
|
|
if trimmedCode == "" {
|
|
return ""
|
|
}
|
|
|
|
return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode)
|
|
}
|
|
|
|
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
|
|
}
|