feat: add projects flow and consolidate default project handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
60
internal/localdb/migration_projects_test.go
Normal file
60
internal/localdb/migration_projects_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user