Store configuration owner by MariaDB username

This commit is contained in:
Mikhail Chusavitin
2026-02-04 12:20:41 +03:00
parent f42b850734
commit f4f92dea66
15 changed files with 281 additions and 223 deletions

View File

@@ -77,10 +77,13 @@ func main() {
if *dryRun { if *dryRun {
log.Println("\n[DRY RUN] Would migrate the following configurations:") log.Println("\n[DRY RUN] Would migrate the following configurations:")
for _, c := range configs { for _, c := range configs {
userName := "unknown" userName := c.OwnerUsername
if c.User != nil { if userName == "" && c.User != nil {
userName = c.User.Username userName = c.User.Username
} }
if userName == "" {
userName = "unknown"
}
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items)) log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
} }
log.Printf("\nTotal: %d configurations", len(configs)) log.Printf("\nTotal: %d configurations", len(configs))
@@ -129,6 +132,11 @@ func main() {
SyncedAt: &now, SyncedAt: &now,
SyncStatus: "synced", SyncStatus: "synced",
OriginalUserID: c.UserID, OriginalUserID: c.UserID,
OriginalUsername: c.OwnerUsername,
}
if localConfig.OriginalUsername == "" && c.User != nil {
localConfig.OriginalUsername = c.User.Username
} }
if err := local.SaveConfiguration(localConfig); err != nil { if err := local.SaveConfiguration(localConfig); err != nil {

View File

@@ -127,7 +127,6 @@ func main() {
connMgr := db.NewConnectionManager(local) connMgr := db.NewConnectionManager(local)
dbUser := local.GetDBUser() dbUser := local.GetDBUser()
dbUserID := uint(1)
// Try to connect to MariaDB on startup // Try to connect to MariaDB on startup
mariaDB, err := connMgr.GetDB() mariaDB, err := connMgr.GetDB()
@@ -136,12 +135,6 @@ func main() {
mariaDB = nil mariaDB = nil
} else { } else {
slog.Info("successfully connected to MariaDB on startup") slog.Info("successfully connected to MariaDB on startup")
// Ensure DB user exists and get their ID
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
slog.Error("failed to ensure DB user", "error", err)
// Continue with default ID
dbUserID = uint(1)
}
} }
slog.Info("starting QuoteForge server", slog.Info("starting QuoteForge server",
@@ -149,7 +142,6 @@ func main() {
"host", cfg.Server.Host, "host", cfg.Server.Host,
"port", cfg.Server.Port, "port", cfg.Server.Port,
"db_user", dbUser, "db_user", dbUser,
"db_user_id", dbUserID,
"online", mariaDB != nil, "online", mariaDB != nil,
) )
@@ -171,7 +163,7 @@ func main() {
} }
gin.SetMode(cfg.Server.Mode) gin.SetMode(cfg.Server.Mode)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID) router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser)
if err != nil { if err != nil {
slog.Error("failed to setup router", "error", err) slog.Error("failed to setup router", "error", err)
os.Exit(1) os.Exit(1)
@@ -400,7 +392,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil return db, nil
} }
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) { func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string) (*gin.Engine, *sync.Service, error) {
// mariaDB may be nil if we're in offline mode // mariaDB may be nil if we're in offline mode
// Repositories // Repositories
@@ -672,7 +664,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return return
} }
config, err := configService.Create(dbUserID, &req) // use DB user ID config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -746,7 +738,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return return
} }
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID) config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return

View File

