Implements complete offline-first architecture with SQLite caching and MariaDB synchronization. Key features: - Local SQLite database for offline operation (data/quoteforge.db) - Connection settings with encrypted credentials - Component and pricelist caching with auto-sync - Sync API endpoints (/api/sync/status, /components, /pricelists, /all) - Real-time sync status indicator in UI with auto-refresh - Offline mode detection middleware - Migration tool for database initialization - Setup wizard for initial configuration New components: - internal/localdb: SQLite repository layer (components, pricelists, sync) - internal/services/sync: Synchronization service - internal/handlers/sync: Sync API handlers - internal/handlers/setup: Setup wizard handlers - internal/middleware/offline: Offline detection - cmd/migrate: Database migration tool UI improvements: - Setup page for database configuration - Sync status indicator with online/offline detection - Warning icons for pending synchronization - Auto-refresh every 30 seconds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
340 lines
9.4 KiB
Go
340 lines
9.4 KiB
Go
package localdb
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// ConnectionSettings stores MariaDB connection credentials
|
|
type ConnectionSettings struct {
|
|
ID uint `gorm:"primaryKey"`
|
|
Host string `gorm:"not null"`
|
|
Port int `gorm:"not null;default:3306"`
|
|
Database string `gorm:"not null"`
|
|
User string `gorm:"not null"`
|
|
PasswordEncrypted string `gorm:"not null"` // AES encrypted
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
}
|
|
|
|
func (ConnectionSettings) TableName() string {
|
|
return "connection_settings"
|
|
}
|
|
|
|
// LocalDB manages the local SQLite database for settings
|
|
type LocalDB struct {
|
|
db *gorm.DB
|
|
path string
|
|
}
|
|
|
|
// New creates a new LocalDB instance
|
|
func New(dbPath string) (*LocalDB, error) {
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(dbPath)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, fmt.Errorf("creating data directory: %w", err)
|
|
}
|
|
|
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
|
}
|
|
|
|
// Auto-migrate all local tables
|
|
if err := db.AutoMigrate(
|
|
&ConnectionSettings{},
|
|
&LocalConfiguration{},
|
|
&LocalPricelist{},
|
|
&LocalPricelistItem{},
|
|
&LocalComponent{},
|
|
&AppSetting{},
|
|
); err != nil {
|
|
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
|
}
|
|
|
|
slog.Info("local SQLite database initialized", "path", dbPath)
|
|
|
|
return &LocalDB{
|
|
db: db,
|
|
path: dbPath,
|
|
}, nil
|
|
}
|
|
|
|
// HasSettings returns true if connection settings exist
|
|
func (l *LocalDB) HasSettings() bool {
|
|
var count int64
|
|
l.db.Model(&ConnectionSettings{}).Count(&count)
|
|
return count > 0
|
|
}
|
|
|
|
// GetSettings retrieves the connection settings with decrypted password
|
|
func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
|
|
var settings ConnectionSettings
|
|
if err := l.db.First(&settings).Error; err != nil {
|
|
return nil, fmt.Errorf("getting settings: %w", err)
|
|
}
|
|
|
|
// Decrypt password
|
|
password, err := Decrypt(settings.PasswordEncrypted)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypting password: %w", err)
|
|
}
|
|
settings.PasswordEncrypted = password // Return decrypted password in this field
|
|
|
|
return &settings, nil
|
|
}
|
|
|
|
// SaveSettings saves connection settings with encrypted password
|
|
func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error {
|
|
// Encrypt password
|
|
encrypted, err := Encrypt(password)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypting password: %w", err)
|
|
}
|
|
|
|
settings := ConnectionSettings{
|
|
ID: 1, // Always use ID=1 for single settings row
|
|
Host: host,
|
|
Port: port,
|
|
Database: database,
|
|
User: user,
|
|
PasswordEncrypted: encrypted,
|
|
}
|
|
|
|
// Upsert: create or update
|
|
result := l.db.Save(&settings)
|
|
if result.Error != nil {
|
|
return fmt.Errorf("saving settings: %w", result.Error)
|
|
}
|
|
|
|
slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user)
|
|
return nil
|
|
}
|
|
|
|
// DeleteSettings removes all connection settings
|
|
func (l *LocalDB) DeleteSettings() error {
|
|
return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error
|
|
}
|
|
|
|
// GetDSN returns the MariaDB DSN string
|
|
func (l *LocalDB) GetDSN() (string, error) {
|
|
settings, err := l.GetSettings()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
|
settings.User,
|
|
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
|
settings.Host,
|
|
settings.Port,
|
|
settings.Database,
|
|
)
|
|
|
|
return dsn, nil
|
|
}
|
|
|
|
// DB returns the underlying gorm.DB for advanced operations
|
|
func (l *LocalDB) DB() *gorm.DB {
|
|
return l.db
|
|
}
|
|
|
|
// Close closes the database connection
|
|
func (l *LocalDB) Close() error {
|
|
sqlDB, err := l.db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sqlDB.Close()
|
|
}
|
|
|
|
// GetDBUser returns the database username from settings
|
|
func (l *LocalDB) GetDBUser() string {
|
|
settings, err := l.GetSettings()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return settings.User
|
|
}
|
|
|
|
// Configuration methods
|
|
|
|
// SaveConfiguration saves a configuration to local SQLite
|
|
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
|
return l.db.Save(config).Error
|
|
}
|
|
|
|
// GetConfigurations returns all local configurations
|
|
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
|
|
var configs []LocalConfiguration
|
|
err := l.db.Order("created_at DESC").Find(&configs).Error
|
|
return configs, err
|
|
}
|
|
|
|
// GetConfigurationByUUID returns a configuration by UUID
|
|
func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) {
|
|
var config LocalConfiguration
|
|
err := l.db.Where("uuid = ?", uuid).First(&config).Error
|
|
return &config, err
|
|
}
|
|
|
|
// DeleteConfiguration deletes a configuration by UUID
|
|
func (l *LocalDB) DeleteConfiguration(uuid string) error {
|
|
return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error
|
|
}
|
|
|
|
// CountConfigurations returns the number of local configurations
|
|
func (l *LocalDB) CountConfigurations() int64 {
|
|
var count int64
|
|
l.db.Model(&LocalConfiguration{}).Count(&count)
|
|
return count
|
|
}
|
|
|
|
// Pricelist methods
|
|
|
|
// GetLastSyncTime returns the last sync timestamp
|
|
func (l *LocalDB) GetLastSyncTime() *time.Time {
|
|
var setting struct {
|
|
Value string
|
|
}
|
|
if err := l.db.Table("app_settings").
|
|
Where("key = ?", "last_pricelist_sync").
|
|
First(&setting).Error; err != nil {
|
|
return nil
|
|
}
|
|
|
|
t, err := time.Parse(time.RFC3339, setting.Value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|
|
|
|
// SetLastSyncTime sets the last sync timestamp
|
|
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
|
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
|
return l.db.Exec(`
|
|
INSERT INTO app_settings (key, value, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
|
}
|
|
|
|
// CountLocalPricelists returns the number of local pricelists
|
|
func (l *LocalDB) CountLocalPricelists() int64 {
|
|
var count int64
|
|
l.db.Model(&LocalPricelist{}).Count(&count)
|
|
return count
|
|
}
|
|
|
|
// GetLatestLocalPricelist returns the most recently synced pricelist
|
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
|
var pricelist LocalPricelist
|
|
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// GetLocalPricelistByServerID returns a local pricelist by its server ID
|
|
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
|
|
var pricelist LocalPricelist
|
|
if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// GetLocalPricelistByID returns a local pricelist by its local ID
|
|
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
|
var pricelist LocalPricelist
|
|
if err := l.db.First(&pricelist, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// SaveLocalPricelist saves a pricelist to local SQLite
|
|
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
|
return l.db.Save(pricelist).Error
|
|
}
|
|
|
|
// GetLocalPricelists returns all local pricelists
|
|
func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) {
|
|
var pricelists []LocalPricelist
|
|
if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return pricelists, nil
|
|
}
|
|
|
|
// CountLocalPricelistItems returns the number of items for a pricelist
|
|
func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
|
var count int64
|
|
l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count)
|
|
return count
|
|
}
|
|
|
|
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
|
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Batch insert
|
|
batchSize := 500
|
|
for i := 0; i < len(items); i += batchSize {
|
|
end := i + batchSize
|
|
if end > len(items) {
|
|
end = len(items)
|
|
}
|
|
if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetLocalPricelistItems returns items for a local pricelist
|
|
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
|
var items []LocalPricelistItem
|
|
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
|
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
|
var item LocalPricelistItem
|
|
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
|
|
First(&item).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return item.Price, nil
|
|
}
|
|
|
|
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
|
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
|
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
|
Update("is_used", isUsed).Error
|
|
}
|
|
|
|
// DeleteLocalPricelist deletes a pricelist and its items
|
|
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
|
// Delete items first
|
|
if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil {
|
|
return err
|
|
}
|
|
// Delete pricelist
|
|
return l.db.Delete(&LocalPricelist{}, id).Error
|
|
}
|