package repository import ( "git.mchus.pro/mchus/quoteforge/internal/models" "gorm.io/gorm" "gorm.io/gorm/clause" ) 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) UpsertByUUID(project *models.Project) error { if err := r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "uuid"}}, DoUpdates: clause.AssignmentColumns([]string{ "owner_username", "name", "tracker_url", "is_active", "is_system", "updated_at", }), }).Create(project).Error; err != nil { return err } // Ensure caller always gets canonical server ID. var persisted models.Project if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil { return err } project.ID = persisted.ID return nil } 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 }) }