Compare commits
3 Commits
stable
...
1bec110d91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bec110d91 | ||
|
|
6392e4b4a9 | ||
|
|
8f7defdb8a |
24
README.md
24
README.md
@@ -85,6 +85,30 @@ auth:
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Минимальные права БД для пользователя квотаций
|
||||
|
||||
Если нужен пользователь, который может создавать/редактировать квотации, но не может управлять ценами:
|
||||
|
||||
```sql
|
||||
DROP USER IF EXISTS 'quote_user'@'%';
|
||||
CREATE USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
|
||||
-- чтение данных для расчета/просмотра
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_users TO 'quote_user'@'%';
|
||||
|
||||
-- работа с квотациями
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Важно: этот вариант не ограничивает редактирование только своими записями в `qt_configurations`.
|
||||
Если пересоздавать пользователя нельзя, используйте `SHOW GRANTS FOR 'quote_user'@'%';` и сделайте точечные `REVOKE`.
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
|
||||
@@ -66,7 +66,7 @@ func main() {
|
||||
|
||||
// Get all configurations from MariaDB
|
||||
var configs []models.Configuration
|
||||
if err := mariaDB.Preload("User").Find(&configs).Error; err != nil {
|
||||
if err := mariaDB.Find(&configs).Error; err != nil {
|
||||
log.Fatalf("Failed to fetch configurations: %v", err)
|
||||
}
|
||||
|
||||
@@ -74,13 +74,10 @@ 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
|
||||
if userName == "" && c.User != nil {
|
||||
userName = c.User.Username
|
||||
}
|
||||
if userName == "" {
|
||||
userName = "unknown"
|
||||
}
|
||||
@@ -131,14 +128,10 @@ func main() {
|
||||
UpdatedAt: now,
|
||||
SyncedAt: &now,
|
||||
SyncStatus: "synced",
|
||||
OriginalUserID: c.UserID,
|
||||
OriginalUserID: derefUint(c.UserID),
|
||||
OriginalUsername: c.OwnerUsername,
|
||||
}
|
||||
|
||||
if localConfig.OriginalUsername == "" && c.User != nil {
|
||||
localConfig.OriginalUsername = c.User.Username
|
||||
}
|
||||
|
||||
if err := local.SaveConfiguration(localConfig); err != nil {
|
||||
log.Printf(" ERROR: %s - %v", c.Name, err)
|
||||
errors++
|
||||
@@ -173,3 +166,10 @@ func main() {
|
||||
|
||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
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/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||
@@ -55,6 +56,7 @@ func main() {
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
||||
appmeta.SetVersion(Version)
|
||||
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||
if err != nil {
|
||||
|
||||
26
internal/appmeta/version.go
Normal file
26
internal/appmeta/version.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package appmeta
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var appVersion atomic.Value
|
||||
|
||||
func init() {
|
||||
appVersion.Store("dev")
|
||||
}
|
||||
|
||||
// SetVersion configures the running application version string.
|
||||
func SetVersion(v string) {
|
||||
if v == "" {
|
||||
v = "dev"
|
||||
}
|
||||
appVersion.Store(v)
|
||||
}
|
||||
|
||||
// Version returns the running application version string.
|
||||
func Version() string {
|
||||
if v, ok := appVersion.Load().(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: cfg.UserID,
|
||||
OriginalUserID: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: local.UUID,
|
||||
UserID: local.OriginalUserID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
@@ -76,10 +75,21 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
if local.ServerID != nil {
|
||||
cfg.ID = *local.ServerID
|
||||
}
|
||||
if local.OriginalUserID != 0 {
|
||||
userID := local.OriginalUserID
|
||||
cfg.UserID = &userID
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
||||
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
name := pl.Notification
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"github.com/glebarez/sqlite"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -238,6 +239,7 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
||||
VersionNo: maxVersion + 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: appmeta.Version(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
|
||||
@@ -103,6 +103,7 @@ func backfillConfigurationVersions(tx *gorm.DB) error {
|
||||
VersionNo: 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: "backfill",
|
||||
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
|
||||
}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
|
||||
@@ -94,6 +94,7 @@ type LocalConfigurationVersion struct {
|
||||
Data string `gorm:"type:text;not null" json:"data"`
|
||||
ChangeNote *string `json:"change_note,omitempty"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime;index:idx_lcv_config_created,sort:desc,priority:2" json:"created_at"`
|
||||
Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
|
||||
}
|
||||
|
||||
@@ -42,8 +42,9 @@ func (c ConfigItems) Total() float64 {
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID uint `gorm:"not null" json:"user_id"` // Legacy owner field (kept for backward compatibility)
|
||||
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"`
|
||||
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"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
|
||||
@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||
|
||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").First(&config, id).Error
|
||||
err := r.db.First(&config, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error
|
||||
|
||||
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
|
||||
err := r.db.Where("uuid = ?", uuid).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -47,12 +47,11 @@ func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
ownerScope := "owner_username = ? OR (COALESCE(owner_username, '') = '' AND user_id IN (SELECT id FROM qt_users WHERE username = ?))"
|
||||
ownerScope := "owner_username = ?"
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername, ownerUsername).Count(&total)
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Where(ownerScope, ownerUsername, ownerUsername).
|
||||
Where(ownerScope, ownerUsername).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
@@ -67,7 +66,6 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Where("is_template = ?", true).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
@@ -84,7 +82,6 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
|
||||
|
||||
r.db.Model(&models.Configuration{}).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
@@ -820,6 +821,7 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
Data: snapshot,
|
||||
ChangeNote: &changeNote,
|
||||
CreatedBy: createdByPtr,
|
||||
AppVersion: appmeta.Version(),
|
||||
}
|
||||
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
@@ -915,6 +917,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
Data: target.Data,
|
||||
ChangeNote: &changeNote,
|
||||
CreatedBy: stringPtrOrNil(userID),
|
||||
AppVersion: appmeta.Version(),
|
||||
}
|
||||
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -605,15 +606,10 @@ func (s *Service) ensureConfigurationOwner(mariaDB *gorm.DB, cfg *models.Configu
|
||||
return fmt.Errorf("owner username is empty")
|
||||
}
|
||||
|
||||
userID, err := models.EnsureDBUser(mariaDB, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userID == 0 {
|
||||
return fmt.Errorf("resolved user ID is 0 for owner %q", ownerUsername)
|
||||
}
|
||||
|
||||
cfg.UserID = userID
|
||||
// user_id is legacy and no longer used for ownership in local-first mode.
|
||||
// Keep it NULL on writes; ownership is represented by owner_username.
|
||||
cfg.UserID = nil
|
||||
cfg.AppVersion = appmeta.Version()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -641,6 +637,10 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
|
||||
|
||||
currentCfg, currentVersionID, currentVersionNo, err := s.loadCurrentConfigurationState(payload.ConfigurationUUID)
|
||||
if err != nil {
|
||||
// Local config may be gone (e.g. stale queue item after delete/cleanup). Treat as no-op.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return payload, payload.Snapshot, true, nil
|
||||
}
|
||||
// create->deactivate race: config may no longer be active/visible locally, skip stale create.
|
||||
if change.Operation == "create" {
|
||||
return payload, payload.Snapshot, true, nil
|
||||
|
||||
@@ -14,6 +14,7 @@ CREATE TABLE local_configuration_versions (
|
||||
data TEXT NOT NULL,
|
||||
change_note TEXT NULL,
|
||||
created_by TEXT NULL,
|
||||
app_version TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
|
||||
UNIQUE(configuration_uuid, version_no)
|
||||
@@ -36,6 +37,7 @@ INSERT INTO local_configuration_versions (
|
||||
data,
|
||||
change_note,
|
||||
created_by,
|
||||
app_version,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
@@ -58,10 +60,12 @@ SELECT
|
||||
'synced_at', synced_at,
|
||||
'sync_status', sync_status,
|
||||
'original_user_id', original_user_id,
|
||||
'original_username', original_username
|
||||
'original_username', original_username,
|
||||
'app_version', NULL
|
||||
) AS data,
|
||||
'Initial snapshot backfill (v1)' AS change_note,
|
||||
NULL AS created_by,
|
||||
NULL AS app_version,
|
||||
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
|
||||
FROM local_configurations;
|
||||
|
||||
|
||||
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Detach qt_configurations from qt_users (ownership is owner_username text)
|
||||
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.
|
||||
|
||||
SET @fk_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'qt_configurations'
|
||||
AND CONSTRAINT_NAME = 'fk_qt_configurations_user'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);
|
||||
|
||||
SET @drop_fk_sql := IF(
|
||||
@fk_exists > 0,
|
||||
'ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_user',
|
||||
'SELECT ''fk_qt_configurations_user not found, skip'' '
|
||||
);
|
||||
PREPARE stmt_drop_fk FROM @drop_fk_sql;
|
||||
EXECUTE stmt_drop_fk;
|
||||
DEALLOCATE PREPARE stmt_drop_fk;
|
||||
|
||||
-- user_id becomes optional legacy column (can stay NULL)
|
||||
ALTER TABLE qt_configurations
|
||||
MODIFY COLUMN user_id BIGINT UNSIGNED NULL;
|
||||
|
||||
4
migrations/008_add_app_version_to_configurations.sql
Normal file
4
migrations/008_add_app_version_to_configurations.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Track application version used for configuration writes (create/update via sync)
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;
|
||||
|
||||
Reference in New Issue
Block a user