Drop qt_users dependency for configs and track app version
This commit is contained in:
@@ -66,7 +66,7 @@ func main() {
|
|||||||
|
|
||||||
// Get all configurations from MariaDB
|
// Get all configurations from MariaDB
|
||||||
var configs []models.Configuration
|
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)
|
log.Fatalf("Failed to fetch configurations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +74,10 @@ func main() {
|
|||||||
localCount := local.CountConfigurations()
|
localCount := local.CountConfigurations()
|
||||||
log.Printf("Found %d configurations in local SQLite", localCount)
|
log.Printf("Found %d configurations in local SQLite", localCount)
|
||||||
|
|
||||||
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 := c.OwnerUsername
|
userName := c.OwnerUsername
|
||||||
if userName == "" && c.User != nil {
|
|
||||||
userName = c.User.Username
|
|
||||||
}
|
|
||||||
if userName == "" {
|
if userName == "" {
|
||||||
userName = "unknown"
|
userName = "unknown"
|
||||||
}
|
}
|
||||||
@@ -131,14 +128,10 @@ func main() {
|
|||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
SyncedAt: &now,
|
SyncedAt: &now,
|
||||||
SyncStatus: "synced",
|
SyncStatus: "synced",
|
||||||
OriginalUserID: c.UserID,
|
OriginalUserID: derefUint(c.UserID),
|
||||||
OriginalUsername: c.OwnerUsername,
|
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 {
|
||||||
log.Printf(" ERROR: %s - %v", c.Name, err)
|
log.Printf(" ERROR: %s - %v", c.Name, err)
|
||||||
errors++
|
errors++
|
||||||
@@ -173,3 +166,10 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
|
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"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
"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/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||||
@@ -55,6 +56,7 @@ func main() {
|
|||||||
|
|
||||||
exePath, _ := os.Executable()
|
exePath, _ := os.Executable()
|
||||||
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
||||||
|
appmeta.SetVersion(Version)
|
||||||
|
|
||||||
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||||
if err != nil {
|
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,
|
CreatedAt: cfg.CreatedAt,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
SyncStatus: "pending",
|
SyncStatus: "pending",
|
||||||
OriginalUserID: cfg.UserID,
|
OriginalUserID: derefUint(cfg.UserID),
|
||||||
OriginalUsername: cfg.OwnerUsername,
|
OriginalUsername: cfg.OwnerUsername,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
|
|
||||||
cfg := &models.Configuration{
|
cfg := &models.Configuration{
|
||||||
UUID: local.UUID,
|
UUID: local.UUID,
|
||||||
UserID: local.OriginalUserID,
|
|
||||||
OwnerUsername: local.OriginalUsername,
|
OwnerUsername: local.OriginalUsername,
|
||||||
Name: local.Name,
|
Name: local.Name,
|
||||||
Items: items,
|
Items: items,
|
||||||
@@ -76,10 +75,21 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
if local.ServerID != nil {
|
if local.ServerID != nil {
|
||||||
cfg.ID = *local.ServerID
|
cfg.ID = *local.ServerID
|
||||||
}
|
}
|
||||||
|
if local.OriginalUserID != 0 {
|
||||||
|
userID := local.OriginalUserID
|
||||||
|
cfg.UserID = &userID
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefUint(v *uint) uint {
|
||||||
|
if v == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
||||||
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||||
name := pl.Notification
|
name := pl.Notification
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
uuidpkg "github.com/google/uuid"
|
uuidpkg "github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -238,6 +239,7 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
|||||||
VersionNo: maxVersion + 1,
|
VersionNo: maxVersion + 1,
|
||||||
Data: snapshot,
|
Data: snapshot,
|
||||||
ChangeNote: ¬e,
|
ChangeNote: ¬e,
|
||||||
|
AppVersion: appmeta.Version(),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if err := tx.Create(version).Error; err != nil {
|
if err := tx.Create(version).Error; err != nil {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ func backfillConfigurationVersions(tx *gorm.DB) error {
|
|||||||
VersionNo: 1,
|
VersionNo: 1,
|
||||||
Data: snapshot,
|
Data: snapshot,
|
||||||
ChangeNote: ¬e,
|
ChangeNote: ¬e,
|
||||||
|
AppVersion: "backfill",
|
||||||
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
|
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
|
||||||
}
|
}
|
||||||
if err := tx.Create(&version).Error; err != nil {
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ type LocalConfigurationVersion struct {
|
|||||||
Data string `gorm:"type:text;not null" json:"data"`
|
Data string `gorm:"type:text;not null" json:"data"`
|
||||||
ChangeNote *string `json:"change_note,omitempty"`
|
ChangeNote *string `json:"change_note,omitempty"`
|
||||||
CreatedBy *string `json:"created_by,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"`
|
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"`
|
Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ 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"` // 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"`
|
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"`
|
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"`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
|||||||
|
|
||||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||||
var config models.Configuration
|
var config models.Configuration
|
||||||
err := r.db.Preload("User").First(&config, id).Error
|
err := r.db.First(&config, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||||
var config models.Configuration
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -47,12 +47,11 @@ func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit
|
|||||||
var configs []models.Configuration
|
var configs []models.Configuration
|
||||||
var total int64
|
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.
|
err := r.db.
|
||||||
Preload("User").
|
Where(ownerScope, ownerUsername).
|
||||||
Where(ownerScope, ownerUsername, ownerUsername).
|
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Limit(limit).
|
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)
|
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||||
err := r.db.
|
err := r.db.
|
||||||
Preload("User").
|
|
||||||
Where("is_template = ?", true).
|
Where("is_template = ?", true).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
@@ -84,7 +82,6 @@ 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).
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
@@ -820,6 +821,7 @@ func (s *LocalConfigurationService) appendVersionTx(
|
|||||||
Data: snapshot,
|
Data: snapshot,
|
||||||
ChangeNote: &changeNote,
|
ChangeNote: &changeNote,
|
||||||
CreatedBy: createdByPtr,
|
CreatedBy: createdByPtr,
|
||||||
|
AppVersion: appmeta.Version(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(version).Error; err != nil {
|
if err := tx.Create(version).Error; err != nil {
|
||||||
@@ -915,6 +917,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
|||||||
Data: target.Data,
|
Data: target.Data,
|
||||||
ChangeNote: &changeNote,
|
ChangeNote: &changeNote,
|
||||||
CreatedBy: stringPtrOrNil(userID),
|
CreatedBy: stringPtrOrNil(userID),
|
||||||
|
AppVersion: appmeta.Version(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(version).Error; err != nil {
|
if err := tx.Create(version).Error; err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"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")
|
return fmt.Errorf("owner username is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := models.EnsureDBUser(mariaDB, ownerUsername)
|
// user_id is legacy and no longer used for ownership in local-first mode.
|
||||||
if err != nil {
|
// Keep it NULL on writes; ownership is represented by owner_username.
|
||||||
return err
|
cfg.UserID = nil
|
||||||
}
|
cfg.AppVersion = appmeta.Version()
|
||||||
if userID == 0 {
|
|
||||||
return fmt.Errorf("resolved user ID is 0 for owner %q", ownerUsername)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.UserID = userID
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ CREATE TABLE local_configuration_versions (
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
change_note TEXT NULL,
|
change_note TEXT NULL,
|
||||||
created_by TEXT NULL,
|
created_by TEXT NULL,
|
||||||
|
app_version TEXT NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
|
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
|
||||||
UNIQUE(configuration_uuid, version_no)
|
UNIQUE(configuration_uuid, version_no)
|
||||||
@@ -36,6 +37,7 @@ INSERT INTO local_configuration_versions (
|
|||||||
data,
|
data,
|
||||||
change_note,
|
change_note,
|
||||||
created_by,
|
created_by,
|
||||||
|
app_version,
|
||||||
created_at
|
created_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -58,10 +60,12 @@ SELECT
|
|||||||
'synced_at', synced_at,
|
'synced_at', synced_at,
|
||||||
'sync_status', sync_status,
|
'sync_status', sync_status,
|
||||||
'original_user_id', original_user_id,
|
'original_user_id', original_user_id,
|
||||||
'original_username', original_username
|
'original_username', original_username,
|
||||||
|
'app_version', NULL
|
||||||
) AS data,
|
) AS data,
|
||||||
'Initial snapshot backfill (v1)' AS change_note,
|
'Initial snapshot backfill (v1)' AS change_note,
|
||||||
NULL AS created_by,
|
NULL AS created_by,
|
||||||
|
NULL AS app_version,
|
||||||
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
|
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
|
||||||
FROM local_configurations;
|
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