diff --git a/README.md b/README.md index ce9feef..651582f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,29 @@ auth: go run ./cmd/qfs -migrate ``` +### Мигратор OPS -> проекты (preview/apply) + +Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`. +Если проекта нет, он будет создан; если архивный — реактивирован. + +Сначала всегда смотрите preview: + +```bash +go run ./cmd/migrate_ops_projects -config config.yaml +``` + +Применение изменений: + +```bash +go run ./cmd/migrate_ops_projects -config config.yaml -apply +``` + +Без интерактивного подтверждения: + +```bash +go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes +``` + ### Минимальные права БД для пользователя квотаций Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты: diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 40a1611..c0619a3 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -74,7 +74,7 @@ func main() { localCount := local.CountConfigurations() log.Printf("Found %d configurations in local SQLite", localCount) - if *dryRun { + if *dryRun { log.Println("\n[DRY RUN] Would migrate the following configurations:") for _, c := range configs { userName := c.OwnerUsername @@ -117,6 +117,7 @@ func main() { localConfig := &localdb.LocalConfiguration{ UUID: c.UUID, ServerID: &c.ID, + ProjectUUID: c.ProjectUUID, Name: c.Name, Items: localItems, TotalPrice: c.TotalPrice, diff --git a/cmd/migrate_ops_projects/main.go b/cmd/migrate_ops_projects/main.go new file mode 100644 index 0000000..3391494 --- /dev/null +++ b/cmd/migrate_ops_projects/main.go @@ -0,0 +1,283 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "os" + "regexp" + "sort" + "strings" + + "git.mchus.pro/mchus/quoteforge/internal/config" + "git.mchus.pro/mchus/quoteforge/internal/models" + "github.com/google/uuid" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type configRow struct { + ID uint + UUID string + OwnerUsername string + Name string + ProjectUUID *string +} + +type migrationAction struct { + ConfigID uint + ConfigUUID string + ConfigName string + OwnerUsername string + TargetProjectName string + CurrentProject string + NeedCreateProject bool + NeedReactivate bool +} + +func main() { + configPath := flag.String("config", "config.yaml", "path to config file") + apply := flag.Bool("apply", false, "apply migration (default is preview only)") + yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + if err := ensureProjectsTable(db); err != nil { + log.Fatalf("precheck failed: %v", err) + } + + actions, existingProjects, err := buildPlan(db, cfg.Database.User) + if err != nil { + log.Fatalf("failed to build migration plan: %v", err) + } + + printPlan(actions) + if len(actions) == 0 { + fmt.Println("Nothing to migrate.") + return + } + + if !*apply { + fmt.Println("\nPreview complete. Re-run with -apply to execute.") + return + } + + if !*yes { + ok, confirmErr := askForConfirmation() + if confirmErr != nil { + log.Fatalf("confirmation failed: %v", confirmErr) + } + if !ok { + fmt.Println("Aborted.") + return + } + } + + if err := executePlan(db, actions, existingProjects); err != nil { + log.Fatalf("migration failed: %v", err) + } + + fmt.Println("Migration completed successfully.") +} + +func ensureProjectsTable(db *gorm.DB) error { + var count int64 + if err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'qt_projects'").Scan(&count).Error; err != nil { + return fmt.Errorf("checking qt_projects table: %w", err) + } + if count == 0 { + return fmt.Errorf("table qt_projects does not exist; run migration 009_add_projects.sql first") + } + return nil +} + +func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string]*models.Project, error) { + var configs []configRow + if err := db.Table("qt_configurations"). + Select("id, uuid, owner_username, name, project_uuid"). + Find(&configs).Error; err != nil { + return nil, nil, fmt.Errorf("load configurations: %w", err) + } + + codeRegex := regexp.MustCompile(`^(OPS-[0-9]{4})`) + owners := make(map[string]struct{}) + projectNames := make(map[string]struct{}) + type candidate struct { + config configRow + code string + owner string + } + candidates := make([]candidate, 0) + + for _, cfg := range configs { + match := codeRegex.FindStringSubmatch(strings.TrimSpace(cfg.Name)) + if len(match) < 2 { + continue + } + owner := strings.TrimSpace(cfg.OwnerUsername) + if owner == "" { + owner = strings.TrimSpace(fallbackOwner) + } + if owner == "" { + continue + } + code := match[1] + owners[owner] = struct{}{} + projectNames[code] = struct{}{} + candidates = append(candidates, candidate{config: cfg, code: code, owner: owner}) + } + + ownerList := setKeys(owners) + nameList := setKeys(projectNames) + existingProjects := make(map[string]*models.Project) + if len(ownerList) > 0 && len(nameList) > 0 { + var projects []models.Project + if err := db.Where("owner_username IN ? AND name IN ?", ownerList, nameList).Find(&projects).Error; err != nil { + return nil, nil, fmt.Errorf("load existing projects: %w", err) + } + for i := range projects { + p := projects[i] + existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p + } + } + + actions := make([]migrationAction, 0) + for _, c := range candidates { + key := projectKey(c.owner, c.code) + existing := existingProjects[key] + + currentProject := "" + if c.config.ProjectUUID != nil { + currentProject = *c.config.ProjectUUID + } + + if existing != nil && currentProject == existing.UUID { + continue + } + + action := migrationAction{ + ConfigID: c.config.ID, + ConfigUUID: c.config.UUID, + ConfigName: c.config.Name, + OwnerUsername: c.owner, + TargetProjectName: c.code, + CurrentProject: currentProject, + } + if existing == nil { + action.NeedCreateProject = true + } else if !existing.IsActive { + action.NeedReactivate = true + } + actions = append(actions, action) + } + + return actions, existingProjects, nil +} + +func printPlan(actions []migrationAction) { + createCount := 0 + reactivateCount := 0 + for _, a := range actions { + if a.NeedCreateProject { + createCount++ + } + if a.NeedReactivate { + reactivateCount++ + } + } + + fmt.Printf("Planned actions: %d\n", len(actions)) + fmt.Printf("Projects to create: %d\n", createCount) + fmt.Printf("Projects to reactivate: %d\n", reactivateCount) + fmt.Println("\nDetails:") + + for _, a := range actions { + extra := "" + if a.NeedCreateProject { + extra = " [create project]" + } else if a.NeedReactivate { + extra = " [reactivate project]" + } + current := a.CurrentProject + if current == "" { + current = "NULL" + } + fmt.Printf("- %s | owner=%s | \"%s\" | project: %s -> %s%s\n", + a.ConfigUUID, a.OwnerUsername, a.ConfigName, current, a.TargetProjectName, extra) + } +} + +func askForConfirmation() (bool, error) { + fmt.Print("\nApply these changes? type 'yes' to continue: ") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return false, err + } + return strings.EqualFold(strings.TrimSpace(line), "yes"), nil +} + +func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[string]*models.Project) error { + return db.Transaction(func(tx *gorm.DB) error { + projectCache := make(map[string]*models.Project, len(existingProjects)) + for k, v := range existingProjects { + cp := *v + projectCache[k] = &cp + } + + for _, action := range actions { + key := projectKey(action.OwnerUsername, action.TargetProjectName) + project := projectCache[key] + if project == nil { + project = &models.Project{ + UUID: uuid.NewString(), + OwnerUsername: action.OwnerUsername, + Name: action.TargetProjectName, + IsActive: true, + IsSystem: false, + } + if err := tx.Create(project).Error; err != nil { + return fmt.Errorf("create project %s for owner %s: %w", action.TargetProjectName, action.OwnerUsername, err) + } + projectCache[key] = project + } else if !project.IsActive { + if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil { + return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err) + } + project.IsActive = true + } + + if err := tx.Table("qt_configurations").Where("id = ?", action.ConfigID).Update("project_uuid", project.UUID).Error; err != nil { + return fmt.Errorf("move configuration %s to project %s: %w", action.ConfigUUID, project.UUID, err) + } + } + + return nil + }) +} + +func setKeys(set map[string]struct{}) []string { + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func projectKey(owner, name string) string { + return owner + "||" + name +} diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index f3c957f..2717a10 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -14,12 +14,13 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "syscall" "time" qfassets "git.mchus.pro/mchus/quoteforge" - "git.mchus.pro/mchus/quoteforge/internal/appstate" "git.mchus.pro/mchus/quoteforge/internal/appmeta" + "git.mchus.pro/mchus/quoteforge/internal/appstate" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/handlers" @@ -164,6 +165,16 @@ func main() { slog.Info("migrations completed") } + // Always apply SQL migrations on startup when database is available. + // This keeps schema in sync for long-running installations without manual steps. + if mariaDB != nil { + sqlMigrationsPath := filepath.Join("migrations") + if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil { + slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err) + os.Exit(1) + } + } + gin.SetMode(cfg.Server.Mode) restartSig := make(chan struct{}, 1) @@ -438,6 +449,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect var alertService *alerts.Service var pricelistService *pricelist.Service var syncService *sync.Service + var projectService *services.ProjectService // Sync service always uses ConnectionManager (works offline and online) syncService = sync.NewService(connMgr, local) @@ -465,8 +477,81 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } // Local-first configuration service (replaces old ConfigurationService) + projectService = services.NewProjectService(local) configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) + // Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project. + if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 { + slog.Info("consolidated duplicate local system projects", "removed", removed) + } + if removed, err := local.PurgeEmptyNamelessProjects(); err == nil && removed > 0 { + slog.Info("purged empty nameless local projects", "removed", removed) + } + if err := local.BackfillConfigurationProjects(dbUsername); err != nil { + slog.Warn("failed to backfill local configuration projects", "error", err) + } + if mariaDB != nil { + serverProjectRepo := repository.NewProjectRepository(mariaDB) + if removed, err := serverProjectRepo.PurgeEmptyNamelessProjects(); err == nil && removed > 0 { + slog.Info("purged empty nameless server projects", "removed", removed) + } + if err := serverProjectRepo.EnsureSystemProjectsAndBackfillConfigurations(); err != nil { + slog.Warn("failed to backfill server configuration projects", "error", err) + } + } + + syncProjectsFromServer := func() { + if !connMgr.IsOnline() { + return + } + serverDB, err := connMgr.GetDB() + if err != nil || serverDB == nil { + return + } + + projectRepo := repository.NewProjectRepository(serverDB) + serverProjects, _, err := projectRepo.List(0, 10000, true) + if err != nil { + return + } + + now := time.Now() + for i := range serverProjects { + sp := serverProjects[i] + localProject, getErr := local.GetProjectByUUID(sp.UUID) + if getErr == nil && localProject != nil { + // Keep unsynced local changes intact. + if localProject.SyncStatus == "pending" { + continue + } + localProject.OwnerUsername = sp.OwnerUsername + localProject.Name = sp.Name + localProject.IsActive = sp.IsActive + localProject.IsSystem = sp.IsSystem + localProject.CreatedAt = sp.CreatedAt + localProject.UpdatedAt = sp.UpdatedAt + serverID := sp.ID + localProject.ServerID = &serverID + localProject.SyncStatus = "synced" + localProject.SyncedAt = &now + _ = local.SaveProject(localProject) + continue + } + + lp := localdb.ProjectToLocal(&sp) + lp.SyncStatus = "synced" + lp.SyncedAt = &now + _ = local.SaveProject(lp) + } + } + + syncConfigurationsFromServer := func() { + if !connMgr.IsOnline() { + return + } + _, _ = configService.ImportFromServer() + } + // Use filepath.Join for cross-platform path compatibility templatesPath := filepath.Join("web", "templates") @@ -581,6 +666,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.GET("/", webHandler.Index) router.GET("/configs", webHandler.Configs) router.GET("/configurator", webHandler.Configurator) + router.GET("/projects", webHandler.Projects) + router.GET("/projects/:uuid", webHandler.ProjectDetail) router.GET("/pricelists", func(c *gin.Context) { // Redirect to admin/pricing with pricelists tab c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists") @@ -641,15 +728,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect configs := api.Group("/configs") { configs.GET("", func(c *gin.Context) { + syncConfigurationsFromServer() + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) status := c.DefaultQuery("status", "active") + search := c.Query("search") if status != "active" && status != "archived" && status != "all" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) return } - cfgs, total, err := configService.ListAllWithStatus(page, perPage, status) + cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -661,6 +751,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect "page": page, "per_page": perPage, "status": status, + "search": search, }) }) @@ -790,6 +881,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect c.JSON(http.StatusOK, config) }) + configs.PATCH("/:uuid/project", func(c *gin.Context) { + uuid := c.Param("uuid") + var req struct { + ProjectUUID string `json:"project_uuid"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID) + if err != nil { + switch { + case errors.Is(err, services.ErrConfigNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, updated) + }) + configs.GET("/:uuid/versions", func(c *gin.Context) { uuid := c.Param("uuid") @@ -895,6 +1012,223 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect }) } + projects := api.Group("/projects") + { + projects.GET("", func(c *gin.Context) { + syncProjectsFromServer() + syncConfigurationsFromServer() + + status := c.DefaultQuery("status", "active") + search := strings.ToLower(strings.TrimSpace(c.Query("search"))) + if status != "active" && status != "archived" && status != "all" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) + return + } + + allProjects, err := projectService.ListByUser(dbUsername, true) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filtered := make([]models.Project, 0, len(allProjects)) + for i := range allProjects { + p := allProjects[i] + if status == "active" && !p.IsActive { + continue + } + if status == "archived" && p.IsActive { + continue + } + if search != "" && !strings.Contains(strings.ToLower(p.Name), search) { + continue + } + filtered = append(filtered, p) + } + + projectRows := make([]gin.H, 0, len(filtered)) + for i := range filtered { + p := filtered[i] + configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active") + if err != nil { + configs = &services.ProjectConfigurationsResult{ + ProjectUUID: p.UUID, + Configs: []models.Configuration{}, + Total: 0, + } + } + projectRows = append(projectRows, gin.H{ + "id": p.ID, + "uuid": p.UUID, + "owner_username": p.OwnerUsername, + "name": p.Name, + "is_active": p.IsActive, + "is_system": p.IsSystem, + "created_at": p.CreatedAt, + "updated_at": p.UpdatedAt, + "config_count": len(configs.Configs), + "total": configs.Total, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "projects": projectRows, + "status": status, + "search": search, + "total": len(projectRows), + }) + }) + + projects.POST("", func(c *gin.Context) { + var req services.CreateProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(req.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"}) + return + } + project, err := projectService.Create(dbUsername, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, project) + }) + + projects.GET("/:uuid", func(c *gin.Context) { + project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername) + if err != nil { + switch { + case errors.Is(err, services.ErrProjectNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, project) + }) + + projects.PUT("/:uuid", func(c *gin.Context) { + var req services.UpdateProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(req.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"}) + return + } + project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) + if err != nil { + switch { + case errors.Is(err, services.ErrProjectNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, project) + }) + + projects.POST("/:uuid/archive", func(c *gin.Context) { + if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil { + switch { + case errors.Is(err, services.ErrProjectNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, gin.H{"message": "project archived"}) + }) + + projects.POST("/:uuid/reactivate", func(c *gin.Context) { + if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil { + switch { + case errors.Is(err, services.ErrProjectNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, gin.H{"message": "project reactivated"}) + }) + + projects.GET("/:uuid/configs", func(c *gin.Context) { + syncConfigurationsFromServer() + + status := c.DefaultQuery("status", "active") + if status != "active" && status != "archived" && status != "all" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) + return + } + + result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status) + if err != nil { + switch { + case errors.Is(err, services.ErrProjectNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + c.Header("X-Config-Status", status) + c.JSON(http.StatusOK, result) + }) + + projects.POST("/:uuid/configs", func(c *gin.Context) { + var req services.CreateConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + projectUUID := c.Param("uuid") + req.ProjectUUID = &projectUUID + + config, err := configService.Create(dbUsername, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, config) + }) + + projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) { + var req struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + projectUUID := c.Param("uuid") + config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, config) + }) + } + // Pricing admin (public - RBAC disabled) pricingAdmin := api.Group("/admin/pricing") { diff --git a/cmd/qfs/versioning_api_test.go b/cmd/qfs/versioning_api_test.go index e86335b..2600057 100644 --- a/cmd/qfs/versioning_api_test.go +++ b/cmd/qfs/versioning_api_test.go @@ -136,6 +136,160 @@ func TestConfigurationVersioningAPI(t *testing.T) { } } +func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) { + moveToRepoRoot(t) + + local, connMgr, configService := newAPITestStack(t) + _ = configService + + cfg := &config.Config{} + setConfigDefaults(cfg) + router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) + if err != nil { + t.Fatalf("setup router: %v", err) + } + + createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`))) + createProjectReq.Header.Set("Content-Type", "application/json") + createProjectRec := httptest.NewRecorder() + router.ServeHTTP(createProjectRec, createProjectReq) + if createProjectRec.Code != http.StatusCreated { + t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String()) + } + var project models.Project + if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil { + t.Fatalf("unmarshal project: %v", err) + } + + createCfgBody := []byte(`{"name":"Cfg A","items":[{"lot_name":"CPU","quantity":1,"unit_price":100}],"server_count":1}`) + createCfgReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs", bytes.NewReader(createCfgBody)) + createCfgReq.Header.Set("Content-Type", "application/json") + createCfgRec := httptest.NewRecorder() + router.ServeHTTP(createCfgRec, createCfgReq) + if createCfgRec.Code != http.StatusCreated { + t.Fatalf("create project config status=%d body=%s", createCfgRec.Code, createCfgRec.Body.String()) + } + var createdCfg models.Configuration + if err := json.Unmarshal(createCfgRec.Body.Bytes(), &createdCfg); err != nil { + t.Fatalf("unmarshal project config: %v", err) + } + if createdCfg.ProjectUUID == nil || *createdCfg.ProjectUUID != project.UUID { + t.Fatalf("expected config project_uuid=%s got=%v", project.UUID, createdCfg.ProjectUUID) + } + + cloneReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs/"+createdCfg.UUID+"/clone", bytes.NewReader([]byte(`{"name":"Cfg A Clone"}`))) + cloneReq.Header.Set("Content-Type", "application/json") + cloneRec := httptest.NewRecorder() + router.ServeHTTP(cloneRec, cloneReq) + if cloneRec.Code != http.StatusCreated { + t.Fatalf("clone in project status=%d body=%s", cloneRec.Code, cloneRec.Body.String()) + } + var cloneCfg models.Configuration + if err := json.Unmarshal(cloneRec.Body.Bytes(), &cloneCfg); err != nil { + t.Fatalf("unmarshal clone config: %v", err) + } + if cloneCfg.ProjectUUID == nil || *cloneCfg.ProjectUUID != project.UUID { + t.Fatalf("expected clone project_uuid=%s got=%v", project.UUID, cloneCfg.ProjectUUID) + } + + projectConfigsReq := httptest.NewRequest(http.MethodGet, "/api/projects/"+project.UUID+"/configs", nil) + projectConfigsRec := httptest.NewRecorder() + router.ServeHTTP(projectConfigsRec, projectConfigsReq) + if projectConfigsRec.Code != http.StatusOK { + t.Fatalf("project configs status=%d body=%s", projectConfigsRec.Code, projectConfigsRec.Body.String()) + } + var projectConfigsResp struct { + Configurations []models.Configuration `json:"configurations"` + } + if err := json.Unmarshal(projectConfigsRec.Body.Bytes(), &projectConfigsResp); err != nil { + t.Fatalf("unmarshal project configs response: %v", err) + } + if len(projectConfigsResp.Configurations) != 2 { + t.Fatalf("expected 2 project configs after clone, got %d", len(projectConfigsResp.Configurations)) + } + + archiveReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/archive", nil) + archiveRec := httptest.NewRecorder() + router.ServeHTTP(archiveRec, archiveReq) + if archiveRec.Code != http.StatusOK { + t.Fatalf("archive project status=%d body=%s", archiveRec.Code, archiveRec.Body.String()) + } + + activeReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil) + activeRec := httptest.NewRecorder() + router.ServeHTTP(activeRec, activeReq) + if activeRec.Code != http.StatusOK { + t.Fatalf("active configs status=%d body=%s", activeRec.Code, activeRec.Body.String()) + } + var activeResp struct { + Configurations []models.Configuration `json:"configurations"` + } + if err := json.Unmarshal(activeRec.Body.Bytes(), &activeResp); err != nil { + t.Fatalf("unmarshal active configs response: %v", err) + } + if len(activeResp.Configurations) != 0 { + t.Fatalf("expected no active configs after project archive, got %d", len(activeResp.Configurations)) + } +} + +func TestConfigMoveToProjectEndpoint(t *testing.T) { + moveToRepoRoot(t) + + local, connMgr, _ := newAPITestStack(t) + cfg := &config.Config{} + setConfigDefaults(cfg) + router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) + if err != nil { + t.Fatalf("setup router: %v", err) + } + + createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`))) + createProjectReq.Header.Set("Content-Type", "application/json") + createProjectRec := httptest.NewRecorder() + router.ServeHTTP(createProjectRec, createProjectReq) + if createProjectRec.Code != http.StatusCreated { + t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String()) + } + var project models.Project + if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil { + t.Fatalf("unmarshal project: %v", err) + } + + createConfigReq := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":"Move Me","items":[],"notes":"","server_count":1}`))) + createConfigReq.Header.Set("Content-Type", "application/json") + createConfigRec := httptest.NewRecorder() + router.ServeHTTP(createConfigRec, createConfigReq) + if createConfigRec.Code != http.StatusCreated { + t.Fatalf("create config status=%d body=%s", createConfigRec.Code, createConfigRec.Body.String()) + } + var created models.Configuration + if err := json.Unmarshal(createConfigRec.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + moveReq := httptest.NewRequest(http.MethodPatch, "/api/configs/"+created.UUID+"/project", bytes.NewReader([]byte(`{"project_uuid":"`+project.UUID+`"}`))) + moveReq.Header.Set("Content-Type", "application/json") + moveRec := httptest.NewRecorder() + router.ServeHTTP(moveRec, moveReq) + if moveRec.Code != http.StatusOK { + t.Fatalf("move config status=%d body=%s", moveRec.Code, moveRec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID, nil) + getRec := httptest.NewRecorder() + router.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("get config status=%d body=%s", getRec.Code, getRec.Body.String()) + } + var updated models.Configuration + if err := json.Unmarshal(getRec.Body.Bytes(), &updated); err != nil { + t.Fatalf("unmarshal updated config: %v", err) + } + if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID { + t.Fatalf("expected moved project_uuid=%s, got %v", project.UUID, updated.ProjectUUID) + } +} + func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) { t.Helper() diff --git a/internal/handlers/web.go b/internal/handlers/web.go index 3bd63d7..7a7659b 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer } // Load each page template with base - simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} + simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} for _, page := range simplePages { pagePath := filepath.Join(templatesPath, page) var tmpl *template.Template @@ -186,6 +186,17 @@ func (h *WebHandler) Configs(c *gin.Context) { h.render(c, "configs.html", gin.H{"ActivePage": "configs"}) } +func (h *WebHandler) Projects(c *gin.Context) { + h.render(c, "projects.html", gin.H{"ActivePage": "projects"}) +} + +func (h *WebHandler) ProjectDetail(c *gin.Context) { + h.render(c, "project_detail.html", gin.H{ + "ActivePage": "projects", + "ProjectUUID": c.Param("uuid"), + }) +} + func (h *WebHandler) AdminPricing(c *gin.Context) { h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"}) } diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index 6774c8c..8aa748d 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -19,6 +19,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { local := &LocalConfiguration{ UUID: cfg.UUID, + ProjectUUID: cfg.ProjectUUID, IsActive: true, Name: cfg.Name, Items: items, @@ -61,6 +62,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration { cfg := &models.Configuration{ UUID: local.UUID, OwnerUsername: local.OriginalUsername, + ProjectUUID: local.ProjectUUID, Name: local.Name, Items: items, TotalPrice: local.TotalPrice, @@ -90,6 +92,40 @@ func derefUint(v *uint) uint { return *v } +func ProjectToLocal(project *models.Project) *LocalProject { + local := &LocalProject{ + UUID: project.UUID, + OwnerUsername: project.OwnerUsername, + Name: project.Name, + IsActive: project.IsActive, + IsSystem: project.IsSystem, + CreatedAt: project.CreatedAt, + UpdatedAt: project.UpdatedAt, + SyncStatus: "pending", + } + if project.ID > 0 { + serverID := project.ID + local.ServerID = &serverID + } + return local +} + +func LocalToProject(local *LocalProject) *models.Project { + project := &models.Project{ + UUID: local.UUID, + OwnerUsername: local.OwnerUsername, + Name: local.Name, + IsActive: local.IsActive, + IsSystem: local.IsSystem, + CreatedAt: local.CreatedAt, + UpdatedAt: local.UpdatedAt, + } + if local.ServerID != nil { + project.ID = *local.ServerID + } + return project +} + // PricelistToLocal converts models.Pricelist to LocalPricelist func PricelistToLocal(pl *models.Pricelist) *LocalPricelist { name := pl.Notification diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 3bffdd2..1f968ec 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1,10 +1,12 @@ package localdb import ( + "errors" "fmt" "log/slog" "os" "path/filepath" + "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/appmeta" @@ -53,6 +55,7 @@ func New(dbPath string) (*LocalDB, error) { // Auto-migrate all local tables if err := db.AutoMigrate( &ConnectionSettings{}, + &LocalProject{}, &LocalConfiguration{}, &LocalConfigurationVersion{}, &LocalPricelist{}, @@ -178,6 +181,216 @@ func (l *LocalDB) GetDBUser() string { // Configuration methods +// Project methods + +func (l *LocalDB) SaveProject(project *LocalProject) error { + return l.db.Save(project).Error +} + +func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) { + var projects []LocalProject + query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername) + if !includeArchived { + query = query.Where("is_active = ?", true) + } + err := query.Order("created_at DESC").Find(&projects).Error + return projects, err +} + +func (l *LocalDB) GetAllProjects(includeArchived bool) ([]LocalProject, error) { + var projects []LocalProject + query := l.db.Model(&LocalProject{}) + if !includeArchived { + query = query.Where("is_active = ?", true) + } + err := query.Order("created_at DESC").Find(&projects).Error + return projects, err +} + +func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) { + var project LocalProject + if err := l.db.Where("uuid = ?", uuid).First(&project).Error; err != nil { + return nil, err + } + return &project, nil +} + +func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) { + var project LocalProject + if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil { + return nil, err + } + return &project, nil +} + +func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) { + var configs []LocalConfiguration + err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true). + Order("created_at DESC"). + Find(&configs).Error + return configs, err +} + +func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, error) { + project := &LocalProject{} + err := l.db. + 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(project).Error + if err == nil { + return project, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + now := time.Now() + project = &LocalProject{ + UUID: uuidpkg.NewString(), + OwnerUsername: "", + Name: "Без проекта", + IsActive: true, + IsSystem: true, + CreatedAt: now, + UpdatedAt: now, + SyncStatus: "pending", + } + if err := l.SaveProject(project); err != nil { + return nil, err + } + return project, nil +} + +// ConsolidateSystemProjects merges all "Без проекта" projects into one shared canonical project. +// Configurations are reassigned to canonical UUID, duplicate projects are deleted. +func (l *LocalDB) ConsolidateSystemProjects() (int64, error) { + var removed int64 + err := l.db.Transaction(func(tx *gorm.DB) error { + var canonical LocalProject + err := tx. + 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(&canonical).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + now := time.Now() + canonical = LocalProject{ + UUID: uuidpkg.NewString(), + OwnerUsername: "", + Name: "Без проекта", + IsActive: true, + IsSystem: true, + CreatedAt: now, + UpdatedAt: now, + SyncStatus: "pending", + } + if err := tx.Create(&canonical).Error; err != nil { + return err + } + } else if err != nil { + return err + } + + if err := tx.Model(&LocalProject{}). + Where("uuid = ?", canonical.UUID). + Updates(map[string]any{ + "name": "Без проекта", + "is_system": true, + "is_active": true, + }).Error; err != nil { + return err + } + + var duplicates []LocalProject + if err := tx.Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND uuid <> ?", "Без проекта", canonical.UUID). + Find(&duplicates).Error; err != nil { + return err + } + + for i := range duplicates { + p := duplicates[i] + if err := tx.Model(&LocalConfiguration{}). + Where("project_uuid = ?", p.UUID). + Update("project_uuid", canonical.UUID).Error; err != nil { + return err + } + + // Remove stale pending project events for deleted UUIDs. + if err := tx.Where("entity_type = ? AND entity_uuid = ?", "project", p.UUID). + Delete(&PendingChange{}).Error; err != nil { + return err + } + + res := tx.Where("uuid = ?", p.UUID).Delete(&LocalProject{}) + if res.Error != nil { + return res.Error + } + removed += res.RowsAffected + } + + // Backfill orphaned local configurations to canonical project. + if err := tx.Model(&LocalConfiguration{}). + Where("project_uuid IS NULL OR TRIM(COALESCE(project_uuid, '')) = ''"). + Update("project_uuid", canonical.UUID).Error; err != nil { + return err + } + + return nil + }) + return removed, err +} + +// PurgeEmptyNamelessProjects removes service-trash projects that have no linked configurations: +// 1) projects with empty names; +// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed). +func (l *LocalDB) PurgeEmptyNamelessProjects() (int64, error) { + tx := l.db.Exec(` +DELETE FROM local_projects +WHERE ( + TRIM(COALESCE(name, '')) = '' + OR LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта') +) + AND uuid NOT IN ( + SELECT DISTINCT project_uuid + FROM local_configurations + WHERE project_uuid IS NOT NULL AND project_uuid <> '' + )`) + return tx.RowsAffected, tx.Error +} + +// BackfillConfigurationProjects ensures every configuration has project_uuid set. +// If missing, it assigns system project "Без проекта" for configuration owner. +func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error { + configs, err := l.GetConfigurations() + if err != nil { + return err + } + + for i := range configs { + cfg := configs[i] + if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" { + continue + } + owner := strings.TrimSpace(cfg.OriginalUsername) + if owner == "" { + owner = strings.TrimSpace(defaultOwner) + } + if owner == "" { + continue + } + + project, err := l.EnsureDefaultProject(owner) + if err != nil { + return err + } + + cfg.ProjectUUID = &project.UUID + if saveErr := l.SaveConfiguration(&cfg); saveErr != nil { + return saveErr + } + } + return nil +} + // SaveConfiguration saves a configuration to local SQLite func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error { return l.db.Save(config).Error diff --git a/internal/localdb/migration_projects_test.go b/internal/localdb/migration_projects_test.go new file mode 100644 index 0000000..fbbe7ea --- /dev/null +++ b/internal/localdb/migration_projects_test.go @@ -0,0 +1,60 @@ +package localdb + +import ( + "path/filepath" + "testing" +) + +func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "projects_backfill.db") + + local, err := New(dbPath) + if err != nil { + t.Fatalf("open localdb: %v", err) + } + t.Cleanup(func() { _ = local.Close() }) + + cfg := &LocalConfiguration{ + UUID: "cfg-without-project", + Name: "Cfg no project", + Items: LocalConfigItems{}, + SyncStatus: "pending", + OriginalUsername: "tester", + IsActive: true, + } + if err := local.SaveConfiguration(cfg); err != nil { + t.Fatalf("save config: %v", err) + } + if err := local.DB(). + Model(&LocalConfiguration{}). + Where("uuid = ?", cfg.UUID). + Update("project_uuid", nil).Error; err != nil { + t.Fatalf("clear project_uuid: %v", err) + } + if err := local.DB().Where("id = ?", "2026_02_06_projects_backfill").Delete(&LocalSchemaMigration{}).Error; err != nil { + t.Fatalf("delete local migration record: %v", err) + } + + if err := runLocalMigrations(local.DB()); err != nil { + t.Fatalf("run local migrations: %v", err) + } + + updated, err := local.GetConfigurationByUUID(cfg.UUID) + if err != nil { + t.Fatalf("get updated config: %v", err) + } + if updated.ProjectUUID == nil || *updated.ProjectUUID == "" { + t.Fatalf("expected project_uuid to be backfilled") + } + + project, err := local.GetProjectByUUID(*updated.ProjectUUID) + if err != nil { + t.Fatalf("get system project: %v", err) + } + if project.Name != "Без проекта" { + t.Fatalf("expected system project name, got %q", project.Name) + } + if !project.IsSystem { + t.Fatalf("expected system project flag") + } +} diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index 3f13a46..e0bd5e0 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -1,6 +1,7 @@ package localdb import ( + "errors" "fmt" "log/slog" "time" @@ -36,6 +37,11 @@ var localMigrations = []localMigration{ name: "Ensure is_active defaults to true for existing configurations", run: backfillConfigurationIsActive, }, + { + id: "2026_02_06_projects_backfill", + name: "Create default projects and attach existing configurations", + run: backfillProjectsForConfigurations, + }, } func runLocalMigrations(db *gorm.DB) error { @@ -133,6 +139,59 @@ func backfillConfigurationIsActive(tx *gorm.DB) error { return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error } +func backfillProjectsForConfigurations(tx *gorm.DB) error { + var owners []string + if err := tx.Model(&LocalConfiguration{}). + Distinct("original_username"). + Pluck("original_username", &owners).Error; err != nil { + return fmt.Errorf("load owners for projects backfill: %w", err) + } + + for _, owner := range owners { + project, err := ensureDefaultProjectTx(tx, owner) + if err != nil { + return err + } + + if err := tx.Model(&LocalConfiguration{}). + Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner). + Update("project_uuid", project.UUID).Error; err != nil { + return fmt.Errorf("assign default project for owner %s: %w", owner, err) + } + } + + return nil +} + +func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) { + var project LocalProject + err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта"). + First(&project).Error + if err == nil { + return &project, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err) + } + + now := time.Now() + project = LocalProject{ + UUID: uuid.NewString(), + OwnerUsername: ownerUsername, + Name: "Без проекта", + IsActive: true, + IsSystem: true, + CreatedAt: now, + UpdatedAt: now, + SyncStatus: "pending", + } + if err := tx.Create(&project).Error; err != nil { + return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err) + } + + return &project, nil +} + func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time { if candidate.IsZero() { return fallback diff --git a/internal/localdb/models.go b/internal/localdb/models.go index e02e63f..31b9051 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -62,6 +62,7 @@ type LocalConfiguration struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` UUID string `gorm:"uniqueIndex;not null" json:"uuid"` ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only + ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"` CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"` IsActive bool `gorm:"default:true;index" json:"is_active"` Name string `gorm:"not null" json:"name"` @@ -86,6 +87,24 @@ func (LocalConfiguration) TableName() string { return "local_configurations" } +type LocalProject struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UUID string `gorm:"uniqueIndex;not null" json:"uuid"` + ServerID *uint `json:"server_id,omitempty"` + OwnerUsername string `gorm:"not null;index" json:"owner_username"` + Name string `gorm:"not null" json:"name"` + IsActive bool `gorm:"default:true;index" json:"is_active"` + IsSystem bool `gorm:"default:false;index" json:"is_system"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SyncedAt *time.Time `json:"synced_at,omitempty"` + SyncStatus string `gorm:"default:'local'" json:"sync_status"` // local/synced/pending +} + +func (LocalProject) TableName() string { + return "local_projects" +} + // LocalConfigurationVersion stores immutable full snapshots for each configuration version type LocalConfigurationVersion struct { ID string `gorm:"primaryKey" json:"id"` diff --git a/internal/localdb/snapshots.go b/internal/localdb/snapshots.go index 0158bde..078328b 100644 --- a/internal/localdb/snapshots.go +++ b/internal/localdb/snapshots.go @@ -12,6 +12,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) { "id": localCfg.ID, "uuid": localCfg.UUID, "server_id": localCfg.ServerID, + "project_uuid": localCfg.ProjectUUID, "current_version_id": localCfg.CurrentVersionID, "is_active": localCfg.IsActive, "name": localCfg.Name, @@ -40,6 +41,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) { // DecodeConfigurationSnapshot returns editable fields from one saved snapshot. func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) { var snapshot struct { + ProjectUUID *string `json:"project_uuid"` IsActive *bool `json:"is_active"` Name string `json:"name"` Items LocalConfigItems `json:"items"` @@ -64,6 +66,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) { return &LocalConfiguration{ IsActive: isActive, + ProjectUUID: snapshot.ProjectUUID, Name: snapshot.Name, Items: snapshot.Items, TotalPrice: snapshot.TotalPrice, diff --git a/internal/models/configuration.go b/internal/models/configuration.go index 6948f76..a75a5f4 100644 --- a/internal/models/configuration.go +++ b/internal/models/configuration.go @@ -44,6 +44,7 @@ type Configuration struct { UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"` + ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"` AppVersion string `gorm:"size:64" json:"app_version,omitempty"` Name string `gorm:"size:200;not null" json:"name"` Items ConfigItems `gorm:"type:json;not null" json:"items"` diff --git a/internal/models/models.go b/internal/models/models.go index 309e1cb..d758ebb 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -13,6 +13,7 @@ func AllModels() []interface{} { &User{}, &Category{}, &LotMetadata{}, + &Project{}, &Configuration{}, &PriceOverride{}, &PricingAlert{}, diff --git a/internal/models/project.go b/internal/models/project.go new file mode 100644 index 0000000..0b08646 --- /dev/null +++ b/internal/models/project.go @@ -0,0 +1,18 @@ +package models + +import "time" + +type Project struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` + OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"` + Name string `gorm:"size:200;not null" json:"name"` + IsActive bool `gorm:"default:true;index" json:"is_active"` + IsSystem bool `gorm:"default:false;index" json:"is_system"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (Project) TableName() string { + return "qt_projects" +} diff --git a/internal/models/sql_migrations.go b/internal/models/sql_migrations.go new file mode 100644 index 0000000..072025d --- /dev/null +++ b/internal/models/sql_migrations.go @@ -0,0 +1,159 @@ +package models + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gorm.io/gorm" +) + +type SQLSchemaMigration struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Filename string `gorm:"size:255;uniqueIndex;not null"` + AppliedAt time.Time `gorm:"autoCreateTime"` +} + +func (SQLSchemaMigration) TableName() string { + return "qt_schema_migrations" +} + +// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations. +// Local SQLite-only scripts are skipped automatically. +func RunSQLMigrations(db *gorm.DB, migrationsDir string) error { + if err := ensureSQLMigrationsTable(db); err != nil { + return fmt.Errorf("migrate qt_schema_migrations table: %w", err) + } + + entries, err := os.ReadDir(migrationsDir) + if err != nil { + return fmt.Errorf("read migrations dir %s: %w", migrationsDir, err) + } + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".sql") { + continue + } + if isSQLiteOnlyMigration(name) { + continue + } + files = append(files, name) + } + sort.Strings(files) + + for _, filename := range files { + var count int64 + if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil { + return fmt.Errorf("check migration %s: %w", filename, err) + } + if count > 0 { + continue + } + + path := filepath.Join(migrationsDir, filename) + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read migration %s: %w", filename, err) + } + + statements := splitSQLStatements(string(content)) + if len(statements) == 0 { + if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil { + return fmt.Errorf("record empty migration %s: %w", filename, err) + } + continue + } + + if err := executeMigrationStatements(db, filename, statements); err != nil { + return err + } + if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil { + return fmt.Errorf("record migration %s: %w", filename, err) + } + } + + return nil +} + +func ensureSQLMigrationsTable(db *gorm.DB) error { + stmt := ` +CREATE TABLE IF NOT EXISTS qt_schema_migrations ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +);` + return db.Exec(stmt).Error +} + +func executeMigrationStatements(db *gorm.DB, filename string, statements []string) error { + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + if isIgnorableMigrationError(err.Error()) { + continue + } + return fmt.Errorf("exec migration %s statement %q: %w", filename, stmt, err) + } + } + return nil +} + +func isSQLiteOnlyMigration(filename string) bool { + lower := strings.ToLower(filename) + return strings.Contains(lower, "local_") +} + +func isIgnorableMigrationError(message string) bool { + lower := strings.ToLower(message) + ignorable := []string{ + "duplicate column name", + "duplicate key name", + "already exists", + "can't create table", + "duplicate foreign key constraint name", + "errno 121", + } + for _, pattern := range ignorable { + if strings.Contains(lower, pattern) { + return true + } + } + return false +} + +func splitSQLStatements(script string) []string { + scanner := bufio.NewScanner(strings.NewReader(script)) + scanner.Buffer(make([]byte, 1024), 1024*1024) + + lines := make([]string, 0, 128) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if strings.HasPrefix(line, "--") { + continue + } + lines = append(lines, scanner.Text()) + } + + combined := strings.Join(lines, "\n") + raw := strings.Split(combined, ";") + stmts := make([]string, 0, len(raw)) + for _, stmt := range raw { + trimmed := strings.TrimSpace(stmt) + if trimmed == "" { + continue + } + stmts = append(stmts, trimmed) + } + return stmts +} diff --git a/internal/repository/project.go b/internal/repository/project.go new file mode 100644 index 0000000..eb49918 --- /dev/null +++ b/internal/repository/project.go @@ -0,0 +1,169 @@ +package repository + +import ( + "git.mchus.pro/mchus/quoteforge/internal/models" + "gorm.io/gorm" +) + +type ProjectRepository struct { + db *gorm.DB +} + +func NewProjectRepository(db *gorm.DB) *ProjectRepository { + return &ProjectRepository{db: db} +} + +func (r *ProjectRepository) Create(project *models.Project) error { + return r.db.Create(project).Error +} + +func (r *ProjectRepository) Update(project *models.Project) error { + return r.db.Save(project).Error +} + +func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) { + var project models.Project + if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil { + return nil, err + } + return &project, nil +} + +func (r *ProjectRepository) GetSystemByOwner(ownerUsername string) (*models.Project, error) { + var project models.Project + if err := r.db.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта"). + First(&project).Error; err != nil { + return nil, err + } + return &project, nil +} + +func (r *ProjectRepository) List(offset, limit int, includeArchived bool) ([]models.Project, int64, error) { + var projects []models.Project + var total int64 + + query := r.db.Model(&models.Project{}) + if !includeArchived { + query = query.Where("is_active = ?", true) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&projects).Error; err != nil { + return nil, 0, err + } + + return projects, total, nil +} + +func (r *ProjectRepository) ListByOwner(ownerUsername string, includeArchived bool) ([]models.Project, error) { + var projects []models.Project + + query := r.db.Where("owner_username = ?", ownerUsername) + if !includeArchived { + query = query.Where("is_active = ?", true) + } + + if err := query.Order("created_at DESC").Find(&projects).Error; err != nil { + return nil, err + } + return projects, nil +} + +func (r *ProjectRepository) Archive(uuid string) error { + return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", false).Error +} + +func (r *ProjectRepository) Reactivate(uuid string) error { + return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", true).Error +} + +// PurgeEmptyNamelessProjects removes service-trash projects that have no configurations attached: +// 1) projects with empty names; +// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed). +func (r *ProjectRepository) PurgeEmptyNamelessProjects() (int64, error) { + tx := r.db.Exec(` +DELETE p +FROM qt_projects p +WHERE ( + TRIM(COALESCE(p.name, '')) = '' + OR LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта') +) + AND NOT EXISTS ( + SELECT 1 + FROM qt_configurations c + WHERE c.project_uuid = p.uuid + )`) + return tx.RowsAffected, tx.Error +} + +// EnsureSystemProjectsAndBackfillConfigurations ensures there is a single shared system project +// named "Без проекта", reassigns orphan/legacy links to it and removes duplicates. +func (r *ProjectRepository) EnsureSystemProjectsAndBackfillConfigurations() error { + return r.db.Transaction(func(tx *gorm.DB) error { + type row struct { + UUID string `gorm:"column:uuid"` + } + var canonical row + err := tx.Raw(` +SELECT uuid +FROM qt_projects +WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта') + AND is_system = TRUE +ORDER BY CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC +LIMIT 1`).Scan(&canonical).Error + if err != nil { + return err + } + if canonical.UUID == "" { + if err := tx.Exec(` +INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at) +VALUES (UUID(), '', 'Без проекта', TRUE, TRUE, NOW(), NOW())`).Error; err != nil { + return err + } + if err := tx.Raw(` +SELECT uuid +FROM qt_projects +WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта') +ORDER BY created_at DESC, id DESC +LIMIT 1`).Scan(&canonical).Error; err != nil { + return err + } + if canonical.UUID == "" { + return gorm.ErrRecordNotFound + } + } + + if err := tx.Exec(` +UPDATE qt_projects +SET name = 'Без проекта', + is_active = TRUE, + is_system = TRUE +WHERE uuid = ?`, canonical.UUID).Error; err != nil { + return err + } + + if err := tx.Exec(` +UPDATE qt_configurations +SET project_uuid = ? +WHERE project_uuid IS NULL OR project_uuid = ''`, canonical.UUID).Error; err != nil { + return err + } + + if err := tx.Exec(` +UPDATE qt_configurations c +JOIN qt_projects p ON p.uuid = c.project_uuid +SET c.project_uuid = ? +WHERE LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта') + AND p.uuid <> ?`, canonical.UUID, canonical.UUID).Error; err != nil { + return err + } + + return tx.Exec(` +DELETE FROM qt_projects +WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта') + AND uuid <> ?`, canonical.UUID).Error + }) +} diff --git a/internal/services/configuration.go b/internal/services/configuration.go index 3255a11..0dcc7a5 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -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) diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 3883787..4c8a6a8 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -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 +} diff --git a/internal/services/project.go b/internal/services/project.go new file mode 100644 index 0000000..7f87a79 --- /dev/null +++ b/internal/services/project.go @@ -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 +} diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index c2d983c..6a1457a 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -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() +} diff --git a/internal/services/sync/service_order_test.go b/internal/services/sync/service_order_test.go new file mode 100644 index 0000000..c26131b --- /dev/null +++ b/internal/services/sync/service_order_test.go @@ -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) + } +} diff --git a/internal/services/sync/service_projects_push_test.go b/internal/services/sync/service_projects_push_test.go new file mode 100644 index 0000000..4916fd7 --- /dev/null +++ b/internal/services/sync/service_projects_push_test.go @@ -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 +} diff --git a/migrations/009_add_projects.sql b/migrations/009_add_projects.sql new file mode 100644 index 0000000..0a8a143 --- /dev/null +++ b/migrations/009_add_projects.sql @@ -0,0 +1,45 @@ +-- Add projects and attach configurations to projects + +CREATE TABLE qt_projects ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) NOT NULL UNIQUE, + owner_username VARCHAR(100) NOT NULL, + name VARCHAR(200) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_qt_projects_owner_username (owner_username), + INDEX idx_qt_projects_is_active (is_active), + INDEX idx_qt_projects_is_system (is_system) +); + +ALTER TABLE qt_configurations + ADD COLUMN project_uuid CHAR(36) NULL AFTER app_version, + ADD INDEX idx_qt_configurations_project_uuid (project_uuid), + ADD CONSTRAINT fk_qt_configurations_project_uuid + FOREIGN KEY (project_uuid) REFERENCES qt_projects(uuid) + ON UPDATE CASCADE + ON DELETE SET NULL; + +-- One system project per owner: "Без проекта" +INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at) +SELECT UUID(), owners.owner_username, 'Без проекта', TRUE, TRUE, NOW(), NOW() +FROM ( + SELECT DISTINCT owner_username + FROM qt_configurations +) AS owners +LEFT JOIN qt_projects p + ON p.owner_username = owners.owner_username + AND p.name = 'Без проекта' + AND p.is_system = TRUE +WHERE p.id IS NULL; + +-- Attach all existing configurations without project to the system project +UPDATE qt_configurations c +JOIN qt_projects p + ON p.owner_username = c.owner_username + AND p.name = 'Без проекта' + AND p.is_system = TRUE +SET c.project_uuid = p.uuid +WHERE c.project_uuid IS NULL OR c.project_uuid = ''; diff --git a/web/templates/base.html b/web/templates/base.html index 7b489b1..d9a7155 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -19,7 +19,7 @@