@@ -1,13 +1,12 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type ConfigurationHandler struct { type ConfigurationHandler struct {
@@ -26,11 +25,11 @@ func NewConfigurationHandler(
} }
func (h *ConfigurationHandler) List(c *gin.Context) { func (h *ConfigurationHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
configs, total, err := h.configService.ListByUser(userID, page, perPage) configs, total, err := h.configService.ListByUser(username, page, perPage)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -45,7 +44,7 @@ func (h *ConfigurationHandler) List(c *gin.Context) {
} }
func (h *ConfigurationHandler) Create(c *gin.Context) { func (h *ConfigurationHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -53,7 +52,7 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
return return
} }
config, err := h.configService.Create(userID, &req) config, err := h.configService.Create(username, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -63,10 +62,10 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
} }
func (h *ConfigurationHandler) Get(c *gin.Context) { func (h *ConfigurationHandler) Get(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID) config, err := h.configService.GetByUUID(uuid, username)
if err != nil { if err != nil {
status := http.StatusNotFound status := http.StatusNotFound
if err == services.ErrConfigForbidden { if err == services.ErrConfigForbidden {
@@ -80,7 +79,7 @@ func (h *ConfigurationHandler) Get(c *gin.Context) {
} }
func (h *ConfigurationHandler) Update(c *gin.Context) { func (h *ConfigurationHandler) Update(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req services.CreateConfigRequest var req services.CreateConfigRequest
@@ -89,7 +88,7 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
return return
} }
config, err := h.configService.Update(uuid, userID, &req) config, err := h.configService.Update(uuid, username, &req)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -105,10 +104,10 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
} }
func (h *ConfigurationHandler) Delete(c *gin.Context) { func (h *ConfigurationHandler) Delete(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
err := h.configService.Delete(uuid, userID) err := h.configService.Delete(uuid, username)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -128,7 +127,7 @@ type RenameConfigRequest struct {
} }
func (h *ConfigurationHandler) Rename(c *gin.Context) { func (h *ConfigurationHandler) Rename(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req RenameConfigRequest var req RenameConfigRequest
@@ -137,7 +136,7 @@ func (h *ConfigurationHandler) Rename(c *gin.Context) {
return return
} }
config, err := h.configService.Rename(uuid, userID, req.Name) config, err := h.configService.Rename(uuid, username, req.Name)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -157,7 +156,7 @@ type CloneConfigRequest struct {
} }
func (h *ConfigurationHandler) Clone(c *gin.Context) { func (h *ConfigurationHandler) Clone(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req CloneConfigRequest var req CloneConfigRequest
@@ -166,7 +165,7 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
return return
} }
config, err := h.configService.Clone(uuid, userID, req.Name) config, err := h.configService.Clone(uuid, username, req.Name)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -182,10 +181,10 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
} }
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) { func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := h.configService.RefreshPrices(uuid, userID) config, err := h.configService.RefreshPrices(uuid, username)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {

View File

@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type ExportHandler struct { type ExportHandler struct {
@@ -98,10 +98,10 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
} }
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID) config, err := h.configService.GetByUUID(uuid, username)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return

View File

@@ -31,6 +31,11 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUserID: cfg.UserID, OriginalUserID: cfg.UserID,
OriginalUsername: cfg.OwnerUsername,
}
if local.OriginalUsername == "" && cfg.User != nil {
local.OriginalUsername = cfg.User.Username
} }
if cfg.ID > 0 { if cfg.ID > 0 {
@@ -55,6 +60,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: local.UUID, UUID: local.UUID,
UserID: local.OriginalUserID, UserID: local.OriginalUserID,
OwnerUsername: local.OriginalUsername,
Name: local.Name, Name: local.Name,
Items: items, Items: items,
TotalPrice: local.TotalPrice, TotalPrice: local.TotalPrice,

View File

@@ -75,6 +75,7 @@ type LocalConfiguration struct {
SyncedAt *time.Time `json:"synced_at"` SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified' SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
} }
func (LocalConfiguration) TableName() string { func (LocalConfiguration) TableName() string {

View File

@@ -4,9 +4,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
const ( const (
@@ -99,3 +99,12 @@ func GetUserID(c *gin.Context) uint {
} }
return claims.UserID return claims.UserID
} }
// GetUsername extracts username from context
func GetUsername(c *gin.Context) string {
claims := GetClaims(c)
if claims == nil {
return ""
}
return claims.Username
}

View File

@@ -42,7 +42,8 @@ func (c ConfigItems) Total() float64 {
type Configuration struct { type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"` UserID uint `gorm:"not null" json:"user_id"` // Legacy owner field (kept for backward compatibility)
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"` Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"` Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`

View File

@@ -43,13 +43,16 @@ func (r *ConfigurationRepository) Delete(id uint) error {
return r.db.Delete(&models.Configuration{}, id).Error return r.db.Delete(&models.Configuration{}, id).Error
} }
func (r *ConfigurationRepository) ListByUser(userID uint, offset, limit int) ([]models.Configuration, int64, error) { func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit int) ([]models.Configuration, int64, error) {
var configs []models.Configuration var configs []models.Configuration
var total int64 var total int64
r.db.Model(&models.Configuration{}).Where("user_id = ?", userID).Count(&total) ownerScope := "owner_username = ? OR (COALESCE(owner_username, '') = '' AND user_id IN (SELECT id FROM qt_users WHERE username = ?))"
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername, ownerUsername).Count(&total)
err := r.db. err := r.db.
Where("user_id = ?", userID). Preload("User").
Where(ownerScope, ownerUsername, ownerUsername).
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).
@@ -81,6 +84,7 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
r.db.Model(&models.Configuration{}).Count(&total) r.db.Model(&models.Configuration{}).Count(&total)
err := r.db. err := r.db.
Preload("User").
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).

View File

@@ -19,7 +19,7 @@ type DataSource interface {
// Configurations // Configurations
SaveConfiguration(cfg *models.Configuration) error SaveConfiguration(cfg *models.Configuration) error
GetConfigurations(userID uint) ([]models.Configuration, error) GetConfigurations(ownerUsername string) ([]models.Configuration, error)
GetConfigurationByUUID(uuid string) (*models.Configuration, error) GetConfigurationByUUID(uuid string) (*models.Configuration, error)
DeleteConfiguration(uuid string) error DeleteConfiguration(uuid string) error
@@ -169,6 +169,7 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUsername: cfg.OwnerUsername,
} }
// Convert items // Convert items
@@ -196,10 +197,10 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
} }
// GetConfigurations returns all configurations for a user // GetConfigurations returns all configurations for a user
func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, error) { func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
if r.isOnline { if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB) repo := NewConfigurationRepository(r.mariaDB)
configs, _, err := repo.ListByUser(userID, 0, 1000) configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
return configs, err return configs, err
} }
@@ -223,6 +224,7 @@ func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, er
result[i] = models.Configuration{ result[i] = models.Configuration{
UUID: lc.UUID, UUID: lc.UUID,
OwnerUsername: lc.OriginalUsername,
Name: lc.Name, Name: lc.Name,
Items: items, Items: items,
TotalPrice: lc.TotalPrice, TotalPrice: lc.TotalPrice,

View File

@@ -4,9 +4,9 @@ import (
"errors" "errors"
"time" "time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
) )
var ( var (
@@ -17,7 +17,7 @@ var (
// ConfigurationGetter is an interface for services that can retrieve configurations // ConfigurationGetter is an interface for services that can retrieve configurations
// Used by handlers to work with both ConfigurationService and LocalConfigurationService // Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface { type ConfigurationGetter interface {
GetByUUID(uuid string, userID uint) (*models.Configuration, error) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
} }
type ConfigurationService struct { type ConfigurationService struct {
@@ -47,7 +47,7 @@ type CreateConfigRequest struct {
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
} }
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) { func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
total := req.Items.Total() total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count // If server count is greater than 1, multiply the total by server count
@@ -57,7 +57,7 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
config := &models.Configuration{ config := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -77,27 +77,27 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
return config, nil return config, nil
} }
func (s *ConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) { func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
// Allow access if user owns config or it's a template // Allow access if user owns config or it's a template
if config.UserID != userID && !config.IsTemplate { if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
return config, nil return config, nil
} }
func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) { func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -123,26 +123,26 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
return config, nil return config, nil
} }
func (s *ConfigurationService) Delete(uuid string, userID uint) error { func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
config, err := s.configRepo.GetByUUID(uuid) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return ErrConfigNotFound return ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return ErrConfigForbidden return ErrConfigForbidden
} }
return s.configRepo.Delete(config.ID) return s.configRepo.Delete(config.ID)
} }
func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) { func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -155,8 +155,8 @@ func (s *ConfigurationService) Rename(uuid string, userID uint, newName string)
return config, nil return config, nil
} }
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) { func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID) original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -171,7 +171,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -188,7 +188,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
return clone, nil return clone, nil
} }
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) { func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -197,7 +197,7 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
return s.configRepo.ListByUser(userID, offset, perPage) return s.configRepo.ListByUser(ownerUsername, offset, perPage)
} }
// ListAll returns all configurations without user filter (for use when auth is disabled) // ListAll returns all configurations without user filter (for use when auth is disabled)
@@ -274,7 +274,7 @@ func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*model
} }
// CloneNoAuth clones configuration without ownership check // CloneNoAuth clones configuration without ownership check
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) { func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
original, err := s.configRepo.GetByUUID(configUUID) original, err := s.configRepo.GetByUUID(configUUID)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
@@ -287,7 +287,7 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, // Use provided user ID OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -356,13 +356,13 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
} }
// RefreshPrices updates all component prices in the configuration with current prices // RefreshPrices updates all component prices in the configuration with current prices
func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) { func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -407,6 +407,19 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
return config, nil return config, nil
} }
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
if config == nil || ownerUsername == "" {
return false
}
if config.OwnerUsername != "" {
return config.OwnerUsername == ownerUsername
}
if config.User != nil {
return config.User.Username == ownerUsername
}
return false
}
// // Export configuration as JSON // // Export configuration as JSON
// type ConfigExport struct { // type ConfigExport struct {
// Name string `json:"name"` // Name string `json:"name"`

