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") ) type ProjectService struct { localDB *localdb.LocalDB } func NewProjectService(localDB *localdb.LocalDB) *ProjectService { return &ProjectService{localDB: localDB} } type CreateProjectRequest struct { Name string `json:"name"` TrackerURL string `json:"tracker_url"` } type UpdateProjectRequest struct { Name string `json:"name"` 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) { 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, TrackerURL: normalizeProjectTrackerURL(name, 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 localProject.OwnerUsername != ownerUsername { return nil, ErrProjectForbidden } name := strings.TrimSpace(req.Name) if name == "" { return nil, fmt.Errorf("project name is required") } localProject.Name = name if req.TrackerURL != nil { localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL) } else if strings.TrimSpace(localProject.TrackerURL) == "" { localProject.TrackerURL = normalizeProjectTrackerURL(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 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 }