feat: add projects flow and consolidate default project handling
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user