View File

@@ -35,7 +35,7 @@ func NewLocalConfigurationService(
} }
// Create creates a new configuration in local SQLite and queues it for sync // Create creates a new configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) { func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
// If online, check for new pricelists first // If online, check for new pricelists first
if s.isOnline() { if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
@@ -50,7 +50,7 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -85,7 +85,7 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
} }
// GetByUUID returns a configuration from local SQLite // GetByUUID returns a configuration from local SQLite
func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) { func (s *LocalConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
@@ -95,7 +95,7 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
cfg := localdb.LocalToConfiguration(localCfg) cfg := localdb.LocalToConfiguration(localCfg)
// Allow access if user owns config or it's a template // Allow access if user owns config or it's a template
if cfg.UserID != userID && !cfg.IsTemplate { if !s.isOwner(localCfg, ownerUsername) && !cfg.IsTemplate {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -103,13 +103,13 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
} }
// Update updates a configuration in local SQLite and queues it for sync // Update updates a configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) { func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -155,13 +155,13 @@ func (s *LocalConfigurationService) Update(uuid string, userID uint, req *Create
} }
// Delete deletes a configuration from local SQLite and queues it for sync // Delete deletes a configuration from local SQLite and queues it for sync
func (s *LocalConfigurationService) Delete(uuid string, userID uint) error { func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return ErrConfigNotFound return ErrConfigNotFound
} }
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return ErrConfigForbidden return ErrConfigForbidden
} }
@@ -179,13 +179,13 @@ func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
} }
// Rename renames a configuration // Rename renames a configuration
func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) { func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -211,8 +211,8 @@ func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName str
} }
// Clone clones a configuration // Clone clones a configuration
func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) { func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID) original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -224,7 +224,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -253,7 +253,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
} }
// ListByUser returns all configurations for a user from local SQLite // ListByUser returns all configurations for a user from local SQLite
func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) { func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
// Get all local configurations // Get all local configurations
localConfigs, err := s.localDB.GetConfigurations() localConfigs, err := s.localDB.GetConfigurations()
if err != nil { if err != nil {
@@ -263,7 +263,7 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
// Filter by user // Filter by user
var userConfigs []models.Configuration var userConfigs []models.Configuration
for _, lc := range localConfigs { for _, lc := range localConfigs {
if lc.OriginalUserID == userID || lc.IsTemplate { if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc)) userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
} }
} }
@@ -292,7 +292,7 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
} }
// RefreshPrices updates all component prices in the configuration from local cache // RefreshPrices updates all component prices in the configuration from local cache
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) { func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
// Get configuration from local SQLite // Get configuration from local SQLite
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
@@ -300,7 +300,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*mo
} }
// Check ownership // Check ownership
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -448,7 +448,7 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
} }
// CloneNoAuth clones configuration without ownership check // CloneNoAuth clones configuration without ownership check
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) { func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
original, err := s.GetByUUIDNoAuth(configUUID) original, err := s.GetByUUIDNoAuth(configUUID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -461,7 +461,7 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -626,3 +626,13 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) { func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
return s.syncService.ImportConfigurationsToLocal() return s.syncService.ImportConfigurationsToLocal()
} }
func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, ownerUsername string) bool {
if cfg == nil || ownerUsername == "" {
return false
}
if cfg.OriginalUsername != "" {
return cfg.OriginalUsername == ownerUsername
}
return false
}

