From af838185642e781d7008136e36b901f88b85d5f4 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 24 Feb 2026 15:09:12 +0300 Subject: [PATCH] fix(pricelists): tolerate restricted DB grants and use embedded assets only --- bible/03-database.md | 8 +++++ cmd/qfs/main.go | 15 ++++----- internal/handlers/setup.go | 11 ++----- internal/handlers/sync.go | 13 ++------ internal/handlers/web.go | 56 +++++++++----------------------- internal/repository/pricelist.go | 6 +++- 6 files changed, 40 insertions(+), 69 deletions(-) diff --git a/bible/03-database.md b/bible/03-database.md index 47ef820..75817ee 100644 --- a/bible/03-database.md +++ b/bible/03-database.md @@ -100,6 +100,8 @@ Database: `RFQ_LOG` | `qt_categories` | Component categories | SELECT | | `qt_pricelists` | Pricelists | SELECT | | `qt_pricelist_items` | Pricelist line items | SELECT | +| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT | +| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT | | `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE | | `qt_projects` | Projects | SELECT, INSERT, UPDATE | | `qt_client_local_migrations` | Migration catalog | SELECT only | @@ -116,6 +118,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO ''@'%'; GRANT SELECT ON RFQ_LOG.qt_categories TO ''@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO ''@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO ''@'%'; +GRANT SELECT ON RFQ_LOG.lot_partnumbers TO ''@'%'; +GRANT SELECT ON RFQ_LOG.stock_log TO ''@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO ''@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO ''@'%'; @@ -140,6 +144,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_categories 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.lot_partnumbers TO 'quote_user'@'%'; +GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%'; @@ -152,6 +158,8 @@ FLUSH PRIVILEGES; SHOW GRANTS FOR 'quote_user'@'%'; ``` +**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`. + **Note:** If you see `Access denied for user ...@''`, check for conflicting user entries (user@localhost vs user@'%'). --- diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 720a08e..86deb4e 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -555,11 +555,11 @@ func runSetupMode(local *localdb.LocalDB) { router := gin.New() router.Use(gin.Recovery()) - staticPath := filepath.Join("web", "static") - if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() { - router.Static("/static", staticPath) - } else if staticFS, err := qfassets.StaticFS(); err == nil { + if staticFS, err := qfassets.StaticFS(); err == nil { router.StaticFS("/static", http.FS(staticFS)) + } else { + slog.Error("failed to load embedded static assets", "error", err) + os.Exit(1) } // Setup routes only @@ -847,11 +847,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.Use(middleware.OfflineDetector(connMgr, local)) // Static files (use filepath.Join for Windows compatibility) - staticPath := filepath.Join("web", "static") - if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() { - router.Static("/static", staticPath) - } else if staticFS, err := qfassets.StaticFS(); err == nil { + if staticFS, err := qfassets.StaticFS(); err == nil { router.StaticFS("/static", http.FS(staticFS)) + } else { + return nil, nil, fmt.Errorf("load embedded static assets: %w", err) } // Health check diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 60c927b..967dd13 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -6,8 +6,6 @@ import ( "log/slog" "net" "net/http" - "os" - "path/filepath" "strconv" "time" @@ -28,7 +26,7 @@ type SetupHandler struct { restartSig chan struct{} } -func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) { +func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) { funcMap := template.FuncMap{ "sub": func(a, b int) int { return a - b }, "add": func(a, b int) int { return a + b }, @@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te templates := make(map[string]*template.Template) // Load setup template (standalone, no base needed) - setupPath := filepath.Join(templatesPath, "setup.html") var tmpl *template.Template var err error - if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() { - tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath) - } else { - tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html") - } + tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html") if err != nil { return nil, fmt.Errorf("parsing setup template: %w", err) } diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 8f04816..9ea5bf3 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -6,8 +6,6 @@ import ( "html/template" "log/slog" "net/http" - "os" - "path/filepath" stdsync "sync" "time" @@ -32,16 +30,9 @@ type SyncHandler struct { } // NewSyncHandler creates a new sync handler -func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) { +func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) { // Load sync_status partial template - partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") - var tmpl *template.Template - var err error - if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() { - tmpl, err = template.ParseFiles(partialPath) - } else { - tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html") - } + tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html") if err != nil { return nil, err } diff --git a/internal/handlers/web.go b/internal/handlers/web.go index b1b7c5d..c841b36 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -2,8 +2,6 @@ package handlers import ( "html/template" - "os" - "path/filepath" "strconv" qfassets "git.mchus.pro/mchus/quoteforge" @@ -17,7 +15,7 @@ type WebHandler struct { componentService *services.ComponentService } -func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) { +func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) { funcMap := template.FuncMap{ "sub": func(a, b int) int { return a - b }, "add": func(a, b int) int { return a + b }, @@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer } templates := make(map[string]*template.Template) - basePath := filepath.Join(templatesPath, "base.html") - useDisk := false - if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() { - useDisk = true - } - // Load each page template with base simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"} for _, page := range simplePages { - pagePath := filepath.Join(templatesPath, page) var tmpl *template.Template var err error - if useDisk { - tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath) - } else { - tmpl, err = template.New("").Funcs(funcMap).ParseFS( - qfassets.TemplatesFS, - "web/templates/base.html", - "web/templates/"+page, - ) - } + tmpl, err = template.New("").Funcs(funcMap).ParseFS( + qfassets.TemplatesFS, + "web/templates/base.html", + "web/templates/"+page, + ) if err != nil { return nil, err } @@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer } // Index page needs components_list.html as well - indexPath := filepath.Join(templatesPath, "index.html") - componentsListPath := filepath.Join(templatesPath, "components_list.html") var indexTmpl *template.Template var err error - if useDisk { - indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath) - } else { - indexTmpl, err = template.New("").Funcs(funcMap).ParseFS( - qfassets.TemplatesFS, - "web/templates/base.html", - "web/templates/index.html", - "web/templates/components_list.html", - ) - } + indexTmpl, err = template.New("").Funcs(funcMap).ParseFS( + qfassets.TemplatesFS, + "web/templates/base.html", + "web/templates/index.html", + "web/templates/components_list.html", + ) if err != nil { return nil, err } @@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer // Load partial templates (no base needed) partials := []string{"components_list.html"} for _, partial := range partials { - partialPath := filepath.Join(templatesPath, partial) var tmpl *template.Template var err error - if useDisk { - tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath) - } else { - tmpl, err = template.New("").Funcs(funcMap).ParseFS( - qfassets.TemplatesFS, - "web/templates/"+partial, - ) - } + tmpl, err = template.New("").Funcs(funcMap).ParseFS( + qfassets.TemplatesFS, + "web/templates/"+partial, + ) if err != nil { return nil, err } diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index 3ca9344..282df8d 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -3,6 +3,7 @@ package repository import ( "errors" "fmt" + "log/slog" "sort" "strconv" "strings" @@ -245,8 +246,11 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear items[i].Category = strings.TrimSpace(items[i].LotCategory) } + // Stock/partnumber enrichment is optional for pricelist item listing. + // Return base pricelist rows (lot_name/price/category) even when DB user lacks + // access to stock mapping tables (e.g. lot_partnumbers, stock_log). if err := r.enrichItemsWithStock(items); err != nil { - return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err) + slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err) } return items, total, nil