View File

@@ -0,0 +1,10 @@
-- Store configuration owner as username (instead of relying on numeric user_id)
ALTER TABLE qt_configurations
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
ADD INDEX idx_qt_configurations_owner_username (owner_username);
-- Backfill owner_username from qt_users for existing rows
UPDATE qt_configurations c
LEFT JOIN qt_users u ON u.id = c.user_id
SET c.owner_username = COALESCE(u.username, c.owner_username)
WHERE c.owner_username = '';

View File

@@ -861,7 +861,7 @@ function renderAllConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU'); const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1; const serverCount = c.server_count ? c.server_count : 1;
const username = c.user ? c.user.username : '—'; const username = c.owner_username || (c.user ? c.user.username : '—');
html += '<tr class="hover:bg-gray-50">'; html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>'; html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';

View File

@@ -127,6 +127,7 @@ function renderConfigs(configs) {
html += '<thead class="bg-gray-50"><tr>'; html += '<thead class="bg-gray-50"><tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
@@ -137,6 +138,7 @@ function renderConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU'); const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1; const serverCount = c.server_count ? c.server_count : 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
// Calculate price per unit (total / server count) // Calculate price per unit (total / server count)
let pricePerUnit = '—'; let pricePerUnit = '—';
@@ -148,6 +150,7 @@ function renderConfigs(configs) {
html += '<tr class="hover:bg-gray-50">'; html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>'; html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>'; html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';