From dd806bbb1ecb153347239b2224ef7e34ad8b4e59 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Thu, 19 Mar 2026 15:44:42 +0100 Subject: [PATCH 01/11] feat: create procedure for retrieving products --- i18n/migrations/20260319163200_procedures.sql | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 i18n/migrations/20260319163200_procedures.sql diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql new file mode 100644 index 0000000..b10e54d --- /dev/null +++ b/i18n/migrations/20260319163200_procedures.sql @@ -0,0 +1,224 @@ +-- +goose Up +DELIMITER // + +DROP PROCEDURE IF EXISTS get_full_product // + +CREATE PROCEDURE get_full_product( + IN p_id_product INT UNSIGNED, + IN p_id_shop INT UNSIGNED, + IN p_id_lang INT UNSIGNED, + IN p_id_customer INT UNSIGNED, + IN p_id_group INT UNSIGNED, + IN p_id_currency INT UNSIGNED, + IN p_id_country INT UNSIGNED, + IN p_quantity INT UNSIGNED +) +BEGIN + + DECLARE v_tax_rate DECIMAL(10,4) DEFAULT 0; + + SELECT COALESCE(t.rate, 0.0000) INTO v_tax_rate +FROM ps_tax_rule tr +INNER JOIN ps_tax t + ON t.id_tax = tr.id_tax +WHERE tr.id_tax_rules_group = ( + SELECT ps.id_tax_rules_group + FROM ps_product_shop ps + WHERE ps.id_product = p_id_product + AND ps.id_shop = p_id_shop + LIMIT 1 +) + AND tr.id_country = p_id_country +ORDER BY + tr.id_state DESC, + tr.zipcode_from != '' DESC, + tr.id_tax_rule DESC +LIMIT 1; + + /* FINAL JSON */ + SELECT JSON_OBJECT( + + /* ================= PRODUCT ================= */ + 'id_product', p.id_product, + 'reference', p.reference, + 'name', pl.name, + 'description', pl.description, + 'short_description', pl.description_short, + + /* ================= PRICE ================= */ + 'price', JSON_OBJECT( + 'base', COALESCE(ps.price, p.price), + + 'final_tax_excl', + ( + COALESCE(ps.price, p.price) + - IFNULL( + CASE + WHEN sp.reduction_type = 'amount' THEN sp.reduction + WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction + ELSE 0 + END, 0 + ) + ), + + 'final_tax_incl', + ( + ( + COALESCE(ps.price, p.price) + - IFNULL( + CASE + WHEN sp.reduction_type = 'amount' THEN sp.reduction + WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction + ELSE 0 + END, 0 + ) + ) * (1 + v_tax_rate / 100) + ) + ), + + /* ================= META ================= */ + 'active', COALESCE(ps.active, p.active), + 'visibility', COALESCE(ps.visibility, p.visibility), + 'manufacturer', m.name, + 'category', cl.name, + + /* ================= IMAGE ================= */ + 'cover_image', JSON_OBJECT( + 'id', i.id_image, + 'legend', il.legend + ), + + /* ================= FEATURES ================= */ + 'features', ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'name', fl.name, + 'value', fvl.value + ) + ) + FROM ps_feature_product fp + JOIN ps_feature_lang fl + ON fl.id_feature = fp.id_feature AND fl.id_lang = p_id_lang + JOIN ps_feature_value_lang fvl + ON fvl.id_feature_value = fp.id_feature_value AND fvl.id_lang = p_id_lang + WHERE fp.id_product = p.id_product + ), + + /* ================= COMBINATIONS ================= */ + 'combinations', ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'id_product_attribute', pa.id_product_attribute, + 'reference', pa.reference, + + 'price', JSON_OBJECT( + 'impact', COALESCE(pas.price, pa.price), + + 'final_tax_excl', + ( + COALESCE(ps.price, p.price) + + COALESCE(pas.price, pa.price) + ), + + 'final_tax_incl', + ( + ( + COALESCE(ps.price, p.price) + + COALESCE(pas.price, pa.price) + ) * (1 + v_tax_rate / 100) + ) + ), + + 'stock', IFNULL(sa.quantity, 0), + + 'default_on', pas.default_on, + + /* ATTRIBUTES JSON */ + 'attributes', ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'group', agl.name, + 'attribute', al.name + ) + ) + FROM ps_product_attribute_combination pac + JOIN ps_attribute a ON a.id_attribute = pac.id_attribute + JOIN ps_attribute_lang al + ON al.id_attribute = a.id_attribute AND al.id_lang = p_id_lang + JOIN ps_attribute_group_lang agl + ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = p_id_lang + WHERE pac.id_product_attribute = pa.id_product_attribute + ), + + /* IMAGES */ + 'images', ( + SELECT JSON_ARRAYAGG(img.id_image) + FROM ps_product_attribute_image pai + JOIN ps_image img ON img.id_image = pai.id_image + WHERE pai.id_product_attribute = pa.id_product_attribute + ) + + ) + ) + FROM ps_product_attribute pa + JOIN ps_product_attribute_shop pas + ON pas.id_product_attribute = pa.id_product_attribute + AND pas.id_shop = p_id_shop + LEFT JOIN ps_stock_available sa + ON sa.id_product = pa.id_product + AND sa.id_product_attribute = pa.id_product_attribute + AND sa.id_shop = p_id_shop + WHERE pa.id_product = p.id_product + ) + + ) AS product_json + + FROM ps_product p + + LEFT JOIN ps_product_shop ps + ON ps.id_product = p.id_product AND ps.id_shop = p_id_shop + + LEFT JOIN ps_product_lang pl + ON pl.id_product = p.id_product + AND pl.id_lang = p_id_lang + AND pl.id_shop = p_id_shop + + LEFT JOIN ps_category_lang cl + ON cl.id_category = COALESCE(ps.id_category_default, p.id_category_default) + AND cl.id_lang = p_id_lang + AND cl.id_shop = p_id_shop + + LEFT JOIN ps_manufacturer m + ON m.id_manufacturer = p.id_manufacturer + + LEFT JOIN ps_image i + ON i.id_product = p.id_product AND i.cover = 1 + + LEFT JOIN ps_image_lang il + ON il.id_image = i.id_image AND il.id_lang = p_id_lang + + /* SPECIFIC PRICE */ + LEFT JOIN ( + SELECT sp1.* + FROM ps_specific_price sp1 + WHERE sp1.id_product = p_id_product + AND (sp1.id_customer = 0 OR sp1.id_customer = p_id_customer) + AND (sp1.id_group = 0 OR sp1.id_group = p_id_group) + AND (sp1.id_currency = 0 OR sp1.id_currency = p_id_currency) + AND sp1.from_quantity <= p_quantity + ORDER BY + sp1.id_customer DESC, + sp1.id_group DESC, + sp1.from_quantity DESC, + sp1.id_specific_price DESC + LIMIT 1 + ) sp ON sp.id_product = p.id_product + + WHERE p.id_product = p_id_product + + LIMIT 1; + +END // + +DELIMITER ; +-- +goose Down From 2c128a4b36f2d740f61e14f4d0663d9862fa6383 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Thu, 19 Mar 2026 15:44:42 +0100 Subject: [PATCH 02/11] feat: create procedure for retrieving products --- i18n/migrations/20260319163200_procedures.sql | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 i18n/migrations/20260319163200_procedures.sql diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql new file mode 100644 index 0000000..b10e54d --- /dev/null +++ b/i18n/migrations/20260319163200_procedures.sql @@ -0,0 +1,224 @@ +-- +goose Up +DELIMITER // + +DROP PROCEDURE IF EXISTS get_full_product // + +CREATE PROCEDURE get_full_product( + IN p_id_product INT UNSIGNED, + IN p_id_shop INT UNSIGNED, + IN p_id_lang INT UNSIGNED, + IN p_id_customer INT UNSIGNED, + IN p_id_group INT UNSIGNED, + IN p_id_currency INT UNSIGNED, + IN p_id_country INT UNSIGNED, + IN p_quantity INT UNSIGNED +) +BEGIN + + DECLARE v_tax_rate DECIMAL(10,4) DEFAULT 0; + + SELECT COALESCE(t.rate, 0.0000) INTO v_tax_rate +FROM ps_tax_rule tr +INNER JOIN ps_tax t + ON t.id_tax = tr.id_tax +WHERE tr.id_tax_rules_group = ( + SELECT ps.id_tax_rules_group + FROM ps_product_shop ps + WHERE ps.id_product = p_id_product + AND ps.id_shop = p_id_shop + LIMIT 1 +) + AND tr.id_country = p_id_country +ORDER BY + tr.id_state DESC, + tr.zipcode_from != '' DESC, + tr.id_tax_rule DESC +LIMIT 1; + + /* FINAL JSON */ + SELECT JSON_OBJECT( + + /* ================= PRODUCT ================= */ + 'id_product', p.id_product, + 'reference', p.reference, + 'name', pl.name, + 'description', pl.description, + 'short_description', pl.description_short, + + /* ================= PRICE ================= */ + 'price', JSON_OBJECT( + 'base', COALESCE(ps.price, p.price), + + 'final_tax_excl', + ( + COALESCE(ps.price, p.price) + - IFNULL( + CASE + WHEN sp.reduction_type = 'amount' THEN sp.reduction + WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction + ELSE 0 + END, 0 + ) + ), + + 'final_tax_incl', + ( + ( + COALESCE(ps.price, p.price) + - IFNULL( + CASE + WHEN sp.reduction_type = 'amount' THEN sp.reduction + WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction + ELSE 0 + END, 0 + ) + ) * (1 + v_tax_rate / 100) + ) + ), + + /* ================= META ================= */ + 'active', COALESCE(ps.active, p.active), + 'visibility', COALESCE(ps.visibility, p.visibility), + 'manufacturer', m.name, + 'category', cl.name, + + /* ================= IMAGE ================= */ + 'cover_image', JSON_OBJECT( + 'id', i.id_image, + 'legend', il.legend + ), + + /* ================= FEATURES ================= */ + 'features', ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'name', fl.name, + 'value', fvl.value + ) + ) + FROM ps_feature_product fp + JOIN ps_feature_lang fl + ON fl.id_feature = fp.id_feature AND fl.id_lang = p_id_lang + JOIN ps_feature_value_lang fvl + ON fvl.id_feature_value = fp.id_feature_value AND fvl.id_lang = p_id_lang + WHERE fp.id_product = p.id_product + ), + + /* ================= COMBINATIONS ================= */ + 'combinations', ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'id_product_attribute', pa.id_product_attribute, + 'reference', pa.reference, + + 'price', JSON_OBJECT( + 'impact', COALESCE(pas.price, pa.price), + + 'final_tax_excl', + ( + COALESCE(ps.price, p.price) + + COALESCE(pas.price, pa.price) + ), + + 'final_tax_incl', + ( + ( + COALESCE(ps.price, p.price) + + COALESCE(pas.price, pa.price) + ) * (1 + v_tax_rate / 100) + ) + ), + + 'stock', IFNULL(sa.quantity, 0), + + 'default_on', pas.default_on, + + /* ATTRIBUTES JSON */ + 'attributes', ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'group', agl.name, + 'attribute', al.name + ) + ) + FROM ps_product_attribute_combination pac + JOIN ps_attribute a ON a.id_attribute = pac.id_attribute + JOIN ps_attribute_lang al + ON al.id_attribute = a.id_attribute AND al.id_lang = p_id_lang + JOIN ps_attribute_group_lang agl + ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = p_id_lang + WHERE pac.id_product_attribute = pa.id_product_attribute + ), + + /* IMAGES */ + 'images', ( + SELECT JSON_ARRAYAGG(img.id_image) + FROM ps_product_attribute_image pai + JOIN ps_image img ON img.id_image = pai.id_image + WHERE pai.id_product_attribute = pa.id_product_attribute + ) + + ) + ) + FROM ps_product_attribute pa + JOIN ps_product_attribute_shop pas + ON pas.id_product_attribute = pa.id_product_attribute + AND pas.id_shop = p_id_shop + LEFT JOIN ps_stock_available sa + ON sa.id_product = pa.id_product + AND sa.id_product_attribute = pa.id_product_attribute + AND sa.id_shop = p_id_shop + WHERE pa.id_product = p.id_product + ) + + ) AS product_json + + FROM ps_product p + + LEFT JOIN ps_product_shop ps + ON ps.id_product = p.id_product AND ps.id_shop = p_id_shop + + LEFT JOIN ps_product_lang pl + ON pl.id_product = p.id_product + AND pl.id_lang = p_id_lang + AND pl.id_shop = p_id_shop + + LEFT JOIN ps_category_lang cl + ON cl.id_category = COALESCE(ps.id_category_default, p.id_category_default) + AND cl.id_lang = p_id_lang + AND cl.id_shop = p_id_shop + + LEFT JOIN ps_manufacturer m + ON m.id_manufacturer = p.id_manufacturer + + LEFT JOIN ps_image i + ON i.id_product = p.id_product AND i.cover = 1 + + LEFT JOIN ps_image_lang il + ON il.id_image = i.id_image AND il.id_lang = p_id_lang + + /* SPECIFIC PRICE */ + LEFT JOIN ( + SELECT sp1.* + FROM ps_specific_price sp1 + WHERE sp1.id_product = p_id_product + AND (sp1.id_customer = 0 OR sp1.id_customer = p_id_customer) + AND (sp1.id_group = 0 OR sp1.id_group = p_id_group) + AND (sp1.id_currency = 0 OR sp1.id_currency = p_id_currency) + AND sp1.from_quantity <= p_quantity + ORDER BY + sp1.id_customer DESC, + sp1.id_group DESC, + sp1.from_quantity DESC, + sp1.id_specific_price DESC + LIMIT 1 + ) sp ON sp.id_product = p.id_product + + WHERE p.id_product = p_id_product + + LIMIT 1; + +END // + +DELIMITER ; +-- +goose Down From bd97ed1a3bc5b03e3a6a7f83a0ec494a7ffaa684 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Thu, 26 Mar 2026 15:59:13 +0100 Subject: [PATCH 03/11] feat: creat main products query --- .vscode/launch.json | 14 + app/delivery/web/api/restricted/currency.go | 97 ++++ app/delivery/web/api/restricted/product.go | 82 +++ app/delivery/web/init.go | 4 + app/model/currency.go | 25 + app/model/model.go | 18 + app/repos/currencyRepo/currencyRepo.go | 53 ++ app/repos/productsRepo/productsRepo.go | 25 + .../currencyService/currencyService.go | 25 + app/service/productService/productService.go | 25 + app/utils/responseErrors/responseErrors.go | 9 +- .../20260302163122_create_tables.sql | 187 +++++-- .../20260302163123_create_tables_data.sql | 32 ++ i18n/migrations/20260319163200_procedures.sql | 529 +++++++++++------- i18n/migrations/20260320113729_stuff.sql | 9 + repository/currencyRepo/currencyRepo.go | 53 ++ taskfiles/db.yml | 1 + 17 files changed, 959 insertions(+), 229 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 app/delivery/web/api/restricted/currency.go create mode 100644 app/delivery/web/api/restricted/product.go create mode 100644 app/model/currency.go create mode 100644 app/model/model.go create mode 100644 app/repos/currencyRepo/currencyRepo.go create mode 100644 app/repos/productsRepo/productsRepo.go create mode 100644 app/service/currencyService/currencyService.go create mode 100644 app/service/productService/productService.go create mode 100644 i18n/migrations/20260302163123_create_tables_data.sql create mode 100644 i18n/migrations/20260320113729_stuff.sql create mode 100644 repository/currencyRepo/currencyRepo.go diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..082ab29 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "./app/cmd/main.go", + "cwd": "${workspaceFolder}", + "envFile": "${workspaceFolder}/.env" + } + ] +} \ No newline at end of file diff --git a/app/delivery/web/api/restricted/currency.go b/app/delivery/web/api/restricted/currency.go new file mode 100644 index 0000000..3ffa226 --- /dev/null +++ b/app/delivery/web/api/restricted/currency.go @@ -0,0 +1,97 @@ +package restricted + +import ( + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/service/currencyService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + + "github.com/gofiber/fiber/v3" +) + +type CurrencyHandler struct { + CurrencyService *currencyService.CurrencyService + config *config.Config +} + +func NewCurrencyHandler() *CurrencyHandler { + currencyService := currencyService.New() + return &CurrencyHandler{ + CurrencyService: currencyService, + config: config.Get(), + } +} + +func CurrencyHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewCurrencyHandler() + + r.Post("/currency-rate", handler.PostCurrencyRate) + r.Get("/currency-rate/:id", handler.GetCurrencyRate) + // r.Get("/currencies", handler.GetCurrencyRates) + return r +} + +func (h *CurrencyHandler) PostCurrencyRate(c fiber.Ctx) error { + var currencyRate model.CurrencyRate + if err := c.Bind().Body(¤cyRate); err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrJSONBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrJSONBody))) + } + + err := h.CurrencyService.CreateCurrencyRate(¤cyRate) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(nullable.GetNil(""), 1, i18n.T_(c, response.Message_OK))) +} + +func (h *CurrencyHandler) GetCurrencyRate(c fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.Atoi(idStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + + } + + currency, err := h.CurrencyService.GetCurrency(uint(id)) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + // err = h.CurrencyService.GetCurrencyRate(userID, uint(productID), uint(productShopID), uint(productLangID), updates) + // if err != nil { + // return c.Status(responseErrors.GetErrorStatus(err)). + // JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + // } + + return c.JSON(response.Make(currency, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *CurrencyHandler) GetCurrencyRates(c fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.Atoi(idStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + + } + + currency, err := h.CurrencyService.GetCurrency(uint(id)) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + // err = h.CurrencyService.GetCurrencyRate(userID, uint(productID), uint(productShopID), uint(productLangID), updates) + // if err != nil { + // return c.Status(responseErrors.GetErrorStatus(err)). + // JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + // } + + return c.JSON(response.Make(currency, 0, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go new file mode 100644 index 0000000..d9c40a3 --- /dev/null +++ b/app/delivery/web/api/restricted/product.go @@ -0,0 +1,82 @@ +package restricted + +import ( + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/service/productService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type ProductsHandler struct { + productService *productService.ProductService + config *config.Config +} + +// NewListProductsHandler creates a new ListProductsHandler instance +func NewProductsHandler() *ProductsHandler { + productService := productService.New() + return &ProductsHandler{ + productService: productService, + config: config.Get(), + } +} + +func ProductsHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewProductsHandler() + + //TODO: WIP doesn't work yet + r.Get("/product/:id/:country_id/:quantity", handler.GetProductJson) + + return r +} + +func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { + idStr := c.Params("id") + + p_id_product, err := strconv.Atoi(idStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + country_idStr := c.Params("country_id") + + b2b_id_country, err := strconv.Atoi(country_idStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + quantityStr := c.Params("quantity") + + p_quantity, err := strconv.Atoi(quantityStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + id_lang, ok := c.Locals("lang_id").(int) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + p_id_customer, ok := c.Locals("user_id").(int) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + productJson, err := h.productService.GetJSON(p_id_product, id_lang, p_id_customer, b2b_id_country, p_quantity) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&productJson, 1, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index 94c02a6..b35516a 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -97,6 +97,8 @@ func (s *Server) Setup() error { listProducts := s.restricted.Group("/list-products") restricted.ListProductsHandlerRoutes(listProducts) + restricted.ProductsHandlerRoutes(s.restricted) + // locale selector (restricted) // this is basically for changing user's selected language and country localeSelector := s.restricted.Group("/langs-and-countries") @@ -118,6 +120,8 @@ func (s *Server) Setup() error { return c.SendStatus(fiber.StatusNotFound) }) + restricted.CurrencyHandlerRoutes(s.restricted) + // // Restricted routes example // restricted := s.api.Group("/restricted") // restricted.Use(middleware.AuthMiddleware()) diff --git a/app/model/currency.go b/app/model/currency.go new file mode 100644 index 0000000..18ca8ce --- /dev/null +++ b/app/model/currency.go @@ -0,0 +1,25 @@ +package model + +import "time" + +type Currency struct { + ID int `json:"id"` + PsIDCurrency uint `json:"ps_id_currency"` + IsDefault bool `json:"is_default"` + IsActive bool `json:"is_active"` + ConversionRate *float64 `json:"conversion_rate,omitempty"` +} + +func (Currency) TableName() string { + return "b2b_currencies" +} + +type CurrencyRate struct { + B2bIdCurrency uint `json:"b2b_id_currency"` + CreatedAt time.Time `json:"created_at"` + ConversionRate *float64 `json:"conversion_rate,omitempty"` +} + +func (CurrencyRate) TableName() string { + return "b2b_currency_rates" +} diff --git a/app/model/model.go b/app/model/model.go new file mode 100644 index 0000000..620b57a --- /dev/null +++ b/app/model/model.go @@ -0,0 +1,18 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Model struct { + ID uint `gorm:"primarykey;autoIncrement" swaggerignore:"true" json:"id,omitempty" hidden:"true"` + CreatedAt time.Time `gorm:"not null;autoCreateTime" swaggerignore:"true" json:"-"` + UpdatedAt time.Time `gorm:"autoUpdateTime" swaggerignore:"true" json:"-"` + DeletedAt gorm.DeletedAt `gorm:"index" swaggerignore:"true" json:"-"` +} + +// Makes all objects embedding db.Model implementators of ModelWithID interface +func (m Model) ModelWithID() { +} diff --git a/app/repos/currencyRepo/currencyRepo.go b/app/repos/currencyRepo/currencyRepo.go new file mode 100644 index 0000000..97b1b5e --- /dev/null +++ b/app/repos/currencyRepo/currencyRepo.go @@ -0,0 +1,53 @@ +package currencyRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" +) + +type UICurrencyRepo interface { + CreateConversionRate(currencyRate *model.CurrencyRate) error + Get(id uint) (*model.Currency, error) +} + +type CurrencyRepo struct{} + +func New() UICurrencyRepo { + return &CurrencyRepo{} +} + +func (repo *CurrencyRepo) CreateConversionRate(currencyRate *model.CurrencyRate) error { + return db.DB.Debug().Create(currencyRate).Error +} + +func (repo *CurrencyRepo) Get(id uint) (*model.Currency, error) { + var currency model.Currency + + err := db.DB.Table("b2b_currencies c"). + Select("c.*, r.conversion_rate"). + Joins(` + LEFT JOIN b2b_currency_rates r + ON r.b2b_id_currency = c.id + AND r.created_at = ( + SELECT MAX(created_at) + FROM b2b_currency_rates + WHERE b2b_id_currency = c.id + ) + `). + Where("c.id = ?", id). + Scan(¤cy).Error + + return ¤cy, err +} + +func (repo *CurrencyRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Currency], error) { + + found, err := find.Paginate[model.Currency](langId, p, db.DB. + Model(&model.Currency{}). + Scopes(filt.All()...), + ) + + return &found, err +} diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go new file mode 100644 index 0000000..af68a63 --- /dev/null +++ b/app/repos/productsRepo/productsRepo.go @@ -0,0 +1,25 @@ +package productsRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" +) + +type UIProductsRepo interface { + GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*string, error) +} + +type ProductsRepo struct{} + +func New() UIProductsRepo { + return &ProductsRepo{} +} + +func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*string, error) { + var product string + + err := db.DB.Raw(`CALL get_full_product(?,?,?,?,?,?)`, p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity). + Scan(&product). + Error + + return &product, err +} diff --git a/app/service/currencyService/currencyService.go b/app/service/currencyService/currencyService.go new file mode 100644 index 0000000..d4924d8 --- /dev/null +++ b/app/service/currencyService/currencyService.go @@ -0,0 +1,25 @@ +package currencyService + +import ( + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/currencyRepo" +) + +type CurrencyService struct { + repo currencyRepo.UICurrencyRepo +} + +func (s *CurrencyService) GetCurrency(id uint) (*model.Currency, error) { + return s.repo.Get(id) +} + +func (s *CurrencyService) CreateCurrencyRate(currency *model.CurrencyRate) error { + return s.repo.CreateConversionRate(currency) +} + +func New() *CurrencyService { + repo := currencyRepo.New() + return &CurrencyService{ + repo: repo, + } +} diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go new file mode 100644 index 0000000..364079d --- /dev/null +++ b/app/service/productService/productService.go @@ -0,0 +1,25 @@ +package productService + +import ( + "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" +) + +type ProductService struct { + productsRepo productsRepo.UIProductsRepo +} + +func New() *ProductService { + return &ProductService{ + productsRepo: productsRepo.New(), + } +} + +func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*string, error) { + products, err := s.productsRepo.GetJSON(p_id_product, constdata.SHOP_ID, p_id_lang, p_id_customer, b2b_id_country, p_quantity) + if err != nil { + return products, err + } + + return products, nil +} diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index f658430..2b8715d 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -56,6 +56,9 @@ var ( ErrMaxAmtOfCartsReached = errors.New("maximal amount of carts reached") ErrUserHasNoSuchCart = errors.New("user does not have cart with given id") ErrProductOrItsVariationDoesNotExist = errors.New("product or its variation with given ids does not exist") + + // Typed errors for data parsing + ErrJSONBody = errors.New("invalid JSON body") ) // Error represents an error with HTTP status code @@ -153,6 +156,9 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrProductOrItsVariationDoesNotExist): return i18n.T_(c, "error.product_or_its_variation_does_not_exist") + case errors.Is(err, ErrJSONBody): + return i18n.T_(c, "error.err_json_body") + default: return i18n.T_(c, "error.err_internal_server_error") } @@ -191,7 +197,8 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrNoRootFound), errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrUserHasNoSuchCart), - errors.Is(err, ErrProductOrItsVariationDoesNotExist): + errors.Is(err, ErrProductOrItsVariationDoesNotExist), + errors.Is(err, ErrJSONBody): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index a126730..684af5b 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -1,7 +1,7 @@ -- +goose Up CREATE TABLE IF NOT EXISTS b2b_language ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, created_at DATETIME(6) NOT NULL, updated_at DATETIME(6) NULL, deleted_at DATETIME(6) NULL, @@ -19,29 +19,22 @@ CREATE TABLE IF NOT EXISTS b2b_language ( CREATE INDEX IF NOT EXISTS idx_language_deleted_at ON b2b_language (deleted_at); -INSERT IGNORE INTO b2b_language - (id, created_at, updated_at, deleted_at, name, iso_code, lang_code, date_format, date_format_short, rtl, is_default, active, flag) -VALUES - (1, '2022-09-16 17:10:02.837', '2026-03-02 21:24:36.779730', NULL, 'Polski', 'pl', 'pl', '__-__-____', '__-__', 0, 0, 1, '🇵🇱'), - (2, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'English', 'en', 'en', '__-__-____', '__-__', 0, 1, 1, '🇬🇧'), - (3, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'Deutsch', 'de', 'de', '__-__-____', '__-__', 0, 0, 1, '🇩🇪'); - CREATE TABLE IF NOT EXISTS b2b_components ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE UNIQUE INDEX IF NOT EXISTS uk_components_name ON b2b_components (name, id); -- scopes CREATE TABLE IF NOT EXISTS b2b_scopes ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE UNIQUE INDEX IF NOT EXISTS uk_scopes_name ON b2b_scopes (name); -- translations CREATE TABLE IF NOT EXISTS b2b_translations ( - lang_id INT NOT NULL, - scope_id INT NOT NULL, - component_id INT NOT NULL, + lang_id INT UNSIGNED NOT NULL, + scope_id INT UNSIGNED NOT NULL, + component_id INT UNSIGNED NOT NULL, `key` VARCHAR(255) NOT NULL, data TEXT NULL, PRIMARY KEY (lang_id, scope_id, component_id, `key`), @@ -71,8 +64,8 @@ CREATE TABLE IF NOT EXISTS b2b_customers ( password_reset_expires DATETIME(6) NULL, last_password_reset_request DATETIME(6) NULL, last_login_at DATETIME(6) NULL, - lang_id BIGINT NULL DEFAULT 2, - country_id BIGINT NULL DEFAULT 2, + lang_id INT NULL DEFAULT 2, + country_id INT NULL DEFAULT 2, created_at DATETIME(6) NULL, updated_at DATETIME(6) NULL, deleted_at DATETIME(6) NULL @@ -87,7 +80,7 @@ ON b2b_customers (deleted_at); -- customer_carts CREATE TABLE IF NOT EXISTS b2b_customer_carts ( - cart_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + cart_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, name VARCHAR(255) NULL, CONSTRAINT fk_customer_carts_customers FOREIGN KEY (user_id) REFERENCES b2b_customers(id) ON DELETE CASCADE ON UPDATE CASCADE @@ -97,10 +90,10 @@ CREATE INDEX IF NOT EXISTS idx_customer_carts_user_id ON b2b_customer_carts (use -- carts_products CREATE TABLE IF NOT EXISTS b2b_carts_products ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - cart_id BIGINT UNSIGNED NOT NULL, + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + cart_id INT UNSIGNED NOT NULL, product_id INT UNSIGNED NOT NULL, - product_attribute_id BIGINT NULL, + product_attribute_id INT NULL, amount INT UNSIGNED NOT NULL, CONSTRAINT fk_carts_products_customer_carts FOREIGN KEY (cart_id) REFERENCES b2b_customer_carts (cart_id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_carts_products_product FOREIGN KEY (product_id) REFERENCES ps_product (id_product) ON DELETE CASCADE ON UPDATE CASCADE @@ -110,7 +103,7 @@ CREATE INDEX IF NOT EXISTS idx_carts_products_cart_id ON b2b_carts_products (car -- refresh_tokens CREATE TABLE IF NOT EXISTS b2b_refresh_tokens ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT UNSIGNED NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME(6) NOT NULL, @@ -120,24 +113,146 @@ CREATE TABLE IF NOT EXISTS b2b_refresh_tokens ( CREATE UNIQUE INDEX IF NOT EXISTS uk_refresh_tokens_token_hash ON b2b_refresh_tokens (token_hash); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_customer_id ON b2b_refresh_tokens (customer_id); +CREATE TABLE `b2b_currencies` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, + `ps_id_currency` INT UNSIGNED NOT NULL, + `is_default` TINYINT NOT NULL, + `is_active` TINYINT NOT NULL, + PRIMARY KEY (`id`) +) +ENGINE = InnoDB; + +ALTER TABLE `b2b_currencies` ADD CONSTRAINT `FK_b2b_currencies_ps_id_currency` FOREIGN KEY (`ps_id_currency`) REFERENCES `ps_currency` (`id_currency`) ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX `fk_b2b_currencies_ps_currency` +ON `b2b_currencies` ( + `ps_id_currency` ASC +); + +CREATE TABLE `b2b_currency_rates` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, + `b2b_id_currency` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL, + `conversion_rate` DECIMAL(13,6) NULL DEFAULT NULL , + PRIMARY KEY (`id`) +) +ENGINE = InnoDB; +ALTER TABLE `b2b_currency_rates` ADD CONSTRAINT `FK_b2b_currency_rates_b2b_id_currency` FOREIGN KEY (`b2b_id_currency`) REFERENCES `b2b_currencies` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX `fk_b2b_currency_rates_b2b_currencies` +ON `b2b_currency_rates` ( + `b2b_id_currency` ASC +); -- countries CREATE TABLE IF NOT EXISTS b2b_countries ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(128) NOT NULL, - currency_id INT UNSIGNED NOT NULL, - flag VARCHAR(16) NOT NULL, - CONSTRAINT fk_countries_currency FOREIGN KEY (currency_id) REFERENCES ps_currency(id_currency) ON DELETE RESTRICT ON UPDATE RESTRICT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, + `flag` VARCHAR(16) NOT NULL, + `ps_id_country` INT UNSIGNED NOT NULL, + `b2b_id_currency` BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_b2b_countries_ps_country` FOREIGN KEY (`ps_id_country`) REFERENCES `ps_country` (`id_country`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `FK_b2b_countries_b2b_id_currency` FOREIGN KEY (`b2b_id_currency`) REFERENCES `b2b_currencies` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION +) +ENGINE = InnoDB; +CREATE INDEX `fk_b2b_countries_ps_country` +ON `b2b_countries` ( + `ps_id_country` ASC +); -INSERT IGNORE INTO b2b_countries - (id, name, currency_id, flag) -VALUES - (1, 'Polska', 1, '🇵🇱'), - (2, 'England', 2, '🇬🇧'), - (3, 'Čeština', 2, '🇨🇿'), - (4, 'Deutschland', 2, '🇩🇪'); +CREATE TABLE b2b_specific_price ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL, + scope ENUM('shop', 'category', 'product') NOT NULL, + valid_from DATETIME NULL, + valid_till DATETIME NULL, + has_expiration_date BOOLEAN DEFAULT FALSE, + reduction_type ENUM('amount', 'percentage') NOT NULL, + price DECIMAL(10, 2) NULL, + b2b_id_currency BIGINT UNSIGNED NULL, -- specifies which currency is used for the price + percentage_reduction DECIMAL(5, 2) NULL, + from_quantity INT UNSIGNED DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + CONSTRAINT fk_b2b_specific_price_country FOREIGN KEY (b2b_id_country) REFERENCES b2b_countries(id) ON DELETE + SET + NULL ON UPDATE CASCADE, + CONSTRAINT fk_b2b_specific_price_customer FOREIGN KEY (b2b_id_customer) REFERENCES b2b_customers(id) ON DELETE + SET + NULL ON UPDATE CASCADE, + CONSTRAINT fk_b2b_specific_price_currency FOREIGN KEY (b2b_id_currency) REFERENCES b2b_currencies(id) ON DELETE + SET + NULL ON UPDATE CASCADE +) ENGINE = InnoDB; +CREATE INDEX idx_b2b_scope ON b2b_specific_price(scope); +CREATE INDEX idx_b2b_customer ON b2b_specific_price(b2b_id_customer); +CREATE INDEX idx_b2b_country ON b2b_specific_price(b2b_id_country); +CREATE INDEX idx_b2b_active_dates ON b2b_specific_price(is_active, valid_from, valid_till); +CREATE INDEX idx_b2b_lookup +ON b2b_specific_price ( + scope, + is_active, + b2b_id_customer, + b2b_id_country, + from_quantity +); +CREATE TABLE b2b_specific_price_product ( + b2b_specific_price_id BIGINT UNSIGNED, + id_product INT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, id_product), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (id_product) REFERENCES ps_product(id_product) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_category ( + b2b_specific_price_id BIGINT UNSIGNED, + id_category INT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, id_category), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (id_category) REFERENCES ps_category(id_category) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_product_attribute ( + b2b_specific_price_id BIGINT UNSIGNED, + id_product_attribute INT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, id_product_attribute), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (id_product_attribute) REFERENCES ps_product_attribute(id_product_attribute) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_customer ( + b2b_specific_price_id BIGINT UNSIGNED, + b2b_id_customer BIGINT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, b2b_id_customer), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (b2b_id_customer) REFERENCES b2b_customers(id) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_country ( + b2b_specific_price_id BIGINT UNSIGNED, + b2b_id_country BIGINT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, b2b_id_country), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (b2b_id_country) REFERENCES b2b_countries(id) ON DELETE CASCADE +); + +CREATE INDEX idx_b2b_product_rel +ON b2b_specific_price_product (id_product); + +CREATE INDEX idx_b2b_category_rel +ON b2b_specific_price_category (id_category); + +CREATE INDEX idx_b2b_product_attribute_rel +ON b2b_specific_price_product_attribute (id_product_attribute); + +CREATE INDEX idx_bsp_customer +ON b2b_specific_price_customer (b2b_specific_price_id, b2b_id_customer); + +CREATE INDEX idx_bsp_country +ON b2b_specific_price_country (b2b_specific_price_id, b2b_id_country); -- +goose Down DROP TABLE IF EXISTS b2b_countries; @@ -147,3 +262,9 @@ DROP TABLE IF EXISTS b2b_scopes; DROP TABLE IF EXISTS b2b_translations; DROP TABLE IF EXISTS b2b_customers; DROP TABLE IF EXISTS b2b_refresh_tokens; +DROP TABLE IF EXISTS b2b_currencies; +DROP TABLE IF EXISTS b2b_currency_rates; +DROP TABLE IF EXISTS b2b_specific_price; +DROP TABLE IF EXISTS b2b_specific_price_product; +DROP TABLE IF EXISTS b2b_specific_price_category; +DROP TABLE IF EXISTS b2b_specific_price_product_attribute; diff --git a/i18n/migrations/20260302163123_create_tables_data.sql b/i18n/migrations/20260302163123_create_tables_data.sql new file mode 100644 index 0000000..7bae811 --- /dev/null +++ b/i18n/migrations/20260302163123_create_tables_data.sql @@ -0,0 +1,32 @@ +-- +goose Up + +INSERT IGNORE INTO b2b_language + (id, created_at, updated_at, deleted_at, name, iso_code, lang_code, date_format, date_format_short, rtl, is_default, active, flag) +VALUES + (1, '2022-09-16 17:10:02.837', '2026-03-02 21:24:36.779730', NULL, 'Polski', 'pl', 'pl', '__-__-____', '__-__', 0, 0, 1, '🇵🇱'), + (2, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'English', 'en', 'en', '__-__-____', '__-__', 0, 1, 1, '🇬🇧'), + (3, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'Deutsch', 'de', 'de', '__-__-____', '__-__', 0, 0, 1, '🇩🇪'); + + +-- insert sample admin user admin@ma-al.com/Maal12345678 + +INSERT IGNORE INTO b2b_customers (id, email, password, first_name, last_name, role, provider, provider_id, avatar_url, is_active, email_verified, email_verification_token, email_verification_expires, password_reset_token, password_reset_expires, last_password_reset_request, last_login_at, lang_id, country_id, created_at, updated_at, deleted_at) +VALUES + (1, 'admin@ma-al.com', '$2a$10$Owy9DjrS0l3Fz4XoOvh5pulgmOMqdwXmb7hYE9BovnSuWS2plGr82', 'Super', 'Admin', 'admin', 'local', '', '', 1, 1, NULL, NULL, '', NULL, NULL, NULL, 1, 1, '2026-03-02 16:55:10.252740', '2026-03-02 16:55:10.252740', NULL); +ALTER TABLE b2b_customers AUTO_INCREMENT = 1; + +INSERT INTO `b2b_currencies` (`ps_id_currency`, `is_default`, `is_active`) VALUES +('1','1','1'), +('2','0','1'); + +INSERT IGNORE INTO b2b_countries + (id, flag, ps_id_country, b2b_id_currency) +VALUES + (1, '🇵🇱', 14, 1), + (2, '🇬🇧', 17, 2), + (3, '🇨🇿', 16, 2), + (4, '🇩🇪', 1, 2); + + + +-- +goose Down \ No newline at end of file diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql index b10e54d..8207466 100644 --- a/i18n/migrations/20260319163200_procedures.sql +++ b/i18n/migrations/20260319163200_procedures.sql @@ -1,223 +1,362 @@ -- +goose Up -DELIMITER // - -DROP PROCEDURE IF EXISTS get_full_product // - +DELIMITER // +DROP PROCEDURE IF EXISTS get_full_product +// CREATE PROCEDURE get_full_product( - IN p_id_product INT UNSIGNED, - IN p_id_shop INT UNSIGNED, - IN p_id_lang INT UNSIGNED, - IN p_id_customer INT UNSIGNED, - IN p_id_group INT UNSIGNED, - IN p_id_currency INT UNSIGNED, - IN p_id_country INT UNSIGNED, - IN p_quantity INT UNSIGNED -) -BEGIN - - DECLARE v_tax_rate DECIMAL(10,4) DEFAULT 0; - - SELECT COALESCE(t.rate, 0.0000) INTO v_tax_rate -FROM ps_tax_rule tr -INNER JOIN ps_tax t - ON t.id_tax = tr.id_tax -WHERE tr.id_tax_rules_group = ( - SELECT ps.id_tax_rules_group - FROM ps_product_shop ps - WHERE ps.id_product = p_id_product - AND ps.id_shop = p_id_shop - LIMIT 1 -) - AND tr.id_country = p_id_country -ORDER BY - tr.id_state DESC, - tr.zipcode_from != '' DESC, - tr.id_tax_rule DESC + IN p_id_product INT UNSIGNED, + IN p_id_shop INT UNSIGNED, + IN p_id_lang INT UNSIGNED, + IN p_id_customer INT UNSIGNED, + IN b2b_id_country INT UNSIGNED, + IN p_quantity INT UNSIGNED +) +BEGIN +DECLARE v_tax_rate DECIMAL(10, 4) DEFAULT 0; +DECLARE p_id_currency DECIMAL(10, 4) DEFAULT 0; +SELECT + COALESCE(t.rate, 0.0000) INTO v_tax_rate +FROM + ps_tax_rule tr + INNER JOIN ps_tax t ON t.id_tax = tr.id_tax + LEFT JOIN b2b_countries ON b2b_countries.id = b2b_id_country +WHERE + tr.id_tax_rules_group = ( + SELECT + ps.id_tax_rules_group + FROM + ps_product_shop ps + WHERE + ps.id_product = p_id_product + AND ps.id_shop = p_id_shop + LIMIT + 1 + ) + AND tr.id_country = b2b_countries.ps_id_country +ORDER BY + tr.id_state DESC, + tr.zipcode_from != '' DESC, + tr.id_tax_rule DESC +LIMIT + 1; + +SELECT + b2b_currencies.ps_id_currency INTO p_id_currency +FROM + b2b_currencies + LEFT JOIN b2b_countries ON b2b_countries.b2b_id_currency = b2b_currencies.id +WHERE + b2b_countries.id = b2b_id_country LIMIT 1; + +/* FINAL JSON */ +SELECT + JSON_OBJECT( + /* ================= PRODUCT ================= */ + 'id_product', + p.id_product, + 'reference', + p.reference, + 'name', + pl.name, + 'description', + pl.description, + 'short_description', + pl.description_short, + /* ================= PRICE ================= */ +'price', +JSON_OBJECT( + 'base', + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate), - /* FINAL JSON */ - SELECT JSON_OBJECT( - - /* ================= PRODUCT ================= */ - 'id_product', p.id_product, - 'reference', p.reference, - 'name', pl.name, - 'description', pl.description, - 'short_description', pl.description_short, - - /* ================= PRICE ================= */ - 'price', JSON_OBJECT( - 'base', COALESCE(ps.price, p.price), - - 'final_tax_excl', + 'final_tax_excl', + ( + CASE + WHEN bsp.id IS NOT NULL THEN + CASE + /* FIXED PRICE */ + WHEN bsp.reduction_type = 'amount' THEN ( - COALESCE(ps.price, p.price) - - IFNULL( - CASE - WHEN sp.reduction_type = 'amount' THEN sp.reduction - WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction - ELSE 0 - END, 0 - ) - ), - - 'final_tax_incl', - ( - ( - COALESCE(ps.price, p.price) - - IFNULL( - CASE - WHEN sp.reduction_type = 'amount' THEN sp.reduction - WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction - ELSE 0 - END, 0 - ) - ) * (1 + v_tax_rate / 100) + CASE + WHEN bsp.b2b_id_currency IS NULL THEN bsp.price + ELSE bsp.price * br_bsp.conversion_rate + END ) - ), - /* ================= META ================= */ - 'active', COALESCE(ps.active, p.active), - 'visibility', COALESCE(ps.visibility, p.visibility), - 'manufacturer', m.name, - 'category', cl.name, + /* PERCENTAGE */ + WHEN bsp.reduction_type = 'percentage' THEN + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + * (1 - bsp.percentage_reduction / 100) - /* ================= IMAGE ================= */ - 'cover_image', JSON_OBJECT( - 'id', i.id_image, - 'legend', il.legend - ), + ELSE + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + END - /* ================= FEATURES ================= */ - 'features', ( - SELECT JSON_ARRAYAGG( - JSON_OBJECT( - 'name', fl.name, - 'value', fvl.value - ) - ) - FROM ps_feature_product fp - JOIN ps_feature_lang fl - ON fl.id_feature = fp.id_feature AND fl.id_lang = p_id_lang - JOIN ps_feature_value_lang fvl - ON fvl.id_feature_value = fp.id_feature_value AND fvl.id_lang = p_id_lang - WHERE fp.id_product = p.id_product - ), + ELSE + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + END + ), - /* ================= COMBINATIONS ================= */ - 'combinations', ( - SELECT JSON_ARRAYAGG( - JSON_OBJECT( - 'id_product_attribute', pa.id_product_attribute, - 'reference', pa.reference, + 'final_tax_incl', + ( + ( + CASE + WHEN bsp.id IS NOT NULL THEN + CASE + WHEN bsp.reduction_type = 'amount' THEN + ( + CASE + WHEN bsp.b2b_id_currency IS NULL THEN bsp.price + ELSE bsp.price * br_bsp.conversion_rate + END + ) - 'price', JSON_OBJECT( - 'impact', COALESCE(pas.price, pa.price), + WHEN bsp.reduction_type = 'percentage' THEN + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + * (1 - bsp.percentage_reduction / 100) - 'final_tax_excl', - ( - COALESCE(ps.price, p.price) - + COALESCE(pas.price, pa.price) - ), - - 'final_tax_incl', - ( - ( - COALESCE(ps.price, p.price) - + COALESCE(pas.price, pa.price) - ) * (1 + v_tax_rate / 100) - ) - ), - - 'stock', IFNULL(sa.quantity, 0), - - 'default_on', pas.default_on, - - /* ATTRIBUTES JSON */ - 'attributes', ( - SELECT JSON_ARRAYAGG( - JSON_OBJECT( - 'group', agl.name, - 'attribute', al.name - ) - ) - FROM ps_product_attribute_combination pac - JOIN ps_attribute a ON a.id_attribute = pac.id_attribute - JOIN ps_attribute_lang al - ON al.id_attribute = a.id_attribute AND al.id_lang = p_id_lang - JOIN ps_attribute_group_lang agl - ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = p_id_lang - WHERE pac.id_product_attribute = pa.id_product_attribute - ), - - /* IMAGES */ - 'images', ( - SELECT JSON_ARRAYAGG(img.id_image) - FROM ps_product_attribute_image pai - JOIN ps_image img ON img.id_image = pai.id_image - WHERE pai.id_product_attribute = pa.id_product_attribute - ) - - ) - ) - FROM ps_product_attribute pa - JOIN ps_product_attribute_shop pas - ON pas.id_product_attribute = pa.id_product_attribute - AND pas.id_shop = p_id_shop - LEFT JOIN ps_stock_available sa - ON sa.id_product = pa.id_product - AND sa.id_product_attribute = pa.id_product_attribute - AND sa.id_shop = p_id_shop - WHERE pa.id_product = p.id_product + ELSE + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + END + ELSE + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + END + ) * (1 + v_tax_rate / 100) + ) +), + /* ================= META ================= */ + 'active', + COALESCE(ps.active, p.active), + 'visibility', + COALESCE(ps.visibility, p.visibility), + 'manufacturer', + m.name, + 'category', + cl.name, + /* ================= IMAGE ================= */ + 'cover_image', + JSON_OBJECT( + 'id', + i.id_image, + 'legend', + il.legend + ), + /* ================= FEATURES ================= */ + 'features', + ( + SELECT + JSON_ARRAYAGG( + JSON_OBJECT( + 'name', + fl.name, + 'value', + fvl.value + ) ) + FROM + ps_feature_product fp + JOIN ps_feature_lang fl ON fl.id_feature = fp.id_feature + AND fl.id_lang = p_id_lang + JOIN ps_feature_value_lang fvl ON fvl.id_feature_value = fp.id_feature_value + AND fvl.id_lang = p_id_lang + WHERE + fp.id_product = p.id_product + ), + /* ================= COMBINATIONS ================= */ + 'combinations', + ( + SELECT + JSON_ARRAYAGG( + JSON_OBJECT( + 'id_product_attribute', + pa.id_product_attribute, + 'reference', + pa.reference, + 'price', + JSON_OBJECT( + 'impact', + COALESCE(pas.price, pa.price), + 'final_tax_excl', + ( + COALESCE(ps.price, p.price) + COALESCE(pas.price, pa.price) + ), + 'final_tax_incl', + ( + ( + COALESCE(ps.price, p.price) + COALESCE(pas.price, pa.price) + ) * (1 + v_tax_rate / 100) + ) + ), + 'stock', + IFNULL(sa.quantity, 0), + 'default_on', + pas.default_on, + /* ATTRIBUTES JSON */ + 'attributes', + ( + SELECT + JSON_ARRAYAGG( + JSON_OBJECT( + 'group', + agl.name, + 'attribute', + al.name + ) + ) + FROM + ps_product_attribute_combination pac + JOIN ps_attribute a ON a.id_attribute = pac.id_attribute + JOIN ps_attribute_lang al ON al.id_attribute = a.id_attribute + AND al.id_lang = p_id_lang + JOIN ps_attribute_group_lang agl ON agl.id_attribute_group = a.id_attribute_group + AND agl.id_lang = p_id_lang + WHERE + pac.id_product_attribute = pa.id_product_attribute + ), + /* IMAGES */ + 'images', + ( + SELECT + JSON_ARRAYAGG(img.id_image) + FROM + ps_product_attribute_image pai + JOIN ps_image img ON img.id_image = pai.id_image + WHERE + pai.id_product_attribute = pa.id_product_attribute + ) + ) + ) + FROM + ps_product_attribute pa + JOIN ps_product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute + AND pas.id_shop = p_id_shop + LEFT JOIN ps_stock_available sa ON sa.id_product = pa.id_product + AND sa.id_product_attribute = pa.id_product_attribute + AND sa.id_shop = p_id_shop + WHERE + pa.id_product = p.id_product + ) + ) AS product_json +FROM + ps_product p + LEFT JOIN ps_product_shop ps ON ps.id_product = p.id_product + AND ps.id_shop = p_id_shop + LEFT JOIN ps_product_lang pl ON pl.id_product = p.id_product + AND pl.id_lang = p_id_lang + AND pl.id_shop = p_id_shop + LEFT JOIN ps_category_lang cl ON cl.id_category = COALESCE(ps.id_category_default, p.id_category_default) + AND cl.id_lang = p_id_lang + AND cl.id_shop = p_id_shop + LEFT JOIN ps_manufacturer m ON m.id_manufacturer = p.id_manufacturer + LEFT JOIN ps_image i ON i.id_product = p.id_product + AND i.cover = 1 + LEFT JOIN ps_image_lang il ON il.id_image = i.id_image + AND il.id_lang = p_id_lang + /* SPECIFIC PRICE */ +LEFT JOIN ( + SELECT bsp.* + FROM b2b_specific_price bsp - ) AS product_json + /* RELATIONS */ + LEFT JOIN b2b_specific_price_product bsp_p + ON bsp_p.b2b_specific_price_id = bsp.id - FROM ps_product p + LEFT JOIN b2b_specific_price_category bsp_c + ON bsp_c.b2b_specific_price_id = bsp.id - LEFT JOIN ps_product_shop ps - ON ps.id_product = p.id_product AND ps.id_shop = p_id_shop + WHERE bsp.is_active = TRUE - LEFT JOIN ps_product_lang pl - ON pl.id_product = p.id_product - AND pl.id_lang = p_id_lang - AND pl.id_shop = p_id_shop + /* SCOPE MATCH */ + AND ( + /* PRODUCT */ + (bsp.scope = 'product' AND bsp_p.id_product = p_id_product) - LEFT JOIN ps_category_lang cl - ON cl.id_category = COALESCE(ps.id_category_default, p.id_category_default) - AND cl.id_lang = p_id_lang - AND cl.id_shop = p_id_shop + /* CATEGORY */ + OR ( + bsp.scope = 'category' + AND bsp_c.id_category IN ( + SELECT cp.id_category + FROM ps_category_product cp + WHERE cp.id_product = p_id_product + ) + ) - LEFT JOIN ps_manufacturer m - ON m.id_manufacturer = p.id_manufacturer + /* SHOP (GLOBAL) */ + OR (bsp.scope = 'shop') + ) - LEFT JOIN ps_image i - ON i.id_product = p.id_product AND i.cover = 1 + /* CUSTOMER MATCH */ +AND ( + NOT EXISTS ( + SELECT 1 FROM b2b_specific_price_customer c + WHERE c.b2b_specific_price_id = bsp.id + ) + OR EXISTS ( + SELECT 1 FROM b2b_specific_price_customer c + WHERE c.b2b_specific_price_id = bsp.id + AND c.b2b_id_customer = p_id_customer + ) +) - LEFT JOIN ps_image_lang il - ON il.id_image = i.id_image AND il.id_lang = p_id_lang +/* COUNTRY MATCH */ +AND ( + NOT EXISTS ( + SELECT 1 FROM b2b_specific_price_country ctry + WHERE ctry.b2b_specific_price_id = bsp.id + ) + OR EXISTS ( + SELECT 1 FROM b2b_specific_price_country ctry + WHERE ctry.b2b_specific_price_id = bsp.id + AND ctry.b2b_id_country = b2b_id_country + ) +) - /* SPECIFIC PRICE */ - LEFT JOIN ( - SELECT sp1.* - FROM ps_specific_price sp1 - WHERE sp1.id_product = p_id_product - AND (sp1.id_customer = 0 OR sp1.id_customer = p_id_customer) - AND (sp1.id_group = 0 OR sp1.id_group = p_id_group) - AND (sp1.id_currency = 0 OR sp1.id_currency = p_id_currency) - AND sp1.from_quantity <= p_quantity - ORDER BY - sp1.id_customer DESC, - sp1.id_group DESC, - sp1.from_quantity DESC, - sp1.id_specific_price DESC - LIMIT 1 - ) sp ON sp.id_product = p.id_product + /* QUANTITY */ + AND bsp.from_quantity <= p_quantity - WHERE p.id_product = p_id_product + /* DATE */ + AND ( + bsp.has_expiration_date = FALSE OR ( + (bsp.valid_from IS NULL OR bsp.valid_from <= NOW()) + AND (bsp.valid_till IS NULL OR bsp.valid_till >= NOW()) + ) + ) - LIMIT 1; + ORDER BY + /* 🔥 STRICT PRIORITY */ + bsp.scope = 'product' DESC, + bsp.scope = 'category' DESC, + bsp.scope = 'shop' DESC, + bsp.b2b_id_customer DESC, + bsp.b2b_id_country DESC, + + bsp.from_quantity DESC, + bsp.id DESC + + LIMIT 1 +) bsp ON 1=1 +LEFT JOIN b2b_currency_rates br_bsp + ON br_bsp.b2b_id_currency = bsp.b2b_id_currency + AND br_bsp.created_at = ( + SELECT MAX(created_at) + FROM b2b_currency_rates + WHERE b2b_id_currency = bsp.b2b_id_currency + ) + LEFT JOIN b2b_countries ON b2b_countries.id = b2b_id_country + LEFT JOIN b2b_currencies ON b2b_currencies.id = p_id_currency + LEFT JOIN b2b_currency_rates r ON r.b2b_id_currency = b2b_currencies.id + AND r.created_at = ( + SELECT + MAX(created_at) + FROM + b2b_currency_rates + WHERE + b2b_id_currency = b2b_currencies.id + ) +WHERE + p.id_product = p_id_product +LIMIT + 1; END // DELIMITER ; diff --git a/i18n/migrations/20260320113729_stuff.sql b/i18n/migrations/20260320113729_stuff.sql new file mode 100644 index 0000000..b9c449e --- /dev/null +++ b/i18n/migrations/20260320113729_stuff.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +-- +goose StatementEnd diff --git a/repository/currencyRepo/currencyRepo.go b/repository/currencyRepo/currencyRepo.go new file mode 100644 index 0000000..97b1b5e --- /dev/null +++ b/repository/currencyRepo/currencyRepo.go @@ -0,0 +1,53 @@ +package currencyRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" +) + +type UICurrencyRepo interface { + CreateConversionRate(currencyRate *model.CurrencyRate) error + Get(id uint) (*model.Currency, error) +} + +type CurrencyRepo struct{} + +func New() UICurrencyRepo { + return &CurrencyRepo{} +} + +func (repo *CurrencyRepo) CreateConversionRate(currencyRate *model.CurrencyRate) error { + return db.DB.Debug().Create(currencyRate).Error +} + +func (repo *CurrencyRepo) Get(id uint) (*model.Currency, error) { + var currency model.Currency + + err := db.DB.Table("b2b_currencies c"). + Select("c.*, r.conversion_rate"). + Joins(` + LEFT JOIN b2b_currency_rates r + ON r.b2b_id_currency = c.id + AND r.created_at = ( + SELECT MAX(created_at) + FROM b2b_currency_rates + WHERE b2b_id_currency = c.id + ) + `). + Where("c.id = ?", id). + Scan(¤cy).Error + + return ¤cy, err +} + +func (repo *CurrencyRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Currency], error) { + + found, err := find.Paginate[model.Currency](langId, p, db.DB. + Model(&model.Currency{}). + Scopes(filt.All()...), + ) + + return &found, err +} diff --git a/taskfiles/db.yml b/taskfiles/db.yml index b7a349b..21ac231 100644 --- a/taskfiles/db.yml +++ b/taskfiles/db.yml @@ -59,6 +59,7 @@ tasks: - | sed '/-- +goose Down/,$d' i18n/migrations/20260302163100_routes.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} sed '/-- +goose Down/,$d' i18n/migrations/20260302163122_create_tables.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} + sed '/-- +goose Down/,$d' i18n/migrations/20260302163123_create_tables_data.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} sed '/-- +goose Down/,$d' i18n/migrations/20260302163152_translations_backoffice.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} sed '/-- +goose Down/,$d' i18n/migrations/20260302163157_translations_backend.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} From 0ed9d792b64ce88903b62b7f7d411634624a57e5 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Thu, 2 Apr 2026 15:06:00 +0200 Subject: [PATCH 04/11] feat: roles, permissions --- app/delivery/middleware/auth.go | 4 +- app/delivery/middleware/permissions.go | 28 +++++ app/delivery/middleware/perms/permissions.go | 11 ++ app/delivery/web/api/public/auth.go | 2 +- app/delivery/web/api/restricted/customer.go | 70 ++++++++++++ app/delivery/web/api/restricted/menu.go | 5 +- app/delivery/web/api/restricted/product.go | 13 ++- app/delivery/web/init.go | 6 +- app/model/customer.go | 72 ++++++------ app/model/permission.go | 12 ++ app/model/role.go | 19 ++++ app/repos/currencyRepo/currencyRepo.go | 2 +- app/repos/customerRepo/customerRepo.go | 27 +++++ app/repos/routesRepo/routesRepo.go | 14 ++- app/service/authService/auth.go | 22 ++-- app/service/authService/google_oauth.go | 2 +- .../customerService/customerService.go | 20 ++++ app/service/menuService/menuService.go | 4 +- app/utils/const_data/consts.go | 1 + app/utils/responseErrors/responseErrors.go | 5 + .../20260302163122_create_tables.sql | 105 ++++++++++++++++-- i18n/migrations/20260319163200_procedures.sql | 27 ++++- 22 files changed, 391 insertions(+), 80 deletions(-) create mode 100644 app/delivery/middleware/permissions.go create mode 100644 app/delivery/middleware/perms/permissions.go create mode 100644 app/delivery/web/api/restricted/customer.go create mode 100644 app/model/permission.go create mode 100644 app/model/role.go create mode 100644 app/repos/customerRepo/customerRepo.go create mode 100644 app/service/customerService/customerService.go diff --git a/app/delivery/middleware/auth.go b/app/delivery/middleware/auth.go index 2aefce0..30651f8 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -63,7 +63,7 @@ func AuthMiddleware() fiber.Handler { // Set user in context c.Locals(constdata.USER_LOCALES_NAME, user.ToSession()) c.Locals(constdata.USER_LOCALES_ID, user.ID) - + c.Locals(constdata.LANG_LOCALES_ID, user.LangID) return c.Next() } } @@ -85,7 +85,7 @@ func RequireAdmin() fiber.Handler { }) } - if userSession.Role != model.RoleAdmin { + if model.CustomerRole(userSession.RoleName) != model.RoleAdmin { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "admin access required", }) diff --git a/app/delivery/middleware/permissions.go b/app/delivery/middleware/permissions.go new file mode 100644 index 0000000..96ab057 --- /dev/null +++ b/app/delivery/middleware/permissions.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" + "git.ma-al.com/goc_daniel/b2b/app/model" + "github.com/gofiber/fiber/v3" +) + +func Require(p perms.Permission) fiber.Handler { + return func(c fiber.Ctx) error { + u := c.Locals("user") + if u == nil { + return c.SendStatus(fiber.StatusUnauthorized) + } + + user, ok := u.(*model.UserSession) + if !ok { + return c.SendStatus(fiber.StatusInternalServerError) + } + + for _, perm := range user.Permissions { + if perm == p { + return c.Next() + } + } + return c.SendStatus(fiber.StatusForbidden) + } +} diff --git a/app/delivery/middleware/perms/permissions.go b/app/delivery/middleware/perms/permissions.go new file mode 100644 index 0000000..d4b9f07 --- /dev/null +++ b/app/delivery/middleware/perms/permissions.go @@ -0,0 +1,11 @@ +package perms + +type Permission string + +const ( + UserRead Permission = "user.read" + UserWrite Permission = "user.write" + UserReadAny Permission = "user.read.any" + UserWriteAny Permission = "user.write.any" + UserDeleteAny Permission = "user.delete.any" +) diff --git a/app/delivery/web/api/public/auth.go b/app/delivery/web/api/public/auth.go index 852a4ac..edf67f3 100644 --- a/app/delivery/web/api/public/auth.go +++ b/app/delivery/web/api/public/auth.go @@ -360,7 +360,7 @@ func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error { user := model.Customer{ ID: userLocals.UserID, Email: userLocals.Email, - Role: userLocals.Role, + Role: model.Role{ID: userLocals.RoleID, Name: userLocals.RoleName}, LangID: userLocals.LangID, CountryID: userLocals.CountryID, IsActive: userLocals.IsActive, diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go new file mode 100644 index 0000000..a15695d --- /dev/null +++ b/app/delivery/web/api/restricted/customer.go @@ -0,0 +1,70 @@ +package restricted + +import ( + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/service/customerService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type customerHandler struct { + service *customerService.CustomerService +} + +func NewCustomerHandler() *customerHandler { + customerService := customerService.New() + return &customerHandler{ + service: customerService, + } +} + +func CustomerHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewCustomerHandler() + + r.Get("", handler.customerData) + return r +} + +func (h *customerHandler) customerData(fc fiber.Ctx) error { + var customerId uint + customerIdStr := fc.Query("id") + if customerIdStr != "" { + user, ok := fc.Locals("user").(*model.UserSession) + if !ok { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + id, err := strconv.ParseUint(customerIdStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if user.UserID != uint(id) && !user.HasPermission(perms.UserReadAny) { + return fc.Status(fiber.StatusForbidden). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) + } + + customerId = uint(id) + } else { + id, ok := fc.Locals("userID").(uint) + if !ok { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + customerId = id + } + + customer, err := h.service.GetById(customerId) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } + + return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) +} diff --git a/app/delivery/web/api/restricted/menu.go b/app/delivery/web/api/restricted/menu.go index ee7e615..960c901 100644 --- a/app/delivery/web/api/restricted/menu.go +++ b/app/delivery/web/api/restricted/menu.go @@ -1,6 +1,7 @@ package restricted import ( + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/menuService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" @@ -45,12 +46,12 @@ func (h *MenuHandler) GetMenu(c fiber.Ctx) error { } func (h *MenuHandler) GetTopMenu(c fiber.Ctx) error { - lang_id, ok := c.Locals("langID").(uint) + session, ok := c.Locals("user").(*model.UserSession) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - menu, err := h.menuService.GetTopMenu(lang_id) + menu, err := h.menuService.GetTopMenu(session.LangID, session.RoleID) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index d9c40a3..8670c1a 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -1,10 +1,12 @@ package restricted import ( + "fmt" "strconv" "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/service/productService" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" "git.ma-al.com/goc_daniel/b2b/app/utils/response" @@ -30,7 +32,7 @@ func ProductsHandlerRoutes(r fiber.Router) fiber.Router { handler := NewProductsHandler() //TODO: WIP doesn't work yet - r.Get("/product/:id/:country_id/:quantity", handler.GetProductJson) + r.Get("/:id/:country_id/:quantity", handler.GetProductJson) return r } @@ -60,19 +62,18 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - id_lang, ok := c.Locals("lang_id").(int) + p_id_customer, ok := c.Locals(constdata.USER_LOCALES_ID).(uint) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - - p_id_customer, ok := c.Locals("user_id").(int) + fmt.Printf("p_id_customer: %v\n", p_id_customer) + id_lang, ok := c.Locals(constdata.LANG_LOCALES_ID).(uint) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - - productJson, err := h.productService.GetJSON(p_id_product, id_lang, p_id_customer, b2b_id_country, p_quantity) + productJson, err := h.productService.GetJSON(p_id_product, int(id_lang), int(p_id_customer), b2b_id_country, p_quantity) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index d7ca9a8..be7730b 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -90,6 +90,9 @@ func (s *Server) Setup() error { menuRouting := s.public.Group("/menu") public.RoutingHandlerRoutes(menuRouting) + pCustomer := s.restricted.Group("/customer") + restricted.CustomerHandlerRoutes(pCustomer) + // product translation routes (restricted) productTranslation := s.restricted.Group("/product-translation") restricted.ProductTranslationHandlerRoutes(productTranslation) @@ -98,7 +101,8 @@ func (s *Server) Setup() error { list := s.restricted.Group("/list") restricted.ListHandlerRoutes(list) - restricted.ProductsHandlerRoutes(s.restricted) + product := s.restricted.Group("/product") + restricted.ProductsHandlerRoutes(product) // locale selector (restricted) // this is basically for changing user's selected language and country diff --git a/app/model/customer.go b/app/model/customer.go index ec7b63d..f7db443 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -3,6 +3,7 @@ package model import ( "time" + "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" "gorm.io/gorm" ) @@ -13,7 +14,8 @@ type Customer struct { Password string `gorm:"size:255" json:"-"` // Hashed password, not exposed in JSON FirstName string `gorm:"size:100" json:"first_name"` LastName string `gorm:"size:100" json:"last_name"` - Role CustomerRole `gorm:"type:varchar(20);default:'user'" json:"role"` + RoleID uint `gorm:"column:role_id;not null;default:1" json:"-"` + Role Role `gorm:"foreignKey:RoleID" json:"role"` Provider AuthProvider `gorm:"type:varchar(20);default:'local'" json:"provider"` ProviderID string `gorm:"size:255" json:"provider_id,omitempty"` // ID from OAuth provider AvatarURL string `gorm:"size:500" json:"avatar_url,omitempty"` @@ -32,14 +34,6 @@ type Customer struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } -// CustomerRole represents the role of a user -type CustomerRole string - -const ( - RoleUser CustomerRole = "user" - RoleAdmin CustomerRole = "admin" -) - // AuthProvider represents the authentication provider type AuthProvider string @@ -53,16 +47,6 @@ func (Customer) TableName() string { return "b2b_customers" } -// IsAdmin checks if the user has admin role -func (u *Customer) IsAdmin() bool { - return u.Role == RoleAdmin -} - -// CanManageUsers checks if the user can manage other users -func (u *Customer) CanManageUsers() bool { - return u.Role == RoleAdmin -} - // FullName returns the user's full name func (u *Customer) FullName() string { if u.FirstName == "" && u.LastName == "" { @@ -73,27 +57,51 @@ func (u *Customer) FullName() string { // UserSession represents a user session for JWT claims type UserSession struct { - UserID uint `json:"user_id"` - Email string `json:"email"` - Username string `json:"username"` - Role CustomerRole `json:"role"` - LangID uint `json:"lang_id"` - CountryID uint `json:"country_id"` - IsActive bool `json:"is_active"` + UserID uint `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + RoleID uint `json:"role_id"` + RoleName string `json:"role_name"` + LangID uint `json:"lang_id"` + CountryID uint `json:"country_id"` + IsActive bool `json:"is_active"` + Permissions []perms.Permission `json:"permissions"` +} + +func (us *UserSession) HasPermission(permission perms.Permission) bool { + for _, p := range us.Permissions { + if p == permission { + return true + } + } + return false } // ToSession converts User to UserSession func (u *Customer) ToSession() *UserSession { + return &UserSession{ - UserID: u.ID, - Email: u.Email, - Role: u.Role, - LangID: u.LangID, - CountryID: u.CountryID, - IsActive: u.IsActive, + UserID: u.ID, + Email: u.Email, + RoleID: u.Role.ID, + RoleName: u.Role.Name, + Permissions: BuildPermissionSlice(u), + LangID: u.LangID, + CountryID: u.CountryID, + IsActive: u.IsActive, } } +func BuildPermissionSlice(user *Customer) []perms.Permission { + var perms []perms.Permission + + for _, p := range user.Role.Permissions { + perms = append(perms, p.Name) + } + + return perms +} + // LoginRequest represents the login form data type LoginRequest struct { Email string `json:"email" form:"email"` diff --git a/app/model/permission.go b/app/model/permission.go new file mode 100644 index 0000000..4b21efe --- /dev/null +++ b/app/model/permission.go @@ -0,0 +1,12 @@ +package model + +import "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" + +type Permission struct { + ID uint + Name perms.Permission +} + +func (Permission) TableName() string { + return "b2b_permissions" +} diff --git a/app/model/role.go b/app/model/role.go new file mode 100644 index 0000000..3c663b5 --- /dev/null +++ b/app/model/role.go @@ -0,0 +1,19 @@ +package model + +type Role struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:64" json:"name"` + Permissions []Permission `gorm:"many2many:b2b_role_permissions;" json:"-"` +} + +func (Role) TableName() string { + return "b2b_roles" +} + +type CustomerRole string + +const ( + RoleUser CustomerRole = "user" + RoleAdmin CustomerRole = "admin" + RoleSuperAdmin CustomerRole = "super_admin" +) diff --git a/app/repos/currencyRepo/currencyRepo.go b/app/repos/currencyRepo/currencyRepo.go index 97b1b5e..9cc153c 100644 --- a/app/repos/currencyRepo/currencyRepo.go +++ b/app/repos/currencyRepo/currencyRepo.go @@ -19,7 +19,7 @@ func New() UICurrencyRepo { } func (repo *CurrencyRepo) CreateConversionRate(currencyRate *model.CurrencyRate) error { - return db.DB.Debug().Create(currencyRate).Error + return db.DB.Create(currencyRate).Error } func (repo *CurrencyRepo) Get(id uint) (*model.Currency, error) { diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go new file mode 100644 index 0000000..058d5fd --- /dev/null +++ b/app/repos/customerRepo/customerRepo.go @@ -0,0 +1,27 @@ +package customerRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UICustomerRepo interface { + Get(id uint) (*model.Customer, error) +} + +type CustomerRepo struct{} + +func New() UICustomerRepo { + return &CustomerRepo{} +} + +func (repo *CustomerRepo) Get(id uint) (*model.Customer, error) { + var customer model.Customer + + err := db.DB. + Preload("Role"). + First(&customer, id). + Error + + return &customer, err +} diff --git a/app/repos/routesRepo/routesRepo.go b/app/repos/routesRepo/routesRepo.go index d12d488..09e5754 100644 --- a/app/repos/routesRepo/routesRepo.go +++ b/app/repos/routesRepo/routesRepo.go @@ -8,7 +8,7 @@ import ( type UIRoutesRepo interface { GetRoutes(langId uint) ([]model.Route, error) - GetTopMenu(id uint) ([]model.B2BTopMenu, error) + GetTopMenu(id uint, roleId uint) ([]model.B2BTopMenu, error) } type RoutesRepo struct{} @@ -26,12 +26,16 @@ func (p *RoutesRepo) GetRoutes(langId uint) ([]model.Route, error) { return routes, nil } -func (p *RoutesRepo) GetTopMenu(id uint) ([]model.B2BTopMenu, error) { +func (p *RoutesRepo) GetTopMenu(langId uint, roleId uint) ([]model.B2BTopMenu, error) { var menus []model.B2BTopMenu - err := db.Get(). - Where("active = ?", 1). - Order("parent_id ASC, position ASC"). + err := db. + Get(). + Model(model.B2BTopMenu{}). + Joins("JOIN b2b_top_menu_roles tmr ON tmr.top_menu_id = b2b_top_menu.menu_id"). + Where(model.B2BTopMenu{Active: 1}). + Where("tmr.role_id = ?", roleId). + Order("b2b_top_menu.parent_id ASC, b2b_top_menu.position ASC"). Find(&menus).Error return menus, err diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index 2fc4a7d..6effc43 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -23,13 +23,13 @@ import ( // JWTClaims represents the JWT claims type JWTClaims struct { - UserID uint `json:"user_id"` - Email string `json:"email"` - Username string `json:"username"` - Role model.CustomerRole `json:"customer_role"` - CartsIDs []uint `json:"carts_ids"` - LangID uint `json:"lang_id"` - CountryID uint `json:"country_id"` + UserID uint `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + Role string `json:"customer_role"` + CartsIDs []uint `json:"carts_ids"` + LangID uint `json:"lang_id"` + CountryID uint `json:"country_id"` jwt.RegisteredClaims } @@ -59,7 +59,7 @@ func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, strin var user model.Customer // Find user by email - if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil { + if err := s.db.Preload("Role.Permissions").Where("email = ?", req.Email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, "", responseErrors.ErrInvalidCredentials } @@ -144,7 +144,7 @@ func (s *AuthService) Register(req *model.RegisterRequest) error { Password: string(hashedPassword), FirstName: req.FirstName, LastName: req.LastName, - Role: model.RoleUser, + Role: model.Role{}, Provider: model.ProviderLocal, IsActive: false, EmailVerified: false, @@ -422,7 +422,7 @@ func (s *AuthService) RevokeAllRefreshTokens(userID uint) { // GetUserByID retrieves a user by ID func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) { var user model.Customer - if err := s.db.First(&user, userID).Error; err != nil { + if err := s.db.Preload("Role.Permissions").First(&user, userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, responseErrors.ErrUserNotFound } @@ -489,7 +489,7 @@ func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) UserID: user.ID, Email: user.Email, Username: user.Email, - Role: user.Role, + Role: user.Role.Name, CartsIDs: []uint{}, LangID: user.LangID, CountryID: user.CountryID, diff --git a/app/service/authService/google_oauth.go b/app/service/authService/google_oauth.go index a4c2cd1..c26da16 100644 --- a/app/service/authService/google_oauth.go +++ b/app/service/authService/google_oauth.go @@ -150,7 +150,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. Provider: model.ProviderGoogle, ProviderID: info.ID, AvatarURL: info.Picture, - Role: model.RoleUser, + Role: model.Role{}, IsActive: true, EmailVerified: true, LangID: 2, // default is english diff --git a/app/service/customerService/customerService.go b/app/service/customerService/customerService.go new file mode 100644 index 0000000..7af553c --- /dev/null +++ b/app/service/customerService/customerService.go @@ -0,0 +1,20 @@ +package customerService + +import ( + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/customerRepo" +) + +type CustomerService struct { + repo customerRepo.UICustomerRepo +} + +func New() *CustomerService { + return &CustomerService{ + repo: customerRepo.New(), + } +} + +func (s *CustomerService) GetById(id uint) (*model.Customer, error) { + return s.repo.Get(id) +} diff --git a/app/service/menuService/menuService.go b/app/service/menuService/menuService.go index e689ede..19efbe1 100644 --- a/app/service/menuService/menuService.go +++ b/app/service/menuService/menuService.go @@ -98,8 +98,8 @@ func (a ByPosition) Len() int { return len(a) } func (a ByPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByPosition) Less(i, j int) bool { return a[i].Position < a[j].Position } -func (s *MenuService) GetTopMenu(id uint) ([]*model.B2BTopMenu, error) { - items, err := s.routesRepo.GetTopMenu(id) +func (s *MenuService) GetTopMenu(languageId uint, roleId uint) ([]*model.B2BTopMenu, error) { + items, err := s.routesRepo.GetTopMenu(languageId, roleId) if err != nil { return nil, err } diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index 812364f..664e55b 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -8,3 +8,4 @@ const DEFAULT_NEW_CART_NAME = "new cart" const USER_LOCALES_NAME = "user" const USER_LOCALES_ID = "userID" +const LANG_LOCALES_ID = "langID" diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 2b8715d..8036d8b 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -9,6 +9,7 @@ import ( var ( // Typed errors for request validation and authentication + ErrForbidden = errors.New("forbidden") ErrInvalidBody = errors.New("invalid request body") ErrNotAuthenticated = errors.New("not authenticated") ErrUserNotFound = errors.New("user not found") @@ -83,6 +84,8 @@ func NewError(err error, status int) *Error { // GetErrorCode returns the error code string for HTTP response mapping func GetErrorCode(c fiber.Ctx, err error) string { switch { + case errors.Is(err, ErrForbidden): + return i18n.T_(c, "error.err_forbidden") case errors.Is(err, ErrInvalidBody): return i18n.T_(c, "error.err_invalid_body") case errors.Is(err, ErrInvalidCredentials): @@ -167,6 +170,8 @@ func GetErrorCode(c fiber.Ctx, err error) string { // GetErrorStatus returns the HTTP status code for the given error func GetErrorStatus(err error) int { switch { + case errors.Is(err, ErrForbidden): + return fiber.StatusForbidden case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrNotAuthenticated), errors.Is(err, ErrInvalidToken), diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index 75a75a0..71b1344 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -43,7 +43,42 @@ CREATE TABLE IF NOT EXISTS b2b_translations ( CONSTRAINT fk_translations_component FOREIGN KEY (component_id) REFERENCES b2b_components(id) ON DELETE RESTRICT ON UPDATE RESTRICT ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; +CREATE TABLE `b2b_roles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(63) NULL, + PRIMARY KEY (`id`) +); +CREATE UNIQUE INDEX `IX_b2b_roles_id` +ON `b2b_roles` ( + `id` ASC +); +CREATE TABLE b2b_permissions ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE b2b_role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + + PRIMARY KEY (role_id, permission_id), + + CONSTRAINT fk_role_permissions_role + FOREIGN KEY (role_id) + REFERENCES b2b_roles(id) + ON DELETE CASCADE, + + CONSTRAINT fk_role_permissions_permission + FOREIGN KEY (permission_id) + REFERENCES b2b_permissions(id) + ON DELETE CASCADE +); + +CREATE TABLE `b2b_top_menu_roles` ( + `top_menu_id` BIGINT UNSIGNED NOT NULL, + `role_id` BIGINT UNSIGNED NOT NULL +); -- customers CREATE TABLE IF NOT EXISTS b2b_customers ( @@ -52,7 +87,7 @@ CREATE TABLE IF NOT EXISTS b2b_customers ( password VARCHAR(255) NULL, first_name VARCHAR(100) NULL, last_name VARCHAR(100) NULL, - role VARCHAR(20) NULL DEFAULT 'user', + role_id BIGINT UNSIGNED NOT NULL DEFAULT 1, provider VARCHAR(20) NULL DEFAULT 'local', provider_id VARCHAR(255) NULL, avatar_url VARCHAR(500) NULL, @@ -77,6 +112,9 @@ ON b2b_customers (email); CREATE INDEX IF NOT EXISTS idx_customers_deleted_at ON b2b_customers (deleted_at); +ALTER TABLE b2b_customers +ADD CONSTRAINT fk_customer_role +FOREIGN KEY (role_id) REFERENCES b2b_roles(id); -- customer_carts CREATE TABLE IF NOT EXISTS b2b_customer_carts ( @@ -175,16 +213,7 @@ CREATE TABLE b2b_specific_price ( b2b_id_currency BIGINT UNSIGNED NULL, -- specifies which currency is used for the price percentage_reduction DECIMAL(5, 2) NULL, from_quantity INT UNSIGNED DEFAULT 1, - is_active BOOLEAN DEFAULT TRUE, - CONSTRAINT fk_b2b_specific_price_country FOREIGN KEY (b2b_id_country) REFERENCES b2b_countries(id) ON DELETE - SET - NULL ON UPDATE CASCADE, - CONSTRAINT fk_b2b_specific_price_customer FOREIGN KEY (b2b_id_customer) REFERENCES b2b_customers(id) ON DELETE - SET - NULL ON UPDATE CASCADE, - CONSTRAINT fk_b2b_specific_price_currency FOREIGN KEY (b2b_id_currency) REFERENCES b2b_currencies(id) ON DELETE - SET - NULL ON UPDATE CASCADE + is_active BOOLEAN DEFAULT TRUE ) ENGINE = InnoDB; CREATE INDEX idx_b2b_scope ON b2b_specific_price(scope); CREATE INDEX idx_b2b_customer ON b2b_specific_price(b2b_id_customer); @@ -344,6 +373,60 @@ END$$ DELIMITER ; +CREATE TABLE b2b_specific_price_product ( + b2b_specific_price_id BIGINT UNSIGNED, + id_product INT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, id_product), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (id_product) REFERENCES ps_product(id_product) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_category ( + b2b_specific_price_id BIGINT UNSIGNED, + id_category INT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, id_category), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (id_category) REFERENCES ps_category(id_category) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_product_attribute ( + b2b_specific_price_id BIGINT UNSIGNED, + id_product_attribute INT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, id_product_attribute), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (id_product_attribute) REFERENCES ps_product_attribute(id_product_attribute) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_customer ( + b2b_specific_price_id BIGINT UNSIGNED, + b2b_id_customer BIGINT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, b2b_id_customer), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (b2b_id_customer) REFERENCES b2b_customers(id) ON DELETE CASCADE +); + +CREATE TABLE b2b_specific_price_country ( + b2b_specific_price_id BIGINT UNSIGNED, + b2b_id_country BIGINT UNSIGNED, + PRIMARY KEY (b2b_specific_price_id, b2b_id_country), + FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, + FOREIGN KEY (b2b_id_country) REFERENCES b2b_countries(id) ON DELETE CASCADE +); + +CREATE INDEX idx_b2b_product_rel +ON b2b_specific_price_product (id_product); + +CREATE INDEX idx_b2b_category_rel +ON b2b_specific_price_category (id_category); + +CREATE INDEX idx_b2b_product_attribute_rel +ON b2b_specific_price_product_attribute (id_product_attribute); + +CREATE INDEX idx_bsp_customer +ON b2b_specific_price_customer (b2b_specific_price_id, b2b_id_customer); + +CREATE INDEX idx_bsp_country +ON b2b_specific_price_country (b2b_specific_price_id, b2b_id_country); -- +goose Down DROP TABLE IF EXISTS b2b_countries; diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql index 8207466..8f7d5ab 100644 --- a/i18n/migrations/20260319163200_procedures.sql +++ b/i18n/migrations/20260319163200_procedures.sql @@ -321,19 +321,36 @@ AND ( ) ) - ORDER BY - /* 🔥 STRICT PRIORITY */ + ORDER BY + /* 🔥 SCOPE PRIORITY */ bsp.scope = 'product' DESC, bsp.scope = 'category' DESC, bsp.scope = 'shop' DESC, - bsp.b2b_id_customer DESC, - bsp.b2b_id_country DESC, + /* 🔥 CUSTOMER PRIORITY */ + ( + EXISTS ( + SELECT 1 FROM b2b_specific_price_customer c + WHERE c.b2b_specific_price_id = bsp.id + AND c.b2b_id_customer = p_id_customer + ) + ) DESC, + + /* 🔥 COUNTRY PRIORITY */ + ( + EXISTS ( + SELECT 1 FROM b2b_specific_price_country ctry + WHERE ctry.b2b_specific_price_id = bsp.id + AND ctry.b2b_id_country = b2b_id_country + ) + ) DESC, + + /* GLOBAL fallback (no restrictions) naturally goes last */ bsp.from_quantity DESC, bsp.id DESC - LIMIT 1 + LIMIT 1 ) bsp ON 1=1 LEFT JOIN b2b_currency_rates br_bsp ON br_bsp.b2b_id_currency = bsp.b2b_id_currency From 701004d005451a319606643c5c5e83c22812e4a9 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Fri, 3 Apr 2026 09:22:58 +0200 Subject: [PATCH 05/11] chore: add bruno endpoints --- app/delivery/web/api/restricted/currency.go | 29 ------- app/delivery/web/api/restricted/product.go | 1 - app/repos/productsRepo/productsRepo.go | 26 ++++-- app/service/productService/productService.go | 4 +- bruno/api_v1/Change Locales.yml | 2 +- bruno/api_v1/Create Search Index.yml | 2 +- bruno/api_v1/Delete Index - MeiliSearch.yml | 2 +- bruno/api_v1/Search Index Settings.yml | 2 +- bruno/api_v1/Search Items.yml | 2 +- bruno/api_v1/auth/Login.yml | 29 +++++++ bruno/api_v1/auth/folder.yml | 7 ++ bruno/api_v1/currency/currency-rate.yml | 15 ++++ bruno/api_v1/currency/currency.yml | 20 +++++ bruno/api_v1/currency/folder.yml | 7 ++ bruno/api_v1/customer/Customer (me).yml | 19 +++++ bruno/api_v1/customer/Customer (other).yml | 19 +++++ bruno/api_v1/customer/folder.yml | 7 ++ bruno/api_v1/product/Get Product.yml | 15 ++++ bruno/api_v1/{ => product}/Products List.yml | 0 bruno/api_v1/product/folder.yml | 7 ++ .../20260302163122_create_tables.sql | 81 ++++--------------- .../20260302163123_create_tables_data.sql | 9 ++- 22 files changed, 194 insertions(+), 111 deletions(-) create mode 100644 bruno/api_v1/auth/Login.yml create mode 100644 bruno/api_v1/auth/folder.yml create mode 100644 bruno/api_v1/currency/currency-rate.yml create mode 100644 bruno/api_v1/currency/currency.yml create mode 100644 bruno/api_v1/currency/folder.yml create mode 100644 bruno/api_v1/customer/Customer (me).yml create mode 100644 bruno/api_v1/customer/Customer (other).yml create mode 100644 bruno/api_v1/customer/folder.yml create mode 100644 bruno/api_v1/product/Get Product.yml rename bruno/api_v1/{ => product}/Products List.yml (100%) create mode 100644 bruno/api_v1/product/folder.yml diff --git a/app/delivery/web/api/restricted/currency.go b/app/delivery/web/api/restricted/currency.go index 3ffa226..52dee21 100644 --- a/app/delivery/web/api/restricted/currency.go +++ b/app/delivery/web/api/restricted/currency.go @@ -32,7 +32,6 @@ func CurrencyHandlerRoutes(r fiber.Router) fiber.Router { r.Post("/currency-rate", handler.PostCurrencyRate) r.Get("/currency-rate/:id", handler.GetCurrencyRate) - // r.Get("/currencies", handler.GetCurrencyRates) return r } @@ -65,33 +64,5 @@ func (h *CurrencyHandler) GetCurrencyRate(c fiber.Ctx) error { return c.Status(responseErrors.GetErrorStatus(err)).JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - // err = h.CurrencyService.GetCurrencyRate(userID, uint(productID), uint(productShopID), uint(productLangID), updates) - // if err != nil { - // return c.Status(responseErrors.GetErrorStatus(err)). - // JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - // } - - return c.JSON(response.Make(currency, 0, i18n.T_(c, response.Message_OK))) -} - -func (h *CurrencyHandler) GetCurrencyRates(c fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.Atoi(idStr) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - - } - - currency, err := h.CurrencyService.GetCurrency(uint(id)) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - // err = h.CurrencyService.GetCurrencyRate(userID, uint(productID), uint(productShopID), uint(productLangID), updates) - // if err != nil { - // return c.Status(responseErrors.GetErrorStatus(err)). - // JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - // } - return c.JSON(response.Make(currency, 0, i18n.T_(c, response.Message_OK))) } diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index 8670c1a..eaade19 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -31,7 +31,6 @@ func NewProductsHandler() *ProductsHandler { func ProductsHandlerRoutes(r fiber.Router) fiber.Router { handler := NewProductsHandler() - //TODO: WIP doesn't work yet r.Get("/:id/:country_id/:quantity", handler.GetProductJson) return r diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index af68a63..7c6c08f 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -1,11 +1,14 @@ package productsRepo import ( + "encoding/json" + "fmt" + "git.ma-al.com/goc_daniel/b2b/app/db" ) type UIProductsRepo interface { - GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*string, error) + GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) } type ProductsRepo struct{} @@ -14,12 +17,23 @@ func New() UIProductsRepo { return &ProductsRepo{} } -func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*string, error) { - var product string +func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) { + var productStr string // ← Scan as string first - err := db.DB.Raw(`CALL get_full_product(?,?,?,?,?,?)`, p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity). - Scan(&product). + err := db.DB.Raw(`CALL get_full_product(?,?,?,?,?,?)`, + p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity). + Scan(&productStr). Error - return &product, err + if err != nil { + return nil, err + } + + // Optional: validate it's valid JSON + if !json.Valid([]byte(productStr)) { + return nil, fmt.Errorf("invalid json returned from stored procedure") + } + + raw := json.RawMessage(productStr) + return &raw, nil } diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index 364079d..66245f1 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -1,6 +1,8 @@ package productService import ( + "encoding/json" + "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" ) @@ -15,7 +17,7 @@ func New() *ProductService { } } -func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*string, error) { +func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) { products, err := s.productsRepo.GetJSON(p_id_product, constdata.SHOP_ID, p_id_lang, p_id_customer, b2b_id_country, p_quantity) if err != nil { return products, err diff --git a/bruno/api_v1/Change Locales.yml b/bruno/api_v1/Change Locales.yml index cff1b71..4e2388e 100644 --- a/bruno/api_v1/Change Locales.yml +++ b/bruno/api_v1/Change Locales.yml @@ -1,7 +1,7 @@ info: name: Change Locales type: http - seq: 4 + seq: 3 http: method: POST diff --git a/bruno/api_v1/Create Search Index.yml b/bruno/api_v1/Create Search Index.yml index 74040b6..a5dfd07 100644 --- a/bruno/api_v1/Create Search Index.yml +++ b/bruno/api_v1/Create Search Index.yml @@ -1,7 +1,7 @@ info: name: Create Search Index type: http - seq: 2 + seq: 1 http: method: GET diff --git a/bruno/api_v1/Delete Index - MeiliSearch.yml b/bruno/api_v1/Delete Index - MeiliSearch.yml index e5e011e..b18e531 100644 --- a/bruno/api_v1/Delete Index - MeiliSearch.yml +++ b/bruno/api_v1/Delete Index - MeiliSearch.yml @@ -1,7 +1,7 @@ info: name: Delete Index - MeiliSearch type: http - seq: 7 + seq: 5 http: method: DELETE diff --git a/bruno/api_v1/Search Index Settings.yml b/bruno/api_v1/Search Index Settings.yml index 8c3c4cb..b11cd07 100644 --- a/bruno/api_v1/Search Index Settings.yml +++ b/bruno/api_v1/Search Index Settings.yml @@ -1,7 +1,7 @@ info: name: Search Index Settings type: http - seq: 5 + seq: 4 http: method: POST diff --git a/bruno/api_v1/Search Items.yml b/bruno/api_v1/Search Items.yml index 112fb94..135daab 100644 --- a/bruno/api_v1/Search Items.yml +++ b/bruno/api_v1/Search Items.yml @@ -1,7 +1,7 @@ info: name: Search Items type: http - seq: 3 + seq: 2 http: method: POST diff --git a/bruno/api_v1/auth/Login.yml b/bruno/api_v1/auth/Login.yml new file mode 100644 index 0000000..d605774 --- /dev/null +++ b/bruno/api_v1/auth/Login.yml @@ -0,0 +1,29 @@ +info: + name: Login + type: http + seq: 1 + +http: + method: POST + url: "{{bas_url}}/public/auth/login" + body: + type: json + data: |- + { + "email":"{{email}}", + "password":"{{password}}" + } + auth: inherit + +runtime: + variables: + - name: email + value: admin@ma-al.com + - name: password + value: Maal12345678 + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/auth/folder.yml b/bruno/api_v1/auth/folder.yml new file mode 100644 index 0000000..4d04d32 --- /dev/null +++ b/bruno/api_v1/auth/folder.yml @@ -0,0 +1,7 @@ +info: + name: auth + type: folder + seq: 6 + +request: + auth: inherit diff --git a/bruno/api_v1/currency/currency-rate.yml b/bruno/api_v1/currency/currency-rate.yml new file mode 100644 index 0000000..d5e5bac --- /dev/null +++ b/bruno/api_v1/currency/currency-rate.yml @@ -0,0 +1,15 @@ +info: + name: currency-rate + type: http + seq: 2 + +http: + method: POST + url: "{{bas_url}}/restricted/currency-rate" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/currency/currency.yml b/bruno/api_v1/currency/currency.yml new file mode 100644 index 0000000..b3de3e9 --- /dev/null +++ b/bruno/api_v1/currency/currency.yml @@ -0,0 +1,20 @@ +info: + name: currency + type: http + seq: 1 + +http: + method: GET + url: "{{bas_url}}/restricted/currency-rate/{{id}}" + auth: inherit + +runtime: + variables: + - name: id + value: "1" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/currency/folder.yml b/bruno/api_v1/currency/folder.yml new file mode 100644 index 0000000..e409d83 --- /dev/null +++ b/bruno/api_v1/currency/folder.yml @@ -0,0 +1,7 @@ +info: + name: currency + type: folder + seq: 8 + +request: + auth: inherit diff --git a/bruno/api_v1/customer/Customer (me).yml b/bruno/api_v1/customer/Customer (me).yml new file mode 100644 index 0000000..253bead --- /dev/null +++ b/bruno/api_v1/customer/Customer (me).yml @@ -0,0 +1,19 @@ +info: + name: Customer (me) + type: http + seq: 2 + +http: + method: GET + url: "{{bas_url}}/restricted/customer?id=1" + params: + - name: id + value: "1" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/customer/Customer (other).yml b/bruno/api_v1/customer/Customer (other).yml new file mode 100644 index 0000000..161094d --- /dev/null +++ b/bruno/api_v1/customer/Customer (other).yml @@ -0,0 +1,19 @@ +info: + name: Customer (other) + type: http + seq: 9 + +http: + method: GET + url: "{{bas_url}}/restricted/customer?id=1" + params: + - name: id + value: "1" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/customer/folder.yml b/bruno/api_v1/customer/folder.yml new file mode 100644 index 0000000..cdd2d6f --- /dev/null +++ b/bruno/api_v1/customer/folder.yml @@ -0,0 +1,7 @@ +info: + name: customer + type: folder + seq: 9 + +request: + auth: inherit diff --git a/bruno/api_v1/product/Get Product.yml b/bruno/api_v1/product/Get Product.yml new file mode 100644 index 0000000..b9b182e --- /dev/null +++ b/bruno/api_v1/product/Get Product.yml @@ -0,0 +1,15 @@ +info: + name: Get Product + type: http + seq: 1 + +http: + method: GET + url: "{{bas_url}}/restricted/product/200/1/5" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/Products List.yml b/bruno/api_v1/product/Products List.yml similarity index 100% rename from bruno/api_v1/Products List.yml rename to bruno/api_v1/product/Products List.yml diff --git a/bruno/api_v1/product/folder.yml b/bruno/api_v1/product/folder.yml new file mode 100644 index 0000000..cd2ad8b --- /dev/null +++ b/bruno/api_v1/product/folder.yml @@ -0,0 +1,7 @@ +info: + name: product + type: folder + seq: 7 + +request: + auth: inherit diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index 71b1344..b88f14e 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -45,12 +45,7 @@ CREATE TABLE IF NOT EXISTS b2b_translations ( CREATE TABLE `b2b_roles` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - `name` VARCHAR(63) NULL, - PRIMARY KEY (`id`) -); -CREATE UNIQUE INDEX `IX_b2b_roles_id` -ON `b2b_roles` ( - `id` ASC + `name` VARCHAR(63) NULL ); CREATE TABLE b2b_permissions ( @@ -76,8 +71,20 @@ CREATE TABLE b2b_role_permissions ( ); CREATE TABLE `b2b_top_menu_roles` ( - `top_menu_id` BIGINT UNSIGNED NOT NULL, - `role_id` BIGINT UNSIGNED NOT NULL + `top_menu_id` INT NOT NULL, + `role_id` BIGINT UNSIGNED NOT NULL, + + PRIMARY KEY (`top_menu_id`, `role_id`), + + CONSTRAINT fk_top_menu_roles_menu + FOREIGN KEY (`top_menu_id`) + REFERENCES `b2b_top_menu`(`menu_id`) + ON DELETE CASCADE, + + CONSTRAINT fk_top_menu_roles_role + FOREIGN KEY (`role_id`) + REFERENCES `b2b_roles`(`id`) + ON DELETE CASCADE ); -- customers @@ -216,15 +223,11 @@ CREATE TABLE b2b_specific_price ( is_active BOOLEAN DEFAULT TRUE ) ENGINE = InnoDB; CREATE INDEX idx_b2b_scope ON b2b_specific_price(scope); -CREATE INDEX idx_b2b_customer ON b2b_specific_price(b2b_id_customer); -CREATE INDEX idx_b2b_country ON b2b_specific_price(b2b_id_country); CREATE INDEX idx_b2b_active_dates ON b2b_specific_price(is_active, valid_from, valid_till); CREATE INDEX idx_b2b_lookup ON b2b_specific_price ( scope, is_active, - b2b_id_customer, - b2b_id_country, from_quantity ); @@ -373,60 +376,6 @@ END$$ DELIMITER ; -CREATE TABLE b2b_specific_price_product ( - b2b_specific_price_id BIGINT UNSIGNED, - id_product INT UNSIGNED, - PRIMARY KEY (b2b_specific_price_id, id_product), - FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, - FOREIGN KEY (id_product) REFERENCES ps_product(id_product) ON DELETE CASCADE -); - -CREATE TABLE b2b_specific_price_category ( - b2b_specific_price_id BIGINT UNSIGNED, - id_category INT UNSIGNED, - PRIMARY KEY (b2b_specific_price_id, id_category), - FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, - FOREIGN KEY (id_category) REFERENCES ps_category(id_category) ON DELETE CASCADE -); - -CREATE TABLE b2b_specific_price_product_attribute ( - b2b_specific_price_id BIGINT UNSIGNED, - id_product_attribute INT UNSIGNED, - PRIMARY KEY (b2b_specific_price_id, id_product_attribute), - FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, - FOREIGN KEY (id_product_attribute) REFERENCES ps_product_attribute(id_product_attribute) ON DELETE CASCADE -); - -CREATE TABLE b2b_specific_price_customer ( - b2b_specific_price_id BIGINT UNSIGNED, - b2b_id_customer BIGINT UNSIGNED, - PRIMARY KEY (b2b_specific_price_id, b2b_id_customer), - FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, - FOREIGN KEY (b2b_id_customer) REFERENCES b2b_customers(id) ON DELETE CASCADE -); - -CREATE TABLE b2b_specific_price_country ( - b2b_specific_price_id BIGINT UNSIGNED, - b2b_id_country BIGINT UNSIGNED, - PRIMARY KEY (b2b_specific_price_id, b2b_id_country), - FOREIGN KEY (b2b_specific_price_id) REFERENCES b2b_specific_price(id) ON DELETE CASCADE, - FOREIGN KEY (b2b_id_country) REFERENCES b2b_countries(id) ON DELETE CASCADE -); - -CREATE INDEX idx_b2b_product_rel -ON b2b_specific_price_product (id_product); - -CREATE INDEX idx_b2b_category_rel -ON b2b_specific_price_category (id_category); - -CREATE INDEX idx_b2b_product_attribute_rel -ON b2b_specific_price_product_attribute (id_product_attribute); - -CREATE INDEX idx_bsp_customer -ON b2b_specific_price_customer (b2b_specific_price_id, b2b_id_customer); - -CREATE INDEX idx_bsp_country -ON b2b_specific_price_country (b2b_specific_price_id, b2b_id_country); -- +goose Down DROP TABLE IF EXISTS b2b_countries; diff --git a/i18n/migrations/20260302163123_create_tables_data.sql b/i18n/migrations/20260302163123_create_tables_data.sql index 7bae811..88875c5 100644 --- a/i18n/migrations/20260302163123_create_tables_data.sql +++ b/i18n/migrations/20260302163123_create_tables_data.sql @@ -7,12 +7,15 @@ VALUES (2, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'English', 'en', 'en', '__-__-____', '__-__', 0, 1, 1, '🇬🇧'), (3, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'Deutsch', 'de', 'de', '__-__-____', '__-__', 0, 0, 1, '🇩🇪'); +INSERT INTO `b2b_roles` (`name`, `id`) VALUES ('user','1'); +INSERT INTO `b2b_roles` (`name`, `id`) VALUES ('admin','2'); +INSERT INTO `b2b_roles` (`name`, `id`) VALUES ('super_admin','3'); + -- insert sample admin user admin@ma-al.com/Maal12345678 - -INSERT IGNORE INTO b2b_customers (id, email, password, first_name, last_name, role, provider, provider_id, avatar_url, is_active, email_verified, email_verification_token, email_verification_expires, password_reset_token, password_reset_expires, last_password_reset_request, last_login_at, lang_id, country_id, created_at, updated_at, deleted_at) +INSERT IGNORE INTO b2b_customers (id, email, password, first_name, last_name, role_id, provider, provider_id, avatar_url, is_active, email_verified, email_verification_token, email_verification_expires, password_reset_token, password_reset_expires, last_password_reset_request, last_login_at, lang_id, country_id, created_at, updated_at, deleted_at) VALUES - (1, 'admin@ma-al.com', '$2a$10$Owy9DjrS0l3Fz4XoOvh5pulgmOMqdwXmb7hYE9BovnSuWS2plGr82', 'Super', 'Admin', 'admin', 'local', '', '', 1, 1, NULL, NULL, '', NULL, NULL, NULL, 1, 1, '2026-03-02 16:55:10.252740', '2026-03-02 16:55:10.252740', NULL); + (1, 'admin@ma-al.com', '$2a$10$Owy9DjrS0l3Fz4XoOvh5pulgmOMqdwXmb7hYE9BovnSuWS2plGr82', 'Super', 'Admin', 2, 'local', '', '', 1, 1, NULL, NULL, '', NULL, NULL, NULL, 1, 1, '2026-03-02 16:55:10.252740', '2026-03-02 16:55:10.252740', NULL); ALTER TABLE b2b_customers AUTO_INCREMENT = 1; INSERT INTO `b2b_currencies` (`ps_id_currency`, `is_default`, `is_active`) VALUES From 7264a11ba67b8a8911da5a0c0c3fbaa874e9fee4 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 3 Apr 2026 14:58:50 +0200 Subject: [PATCH 06/11] sanitize and save URL slugs --- app/model/productDescription.go | 2 +- .../productDescriptionRepo.go | 10 +-- .../productTranslationService.go | 25 ++++++- .../sanitizeURLSlug.go | 69 +++++++++++++++++++ app/utils/const_data/consts.go | 25 +++++++ app/utils/responseErrors/responseErrors.go | 4 ++ bruno/b2b-daniel/save-product-description.yml | 39 +++++++++++ .../translate-product-description.yml | 28 ++++++++ 8 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 app/service/productTranslationService/sanitizeURLSlug.go create mode 100644 bruno/b2b-daniel/save-product-description.yml create mode 100644 bruno/b2b-daniel/translate-product-description.yml diff --git a/app/model/productDescription.go b/app/model/productDescription.go index 985b819..2080b0b 100644 --- a/app/model/productDescription.go +++ b/app/model/productDescription.go @@ -18,7 +18,7 @@ type ProductDescription struct { AvailableLater string `gorm:"column:available_later;type:varchar(255)" json:"available_later" form:"available_later"` DeliveryInStock string `gorm:"column:delivery_in_stock;type:varchar(255)" json:"delivery_in_stock" form:"delivery_in_stock"` DeliveryOutStock string `gorm:"column:delivery_out_stock;type:varchar(255)" json:"delivery_out_stock" form:"delivery_out_stock"` - Usage string `gorm:"column:_usage_;type:text" json:"usage" form:"usage"` + Usage string `gorm:"column:usage;type:text" json:"usage" form:"usage"` ImageLink string `gorm:"column:image_link" json:"image_link"` ExistsInDatabase bool `gorm:"-" json:"exists_in_database"` diff --git a/app/repos/productDescriptionRepo/productDescriptionRepo.go b/app/repos/productDescriptionRepo/productDescriptionRepo.go index ae26f6b..5083a42 100644 --- a/app/repos/productDescriptionRepo/productDescriptionRepo.go +++ b/app/repos/productDescriptionRepo/productDescriptionRepo.go @@ -52,7 +52,7 @@ func (r *ProductDescriptionRepo) GetProductDescription(productID uint, productid `+dbmodel.PsProductLangCols.AvailableLater.TabCol()+` AS available_later, `+dbmodel.PsProductLangCols.DeliveryInStock.TabCol()+` AS delivery_in_stock, `+dbmodel.PsProductLangCols.DeliveryOutStock.TabCol()+` AS delivery_out_stock, - `+dbmodel.PsProductLangCols.Usage.TabCol()+` AS _usage_, + `+dbmodel.PsProductLangCols.Usage.TabCol()+` AS `+"`usage`"+`, CONCAT(?, '/', `+dbmodel.PsImageShopCols.IDImage.TabCol()+`, '-large_default/', `+dbmodel.PsProductLangCols.LinkRewrite.TabCol()+`, '.webp') AS image_link `, config.Get().Image.ImagePrefix). Joins("JOIN " + dbmodel.TableNamePsImageShop + @@ -74,10 +74,10 @@ func (r *ProductDescriptionRepo) GetProductDescription(productID uint, productid // If it doesn't exist, returns an error. func (r *ProductDescriptionRepo) CreateIfDoesNotExist(productID uint, productid_lang uint) error { - record := model.ProductDescription{ - ProductID: productID, - ShopID: constdata.SHOP_ID, - LangID: productid_lang, + record := dbmodel.PsProductLang{ + IDProduct: int32(productID), + IDShop: int32(constdata.SHOP_ID), + IDLang: int32(productid_lang), } err := db.Get(). diff --git a/app/service/productTranslationService/productTranslationService.go b/app/service/productTranslationService/productTranslationService.go index 1b0a747..0ad8cd7 100644 --- a/app/service/productTranslationService/productTranslationService.go +++ b/app/service/productTranslationService/productTranslationService.go @@ -89,13 +89,24 @@ func (s *ProductTranslationService) GetProductDescription(userID uint, productID // Updates relevant fields with the "updates" map func (s *ProductTranslationService) SaveProductDescription(userID uint, productID uint, productLangID uint, updates map[string]string) error { // only some fields can be affected - allowedFields := []string{"description", "description_short", "meta_description", "meta_title", "name", "available_now", "available_later", "usage"} + allowedFields := []string{"description", "description_short", "link_rewrite", "meta_description", "meta_keywords", "meta_title", "name", + "available_now", "available_later", "delivery_in_stock", "delivery_out_stock", "usage"} for key := range updates { if !slices.Contains(allowedFields, key) { return responseErrors.ErrBadField } } + if text, exists := updates["link_rewrite"]; exists { + // sanitize and check that link_rewrite is a valid url slug + sanitized := SanitizeSlug(text) + if !IsValidSlug(sanitized) { + return responseErrors.ErrInvalidURLSlug + } + + updates["link_rewrite"] = sanitized + } + // check that fields description, description_short and usage, if they exist, have a valid html format mustBeHTML := []string{"description", "description_short", "usage"} for i := 0; i < len(mustBeHTML); i++ { @@ -136,20 +147,28 @@ func (s *ProductTranslationService) TranslateProductDescription(userID uint, pro fields := []*string{&productDescription.Description, &productDescription.DescriptionShort, + &productDescription.LinkRewrite, &productDescription.MetaDescription, + &productDescription.MetaKeywords, &productDescription.MetaTitle, &productDescription.Name, &productDescription.AvailableNow, &productDescription.AvailableLater, + &productDescription.DeliveryInStock, + &productDescription.DeliveryOutStock, &productDescription.Usage, } keys := []string{"translation_of_product_description", "translation_of_product_short_description", + "translation_of_product_url_link", "translation_of_product_meta_description", + "translation_of_product_meta_keywords", "translation_of_product_meta_title", "translation_of_product_name", - "translation_of_product_available_now", - "translation_of_product_available_later", + "translation_of_product_available_now_message", + "translation_of_product_available_later_message", + "translation_of_product_delivery_in_stock_message", + "translation_of_product_delivery_out_stock_message", "translation_of_product_usage", } diff --git a/app/service/productTranslationService/sanitizeURLSlug.go b/app/service/productTranslationService/sanitizeURLSlug.go new file mode 100644 index 0000000..ea69d7c --- /dev/null +++ b/app/service/productTranslationService/sanitizeURLSlug.go @@ -0,0 +1,69 @@ +package productTranslationService + +import ( + "strings" + "unicode" + + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "github.com/dlclark/regexp2" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +func IsValidSlug(s string) bool { + var slug_regex2 = regexp2.MustCompile(constdata.SLUG_REGEX, regexp2.None) + + ok, _ := slug_regex2.MatchString(s) + return ok +} + +func SanitizeSlug(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + + // First apply explicit transliteration for language-specific letters. + s = transliterateWithTable(s) + + // Then normalize and strip any remaining combining marks. + s = removeDiacritics(s) + + // Replace all non-alphanumeric runs with "-" + var non_alphanum_regex2 = regexp2.MustCompile(constdata.NON_ALNUM_REGEX, regexp2.None) + s, _ = non_alphanum_regex2.Replace(s, "-", -1, -1) + + // Collapse repeated "-" and trim edges + var multi_dash_regex2 = regexp2.MustCompile(constdata.MULTI_DASH_REGEX, regexp2.None) + s, _ = multi_dash_regex2.Replace(s, "-", -1, -1) + + s = strings.Trim(s, "-") + + return s +} + +func transliterateWithTable(s string) string { + var b strings.Builder + b.Grow(len(s)) + + for _, r := range s { + if repl, ok := constdata.TRANSLITERATION_TABLE[r]; ok { + b.WriteString(repl) + } else { + b.WriteRune(r) + } + } + + return b.String() +} + +func removeDiacritics(s string) string { + t := transform.Chain( + norm.NFD, + runes.Remove(runes.In(unicode.Mn)), + norm.NFC, + ) + out, _, err := transform.String(t, s) + if err != nil { + return s + } + return out +} diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index b3790c8..cbd5657 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -12,3 +12,28 @@ const MAX_AMOUNT_OF_CARTS_PER_USER = 10 const DEFAULT_NEW_CART_NAME = "new cart" const USER_LOCALE = "user" + +// Slug sanitization +const NON_ALNUM_REGEX = `[^a-z0-9]+` +const MULTI_DASH_REGEX = `-+` +const SLUG_REGEX = `^[a-z0-9]+(?:-[a-z0-9]+)*$` + +// Currently supports only German+Polish specific cases +var TRANSLITERATION_TABLE = map[rune]string{ + // German + 'ä': "ae", + 'ö': "oe", + 'ü': "ue", + 'ß': "ss", + + // Polish + 'ą': "a", + 'ć': "c", + 'ę': "e", + 'ł': "l", + 'ń': "n", + 'ó': "o", + 'ś': "s", + 'ż': "z", + 'ź': "z", +} diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index c4247ea..d20c173 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -42,6 +42,7 @@ var ( // Typed errors for product description handler ErrBadAttribute = errors.New("bad or missing attribute value in header") ErrBadField = errors.New("this field can not be updated") + ErrInvalidURLSlug = errors.New("URL slug does not obey the industry standard") ErrInvalidXHTML = errors.New("text is not in xhtml format") ErrAIResponseFail = errors.New("AI responded with failure") ErrAIBadOutput = errors.New("AI response does not obey the format") @@ -136,6 +137,8 @@ func GetErrorCode(c fiber.Ctx, err error) string { return i18n.T_(c, "error.err_bad_attribute") case errors.Is(err, ErrBadField): return i18n.T_(c, "error.err_bad_field") + case errors.Is(err, ErrInvalidURLSlug): + return i18n.T_(c, "error.invalid_url_slug") case errors.Is(err, ErrInvalidXHTML): return i18n.T_(c, "error.err_invalid_html") case errors.Is(err, ErrAIResponseFail): @@ -195,6 +198,7 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrBadAttribute), errors.Is(err, ErrBadField), + errors.Is(err, ErrInvalidURLSlug), errors.Is(err, ErrInvalidXHTML), errors.Is(err, ErrBadPaging), errors.Is(err, ErrNoRootFound), diff --git a/bruno/b2b-daniel/save-product-description.yml b/bruno/b2b-daniel/save-product-description.yml new file mode 100644 index 0000000..e843995 --- /dev/null +++ b/bruno/b2b-daniel/save-product-description.yml @@ -0,0 +1,39 @@ +info: + name: save-product-description + type: http + seq: 19 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/product-translation/save-product-description?productID=1&productLangID=1 + params: + - name: productID + value: "1" + type: query + - name: productLangID + value: "1" + type: query + body: + type: json + data: |- + { + "description": "

Zastosowanie wałków rehabilitacyjnych w różnego rodzaju ćwiczeniach oraz zabiegach wpływa pozytywnie na łagodzenie urazów oraz zwiększa szanse na powrót pacjenta do pełnej sprawności fizycznej. Stosowane są w rehabilitacji ruchowej, podczas gimnastyki korekcyjnej, masaży tradycyjnych i sportowych, gdyż idealnie nadają się do unoszenia i separacji kończyn. Można je wykorzystać także do podpierania kolan, stóp, ramion, a także barków pacjenta. Wałki rehabilitacyjne polecane są także dla dzieci, wykorzystanie ich podczas zabawy znacznie wspiera rozwój dużej motoryki.

\n

Dzięki szerokiej ofercie kolorystycznej oraz zróżnicowanym rozmiarom, możliwe jest skomponowanie zestawu do ćwiczeń niezbędnego w każdym gabinecie fizjoterapeutycznym, gabinecie masażu czy też szkole i przedszkolu. 

\n

Wałek rehabilitacyjny  jest wyrobem medycznym zgodnie z wymaganiami zasadniczymi dla wyrobów medycznych i w rozumieniu ustawy o wyrobach medycznych, zgłoszonym do Rejestru Wyrobów Medycznych prowadzonego przez Urząd Rejestracji Produktów Leczniczych, Wyrobów Medycznych i Produktów Biobójczych, wyposażonym w deklarację zgodności producenta i opatrzonym znakiem CE.

\n

\n

\"Wyrób

\n

Polecane zastosowanie:

\n
    \n
  • w rehabilitacji
  • \n
  • podczas masaży (tradycyjnych, sportowych)
  • \n
  • w gimnastyce korekcyjnej (w tym zwłaszcza dzieci)
  • \n
  • w łagodzeniu urazów poszczególnych części ciała
  • \n
  • dla podparcia: kolan, kostek, głowy pacjenta
  • \n
  • w ćwiczeniach rozwijających motorykę dzieci
  • \n
  • w salonach kosmetycznych
  • \n
  • w salach zabaw dla dzieci
  • \n
\n

\n

Specyfikacja materiału:

\n

Pokrowiec: materiał z powłoką PCV przeznaczony dla wyrobów medycznych, dzięki czemu jest bardzo łatwy w czyszczeniu oraz dezynfekcji:

\n
    \n
  • materiał zgodny z rozporządzeniem REACH, posiada atest Certyfikat STANDARD 100 by OEKO-TEX ®
  • \n
  • nie zawiera ftalanów
  • \n
  • ognioodporny
  • \n
  • odporny na płyny fizjologiczne (krew, mocz, pot) oraz na alkohol
  • \n
  • odporny na UV, przez co może być także używany na zewnątrz
  • \n
  • odporny na zadrapania
  • \n
  • olejoodporny
  • \n
\n

\"REACH\"\"Certyfikat\"Nie\"Ognioodporny\"\"Odporny\"Odporny\"Przeznaczony\"Odporny\"Olejoodporny\"

\n

Wypełnienie: średnio twarda pianka poliuretanowa o podwyższonej odporności na odkształcenia:

\n
    \n
  • posiada ATEST HIGIENICZNY wydany przez Instytut Medycyny Morskiej i Tropikalnej w Gdyni
  • \n
  • posiada atest Certyfikat STANDARD 100 by OEKO-TEX ® – klasa produktów I wydany przez Instytut Włókiennictwa w Łodzi
  • \n
  • produkowana z surowców o podwyższonej jakości, nie powodujących zubożenia warstwy ozonowej 
  • \n
\n

\"Certyfikat\"Atest\"Atest

\n

\n

", + "description_short": "

Wałki rehabilitacyjne znajdują swoje zastosowanie w różnego rodzaju ćwiczeniach. Stosowane są w rehabilitacji ruchowej, podczas gimnastyki korekcyjnej, masaży tradycyjnych i sportowych, gdyż idealnie nadają się do unoszenia i separacji kończyn. Można je wykorzystać także do podpierania kolan, stóp, ramion, a także barków pacjenta. Wałki rehabilitacyjne polecane są także dla dzieci, wykorzystanie ich podczas zabawy, znacznie wspiera rozwój dużej motoryki. Produkt posiada certyfikację jako wyrób medyczny. 

", + "link_rewrite": " Wałek-Rehabilitacyjny-10x30-cm ", + "meta_description": "", + "meta_keywords": "", + "meta_title": "", + "name": "Wałek rehabilitacyjny 10 x 30 cm", + "available_now": "dostępny", + "available_later": "na zamówienie", + "delivery_in_stock": "Czas realizacji 3-7 dni roboczych", + "delivery_out_stock": "Czas realizacji 3-7 dni roboczych", + "usage": "

I. Czyszczenie i konserwacja

\r\n

Tapicerkę należy czyścić powierzchniowo stosując dozwolone środki:

\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n

Rodzaj zabrudzenia

\r\n
\r\n

Dozwolone środki

\r\n
\r\n

Postępowanie

\r\n
\r\n

Codzienne zabrudzenia

\r\n

 

\r\n
\r\n

Łagodny detergent najlepiej roztwór szarego mydła

\r\n
\r\n

Czyścić regularnie z użyciem gąbki lub miękkiej szczotki. Na koniec przetrzeć czyszczone miejsce wilgotną szmatką po czym wytrzeć do sucha (w celu usunięcia pozostałości detergentu).

\r\n
\r\n

Miejscowe, silniejsze zabrudzenia

\r\n
\r\n

25% roztwór alkoholu etylowego

\r\n
\r\n

Delikatnie przecierać nasączonym tamponem z gazy. Na koniec przetrzeć czyszczone miejsce wilgotną szmatką po czym wytrzeć do sucha (w celu usunięcia pozostałości detergentu).

\r\n
\r\n

Dezynfekcja

\r\n
\r\n

Ogólnodostępne środki do dezynfekcji zawierające:

\r\n

- aktywny chlor – dichloroizocyjanuran sodu, max stężenie 10000 ppm 

\r\n

- aktywny chlor - dwutlenek chloru w roztworze do 20 000 ppm 

\r\n

- alkohol izopropylowy max stężenie 70 % 

\r\n

\r\n
\r\n

Dezynfekować zgodnie z zaleceniami producenta używanego środka.

\r\n
\r\n

Przed użyciem środka innego niż łagodny detergent trzeba sprawdzić efekt w niewidocznym miejscu, a samo czyszczenie wykonać bardzo ostrożnie.

\r\n
\r\n


II. Informacje

\r\n

\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\"\"\r\n

Szamponować przy użyciu gąbki

\r\n
\r\n

 \"\"

\r\n
\r\n

Nie prać!!! (delikatne wyroby)   

\r\n
\r\n

\r\n
\r\n

Nie chlorować!!! (nie stosować do bielenia związków wydzielających wolny chlor)

\r\n
\r\n

 \"\"

\r\n
\r\n

Nie prasować!!! (nie dopuszczać do kontaktu z nagrzanymi powierzchniami np. kaloryfer)

\r\n
\r\n

 \"\"

\r\n
\r\n

Nie czyścić chemicznie!!!

\r\n
\r\n

\r\n

III. Warunki gwarancji

\r\n

Gwarancji nie podlegają:

\r\n
    \r\n
  • Trwałe przebarwienia powstałe wskutek kontaktu z odzieżą zawierającą aktywne, migrujące barwniki (np. jeans, zamsz itp.)
  • \r\n
  • Ślady z długopisu, tuszu, mazaków itp. zawierające aktywne barwniki
  • \r\n
  • Uszkodzenia wywołane przez wysoką temperaturę, płyny żrące, ogień
  • \r\n
  • Uszkodzenia mechaniczne spowodowane przez zwierzęta domowe i innych użytkowników
  • \r\n
  • Wady powstałe wskutek niewłaściwej konserwacji
  • \r\n
" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/translate-product-description.yml b/bruno/b2b-daniel/translate-product-description.yml new file mode 100644 index 0000000..c914958 --- /dev/null +++ b/bruno/b2b-daniel/translate-product-description.yml @@ -0,0 +1,28 @@ +info: + name: translate-product-description + type: http + seq: 20 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/product-translation/translate-product-description?productID=51&productFromLangID=1&productToLangID=3&model=Google + params: + - name: productID + value: "51" + type: query + - name: productFromLangID + value: "1" + type: query + - name: productToLangID + value: "3" + type: query + - name: model + value: Google + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 From 76ca2a2eed98f89f080e70165e755b6c64ca44b1 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Fri, 3 Apr 2026 15:58:35 +0200 Subject: [PATCH 07/11] chore: adapt code to new teleport feature --- app/delivery/web/api/restricted/customer.go | 92 +++++++++---------- app/model/customer.go | 9 ++ app/model/role.go | 2 +- app/repos/customerRepo/customerRepo.go | 2 +- bruno/api_v1/customer/Customer (me).yml | 6 +- .../20260302163123_create_tables_data.sql | 9 +- 6 files changed, 65 insertions(+), 55 deletions(-) diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go index 039efcb..da8a7e5 100644 --- a/app/delivery/web/api/restricted/customer.go +++ b/app/delivery/web/api/restricted/customer.go @@ -4,9 +4,9 @@ import ( "strconv" "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" - "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/customerService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -28,37 +28,34 @@ func CustomerHandlerRoutes(r fiber.Router) fiber.Router { handler := NewCustomerHandler() r.Get("", handler.customerData) - r.Get("/list", handler.listCustomers) + // r.Get("/list", handler.listCustomers) return r } func (h *customerHandler) customerData(fc fiber.Ctx) error { var customerId uint + + user, ok := localeExtractor.GetCustomer(fc) + if !ok || user == nil { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + customerIdStr := fc.Query("id") if customerIdStr != "" { - user, ok := fc.Locals("user").(*model.UserSession) - if !ok { - return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) - } id, err := strconv.ParseUint(customerIdStr, 10, 64) if err != nil { return fiber.ErrBadRequest } - if user.UserID != uint(id) && !user.HasPermission(perms.UserReadAny) { + if user.ID != uint(id) && !user.HasPermission(perms.UserReadAny) { return fc.Status(fiber.StatusForbidden). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) } customerId = uint(id) } else { - id, ok := fc.Locals("userID").(uint) - if !ok { - return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) - } - customerId = id + customerId = user.ID } customer, err := h.service.GetById(customerId) @@ -70,40 +67,41 @@ func (h *customerHandler) customerData(fc fiber.Ctx) error { return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) } -func (h *customerHandler) listCustomers(fc fiber.Ctx) error { - var customerId uint - customerIdStr := fc.Query("id") - if customerIdStr != "" { - user, ok := fc.Locals("user").(*model.UserSession) - if !ok { - return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) - } - id, err := strconv.ParseUint(customerIdStr, 10, 64) - if err != nil { - return fiber.ErrBadRequest - } +// func (h *customerHandler) listCustomers(fc fiber.Ctx) error { +// var customerId uint +// customerIdStr := fc.Query("id") +// if customerIdStr != "" { - if user.UserID != uint(id) && !user.HasPermission(perms.UserReadAny) { - return fc.Status(fiber.StatusForbidden). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) - } +// user, ok := localeExtractor.GetCustomer(fc) +// if !ok || user == nil { +// return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). +// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) +// } +// id, err := strconv.ParseUint(customerIdStr, 10, 64) +// if err != nil { +// return fiber.ErrBadRequest +// } - customerId = uint(id) - } else { - id, ok := fc.Locals("userID").(uint) - if !ok { - return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) - } - customerId = id - } +// if user.UserID != uint(id) && !user.HasPermission(perms.UserReadAny) { +// return fc.Status(fiber.StatusForbidden). +// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) +// } - customer, err := h.service.GetById(customerId) - if err != nil { - return fc.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) - } +// customerId = uint(id) +// } else { +// id, ok := fc.Locals("userID").(uint) +// if !ok { +// return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). +// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) +// } +// customerId = id +// } - return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) -} +// customer, err := h.service.GetById(customerId) +// if err != nil { +// return fc.Status(responseErrors.GetErrorStatus(err)). +// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) +// } + +// return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) +// } diff --git a/app/model/customer.go b/app/model/customer.go index d036e5b..ccf2fe5 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -34,6 +34,15 @@ type Customer struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } +func (u *Customer) HasPermission(permission perms.Permission) bool { + for _, p := range u.Role.Permissions { + if p.Name == permission { + return true + } + } + return false +} + // AuthProvider represents the authentication provider type AuthProvider string diff --git a/app/model/role.go b/app/model/role.go index 3c663b5..2ea0789 100644 --- a/app/model/role.go +++ b/app/model/role.go @@ -3,7 +3,7 @@ package model type Role struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:64" json:"name"` - Permissions []Permission `gorm:"many2many:b2b_role_permissions;" json:"-"` + Permissions []Permission `gorm:"many2many:b2b_role_permissions;" json:"permissions"` } func (Role) TableName() string { diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go index 058d5fd..b46890f 100644 --- a/app/repos/customerRepo/customerRepo.go +++ b/app/repos/customerRepo/customerRepo.go @@ -19,7 +19,7 @@ func (repo *CustomerRepo) Get(id uint) (*model.Customer, error) { var customer model.Customer err := db.DB. - Preload("Role"). + Preload("Role.Permissions"). First(&customer, id). Error diff --git a/bruno/api_v1/customer/Customer (me).yml b/bruno/api_v1/customer/Customer (me).yml index 253bead..891919e 100644 --- a/bruno/api_v1/customer/Customer (me).yml +++ b/bruno/api_v1/customer/Customer (me).yml @@ -5,11 +5,7 @@ info: http: method: GET - url: "{{bas_url}}/restricted/customer?id=1" - params: - - name: id - value: "1" - type: query + url: "{{bas_url}}/restricted/customer" auth: inherit settings: diff --git a/i18n/migrations/20260302163123_create_tables_data.sql b/i18n/migrations/20260302163123_create_tables_data.sql index ce62f1b..dafebf7 100644 --- a/i18n/migrations/20260302163123_create_tables_data.sql +++ b/i18n/migrations/20260302163123_create_tables_data.sql @@ -35,5 +35,12 @@ INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('2', 'user.write.any'); INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('3', 'user.delete.any'); INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('4', 'currency.write'); - +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '1'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '2'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '3'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '4'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '1'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '2'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '3'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '4'); -- +goose Down \ No newline at end of file From 813d1f48791d30f2a0e61f78dee043fdae9146f8 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Tue, 7 Apr 2026 09:28:39 +0200 Subject: [PATCH 08/11] feat: add customer list, modify pagination utils --- app/delivery/web/api/restricted/customer.go | 61 ++++++++----------- app/model/customer.go | 2 +- app/repos/customerRepo/customerRepo.go | 12 ++++ app/service/authService/auth.go | 1 - app/service/authService/google_oauth.go | 1 - .../customerService/customerService.go | 6 ++ app/utils/query/find/find.go | 18 +----- bruno/api_v1/customer/Customer list.yml | 15 +++++ 8 files changed, 61 insertions(+), 55 deletions(-) create mode 100644 bruno/api_v1/customer/Customer list.yml diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go index da8a7e5..6b3ea60 100644 --- a/app/delivery/web/api/restricted/customer.go +++ b/app/delivery/web/api/restricted/customer.go @@ -4,10 +4,12 @@ import ( "strconv" "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/customerService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "github.com/gofiber/fiber/v3" @@ -28,7 +30,7 @@ func CustomerHandlerRoutes(r fiber.Router) fiber.Router { handler := NewCustomerHandler() r.Get("", handler.customerData) - // r.Get("/list", handler.listCustomers) + r.Get("/list", handler.listCustomers) return r } @@ -67,41 +69,28 @@ func (h *customerHandler) customerData(fc fiber.Ctx) error { return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) } -// func (h *customerHandler) listCustomers(fc fiber.Ctx) error { -// var customerId uint -// customerIdStr := fc.Query("id") -// if customerIdStr != "" { +func (h *customerHandler) listCustomers(fc fiber.Ctx) error { + p, filt, err := query_params.ParseFilters[model.Customer](fc, columnMappingListProducts) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } -// user, ok := localeExtractor.GetCustomer(fc) -// if !ok || user == nil { -// return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). -// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) -// } -// id, err := strconv.ParseUint(customerIdStr, 10, 64) -// if err != nil { -// return fiber.ErrBadRequest -// } + user, ok := localeExtractor.GetCustomer(fc) + if !ok || user == nil { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + if !user.HasPermission(perms.UserReadAny) { + return fc.Status(fiber.StatusForbidden). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) + } -// if user.UserID != uint(id) && !user.HasPermission(perms.UserReadAny) { -// return fc.Status(fiber.StatusForbidden). -// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) -// } + customer, err := h.service.Find(user.LangID, p, filt) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } -// customerId = uint(id) -// } else { -// id, ok := fc.Locals("userID").(uint) -// if !ok { -// return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). -// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) -// } -// customerId = id -// } - -// customer, err := h.service.GetById(customerId) -// if err != nil { -// return fc.Status(responseErrors.GetErrorStatus(err)). -// JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) -// } - -// return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) -// } + return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) +} diff --git a/app/model/customer.go b/app/model/customer.go index ccf2fe5..9421862 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -15,7 +15,7 @@ type Customer struct { FirstName string `gorm:"size:100" json:"first_name"` LastName string `gorm:"size:100" json:"last_name"` RoleID uint `gorm:"column:role_id;not null;default:1" json:"-"` - Role Role `gorm:"foreignKey:RoleID" json:"role"` + Role *Role `gorm:"foreignKey:RoleID" json:"role,omitempty"` Provider AuthProvider `gorm:"type:varchar(20);default:'local'" json:"provider"` ProviderID string `gorm:"size:255" json:"provider_id,omitempty"` // ID from OAuth provider AvatarURL string `gorm:"size:500" json:"avatar_url,omitempty"` diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go index b46890f..7a979bb 100644 --- a/app/repos/customerRepo/customerRepo.go +++ b/app/repos/customerRepo/customerRepo.go @@ -3,10 +3,13 @@ package customerRepo import ( "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) type UICustomerRepo interface { Get(id uint) (*model.Customer, error) + Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Customer], error) } type CustomerRepo struct{} @@ -25,3 +28,12 @@ func (repo *CustomerRepo) Get(id uint) (*model.Customer, error) { return &customer, err } + +func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Customer], error) { + found, err := find.Paginate[model.Customer](langId, p, db.DB. + Model(&model.Customer{}). + Scopes(filt.All()...), + ) + + return &found, err +} diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index dc4a35b..ba1fa67 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -153,7 +153,6 @@ func (s *AuthService) Register(req *model.RegisterRequest) error { Password: string(hashedPassword), FirstName: req.FirstName, LastName: req.LastName, - Role: model.Role{}, Provider: model.ProviderLocal, IsActive: false, EmailVerified: false, diff --git a/app/service/authService/google_oauth.go b/app/service/authService/google_oauth.go index c26da16..d8c1820 100644 --- a/app/service/authService/google_oauth.go +++ b/app/service/authService/google_oauth.go @@ -150,7 +150,6 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. Provider: model.ProviderGoogle, ProviderID: info.ID, AvatarURL: info.Picture, - Role: model.Role{}, IsActive: true, EmailVerified: true, LangID: 2, // default is english diff --git a/app/service/customerService/customerService.go b/app/service/customerService/customerService.go index 7af553c..dbaeb24 100644 --- a/app/service/customerService/customerService.go +++ b/app/service/customerService/customerService.go @@ -3,6 +3,8 @@ package customerService import ( "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/repos/customerRepo" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) type CustomerService struct { @@ -18,3 +20,7 @@ func New() *CustomerService { func (s *CustomerService) GetById(id uint) (*model.Customer, error) { return s.repo.Get(id) } + +func (s *CustomerService) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Customer], error) { + return s.repo.Find(langId, p, filt) +} diff --git a/app/utils/query/find/find.go b/app/utils/query/find/find.go index 7d810ec..57ef813 100644 --- a/app/utils/query/find/find.go +++ b/app/utils/query/find/find.go @@ -1,7 +1,6 @@ package find import ( - "errors" "reflect" "strings" @@ -28,18 +27,13 @@ type Found[T any] struct { Spec map[string]interface{} `json:"spec,omitempty"` } -// Wraps given query adding limit, offset clauses and SQL_CALC_FOUND_ROWS to it -// and running SELECT FOUND_ROWS() afterwards to fetch the total number -// (ignoring LIMIT) of results. The final results are wrapped into the -// [find.Found] type. func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error) { var items []T - var count uint64 + var count int64 - // stmt.Debug() + stmt.Count(&count) err := stmt. - Clauses(SqlCalcFound()). Offset(paging.Offset()). Limit(paging.Limit()). Find(&items). @@ -48,14 +42,6 @@ func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error return Found[T]{}, err } - countInterface, ok := stmt.Get(FOUND_ROWS_CTX_KEY) - if !ok { - return Found[T]{}, errors.New(FOUND_ROWS_CTX_KEY + " value was not found in the gorm db context") - } - if count, ok = countInterface.(uint64); !ok { - return Found[T]{}, errors.New("failed to cast value under " + FOUND_ROWS_CTX_KEY + " to uint64") - } - columnsSpec := GetColumnsSpec[T](langID) return Found[T]{ diff --git a/bruno/api_v1/customer/Customer list.yml b/bruno/api_v1/customer/Customer list.yml new file mode 100644 index 0000000..0d5bc26 --- /dev/null +++ b/bruno/api_v1/customer/Customer list.yml @@ -0,0 +1,15 @@ +info: + name: Customer list + type: http + seq: 3 + +http: + method: GET + url: "{{bas_url}}/restricted/customer/list" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 From 918729736785f544c847eed2a8a535fb4c72262d Mon Sep 17 00:00:00 2001 From: Wiktor Date: Tue, 7 Apr 2026 10:32:30 +0200 Subject: [PATCH 09/11] refactor: move lists to their representative repos --- app/delivery/web/api/restricted/customer.go | 9 +- app/delivery/web/api/restricted/list.go | 99 -------------- app/delivery/web/api/restricted/product.go | 34 +++++ app/delivery/web/init.go | 4 - app/model/customer.go | 1 - app/repos/customerRepo/customerRepo.go | 54 +++++++- app/repos/listRepo/listRepo.go | 121 ------------------ app/repos/productsRepo/productsRepo.go | 66 ++++++++++ .../customerService/customerService.go | 2 +- app/service/listService/listService.go | 26 ---- app/service/productService/productService.go | 7 + bruno/api_v1/product/Products List.yml | 5 +- repository/currencyRepo/currencyRepo.go | 53 -------- 13 files changed, 167 insertions(+), 314 deletions(-) delete mode 100644 app/delivery/web/api/restricted/list.go delete mode 100644 app/repos/listRepo/listRepo.go delete mode 100644 app/service/listService/listService.go delete mode 100644 repository/currencyRepo/currencyRepo.go diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go index 6b3ea60..6f953b3 100644 --- a/app/delivery/web/api/restricted/customer.go +++ b/app/delivery/web/api/restricted/customer.go @@ -70,7 +70,7 @@ func (h *customerHandler) customerData(fc fiber.Ctx) error { } func (h *customerHandler) listCustomers(fc fiber.Ctx) error { - p, filt, err := query_params.ParseFilters[model.Customer](fc, columnMappingListProducts) + p, filt, err := query_params.ParseFilters[model.Customer](fc, columnMappingListUsers) if err != nil { return fc.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) @@ -94,3 +94,10 @@ func (h *customerHandler) listCustomers(fc fiber.Ctx) error { return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) } + +var columnMappingListUsers map[string]string = map[string]string{ + "user_id": "users.id", + "email": "users.email", + "first_name": "users.first_name", + "second_name": "users.second_name", +} diff --git a/app/delivery/web/api/restricted/list.go b/app/delivery/web/api/restricted/list.go deleted file mode 100644 index c6b3116..0000000 --- a/app/delivery/web/api/restricted/list.go +++ /dev/null @@ -1,99 +0,0 @@ -package restricted - -import ( - "git.ma-al.com/goc_daniel/b2b/app/config" - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/service/listService" - "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" - "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" - "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" - "git.ma-al.com/goc_daniel/b2b/app/utils/response" - "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" - "github.com/gofiber/fiber/v3" -) - -// ListHandler handles endpoints that list various things (e.g. products or users) -type ListHandler struct { - listService *listService.ListService - config *config.Config -} - -// NewListHandler creates a new ListHandler instance -func NewListHandler() *ListHandler { - listService := listService.New() - return &ListHandler{ - listService: listService, - config: config.Get(), - } -} - -func ListHandlerRoutes(r fiber.Router) fiber.Router { - handler := NewListHandler() - - r.Get("/list-products", handler.ListProducts) - r.Get("/list-users", handler.ListUsers) - - return r -} - -func (h *ListHandler) ListProducts(c fiber.Ctx) error { - paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingListProducts) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - id_lang, ok := localeExtractor.GetLangID(c) - if !ok { - return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) - } - - list, err := h.listService.ListProducts(id_lang, paging, filters) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - return c.JSON(response.Make(&list.Items, int(list.Count), i18n.T_(c, response.Message_OK))) -} - -var columnMappingListProducts map[string]string = map[string]string{ - "product_id": "ps.id_product", - "name": "pl.name", - "reference": "p.reference", - "category_name": "cl.name", - "category_id": "cp.id_category", - "quantity": "sa.quantity", -} - -func (h *ListHandler) ListUsers(c fiber.Ctx) error { - paging, filters, err := query_params.ParseFilters[model.Customer](c, columnMappingListUsers) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - id_lang, ok := localeExtractor.GetLangID(c) - if !ok { - return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) - } - - list, err := h.listService.ListUsers(id_lang, paging, filters) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - return c.JSON(response.Make(&list.Items, int(list.Count), i18n.T_(c, response.Message_OK))) -} - -var columnMappingListUsers map[string]string = map[string]string{ - "user_id": "users.id", - "email": "users.email", - "first_name": "users.first_name", - "second_name": "users.second_name", - "role": "users.role", -} diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index d4fa8ce..ddd8677 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -4,10 +4,12 @@ import ( "strconv" "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/productService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "github.com/gofiber/fiber/v3" @@ -31,6 +33,7 @@ func ProductsHandlerRoutes(r fiber.Router) fiber.Router { handler := NewProductsHandler() r.Get("/:id/:country_id/:quantity", handler.GetProductJson) + r.Get("/list", handler.ListProducts) return r } @@ -73,3 +76,34 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { return c.JSON(response.Make(&productJson, 1, i18n.T_(c, response.Message_OK))) } + +func (h *ProductsHandler) ListProducts(c fiber.Ctx) error { + paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingListProducts) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + id_lang, ok := localeExtractor.GetLangID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + list, err := h.productService.Find(id_lang, paging, filters) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&list.Items, int(list.Count), i18n.T_(c, response.Message_OK))) +} + +var columnMappingListProducts map[string]string = map[string]string{ + "product_id": "ps.id_product", + "name": "pl.name", + "reference": "p.reference", + "category_name": "cl.name", + "category_id": "cp.id_category", + "quantity": "sa.quantity", +} diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index ad75e75..9d673f5 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -97,10 +97,6 @@ func (s *Server) Setup() error { productTranslation := s.restricted.Group("/product-translation") restricted.ProductTranslationHandlerRoutes(productTranslation) - // lists of things routes (restricted) - list := s.restricted.Group("/list") - restricted.ListHandlerRoutes(list) - product := s.restricted.Group("/product") restricted.ProductsHandlerRoutes(product) diff --git a/app/model/customer.go b/app/model/customer.go index 9421862..77102ad 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -177,5 +177,4 @@ type UserInList struct { Email string `gorm:"column:email" json:"email"` FirstName string `gorm:"column:first_name" json:"first_name"` LastName string `gorm:"column:last_name" json:"last_name"` - Role string `gorm:"column:role" json:"role"` } diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go index 7a979bb..9f325c2 100644 --- a/app/repos/customerRepo/customerRepo.go +++ b/app/repos/customerRepo/customerRepo.go @@ -9,7 +9,7 @@ import ( type UICustomerRepo interface { Get(id uint) (*model.Customer, error) - Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Customer], error) + Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) } type CustomerRepo struct{} @@ -29,11 +29,57 @@ func (repo *CustomerRepo) Get(id uint) (*model.Customer, error) { return &customer, err } -func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Customer], error) { - found, err := find.Paginate[model.Customer](langId, p, db.DB. - Model(&model.Customer{}). +func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) { + found, err := find.Paginate[model.UserInList](langId, p, db.DB. + Table("b2b_customers AS users"). + Select(` + users.id AS id, + users.email AS email, + users.first_name AS first_name, + users.last_name AS last_name + `). Scopes(filt.All()...), ) return &found, err } + +// func (repo *ListRepo) ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) { +// var list []model.UserInList +// var total int64 + +// query := db.Get(). +// Table("b2b_customers AS users"). +// Select(` +// users.id AS id, +// users.email AS email, +// users.first_name AS first_name, +// users.last_name AS last_name, +// users.role AS role +// `) + +// // Apply all filters +// if filt != nil { +// filt.ApplyAll(query) +// } + +// // run counter first as query is without limit and offset +// err := query.Count(&total).Error +// if err != nil { +// return find.Found[model.UserInList]{}, err +// } + +// err = query. +// Order("users.id DESC"). +// Limit(p.Limit()). +// Offset(p.Offset()). +// Find(&list).Error +// if err != nil { +// return find.Found[model.UserInList]{}, err +// } + +// return find.Found[model.UserInList]{ +// Items: list, +// Count: uint(total), +// }, nil +// } diff --git a/app/repos/listRepo/listRepo.go b/app/repos/listRepo/listRepo.go deleted file mode 100644 index d31ebda..0000000 --- a/app/repos/listRepo/listRepo.go +++ /dev/null @@ -1,121 +0,0 @@ -package listRepo - -import ( - "git.ma-al.com/goc_daniel/b2b/app/config" - "git.ma-al.com/goc_daniel/b2b/app/db" - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" - "git.ma-al.com/goc_marek/gormcol" - "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" -) - -type UIListRepo interface { - ListProducts(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) - ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) -} - -type ListRepo struct{} - -func New() UIListRepo { - return &ListRepo{} -} - -func (repo *ListRepo) ListProducts(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { - var list []model.ProductInList - var total int64 - - query := db.Get(). - Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps"). - Select(` - ps.id_product AS product_id, - pl.name AS name, - pl.link_rewrite AS link_rewrite, - CONCAT(?, '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, - cl.name AS category_name, - p.reference AS reference, - COALESCE(v.variants_number, 0) AS variants_number, - sa.quantity AS quantity - `, config.Get().Image.ImagePrefix). - Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product"). - Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). - Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). - Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). - Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product"). - Joins("LEFT JOIN variants v ON v.id_product = ps.id_product"). - Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0"). - Where("ps.active = ?", 1). - Group("ps.id_product"). - Clauses(exclause.With{CTEs: []exclause.CTE{ - { - Name: "variants", - Subquery: exclause.Subquery{DB: db.Get().Model(&dbmodel.PsProductAttributeShop{}).Select("id_product", "COUNT(*) AS variants_number").Group("id_product")}, - }, - }}) - - // Apply all filters - if filt != nil { - filt.ApplyAll(query) - } - - // run counter first as query is without limit and offset - err := query.Count(&total).Error - if err != nil { - return find.Found[model.ProductInList]{}, err - } - - err = query. - Order("ps.id_product DESC"). - Limit(p.Limit()). - Offset(p.Offset()). - Find(&list).Error - if err != nil { - return find.Found[model.ProductInList]{}, err - } - - return find.Found[model.ProductInList]{ - Items: list, - Count: uint(total), - }, nil -} - -func (repo *ListRepo) ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) { - var list []model.UserInList - var total int64 - - query := db.Get(). - Table("b2b_customers AS users"). - Select(` - users.id AS id, - users.email AS email, - users.first_name AS first_name, - users.last_name AS last_name, - users.role AS role - `) - - // Apply all filters - if filt != nil { - filt.ApplyAll(query) - } - - // run counter first as query is without limit and offset - err := query.Count(&total).Error - if err != nil { - return find.Found[model.UserInList]{}, err - } - - err = query. - Order("users.id DESC"). - Limit(p.Limit()). - Offset(p.Offset()). - Find(&list).Error - if err != nil { - return find.Found[model.UserInList]{}, err - } - - return find.Found[model.UserInList]{ - Items: list, - Count: uint(total), - }, nil -} diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 7c6c08f..341b348 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -4,11 +4,19 @@ import ( "encoding/json" "fmt" + "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" + "git.ma-al.com/goc_marek/gormcol" + "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" ) type UIProductsRepo interface { GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) + Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) } type ProductsRepo struct{} @@ -37,3 +45,61 @@ func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_custo raw := json.RawMessage(productStr) return &raw, nil } + +func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { + var list []model.ProductInList + var total int64 + + query := db.Get(). + Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps"). + Select(` + ps.id_product AS product_id, + pl.name AS name, + pl.link_rewrite AS link_rewrite, + CONCAT(?, '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, + cl.name AS category_name, + p.reference AS reference, + COALESCE(v.variants_number, 0) AS variants_number, + sa.quantity AS quantity + `, config.Get().Image.ImagePrefix). + Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product"). + Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). + Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). + Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). + Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product"). + Joins("LEFT JOIN variants v ON v.id_product = ps.id_product"). + Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0"). + Where("ps.active = ?", 1). + Group("ps.id_product"). + Clauses(exclause.With{CTEs: []exclause.CTE{ + { + Name: "variants", + Subquery: exclause.Subquery{DB: db.Get().Model(&dbmodel.PsProductAttributeShop{}).Select("id_product", "COUNT(*) AS variants_number").Group("id_product")}, + }, + }}). + Order("ps.id_product DESC") + + // Apply all filters + if filt != nil { + filt.ApplyAll(query) + } + + // run counter first as query is without limit and offset + err := query.Count(&total).Error + if err != nil { + return find.Found[model.ProductInList]{}, err + } + + err = query. + Limit(p.Limit()). + Offset(p.Offset()). + Find(&list).Error + if err != nil { + return find.Found[model.ProductInList]{}, err + } + + return find.Found[model.ProductInList]{ + Items: list, + Count: uint(total), + }, nil +} diff --git a/app/service/customerService/customerService.go b/app/service/customerService/customerService.go index dbaeb24..f9f2f4a 100644 --- a/app/service/customerService/customerService.go +++ b/app/service/customerService/customerService.go @@ -21,6 +21,6 @@ func (s *CustomerService) GetById(id uint) (*model.Customer, error) { return s.repo.Get(id) } -func (s *CustomerService) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Customer], error) { +func (s *CustomerService) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) { return s.repo.Find(langId, p, filt) } diff --git a/app/service/listService/listService.go b/app/service/listService/listService.go deleted file mode 100644 index d3d168b..0000000 --- a/app/service/listService/listService.go +++ /dev/null @@ -1,26 +0,0 @@ -package listService - -import ( - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/repos/listRepo" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" -) - -type ListService struct { - listRepo listRepo.UIListRepo -} - -func New() *ListService { - return &ListService{ - listRepo: listRepo.New(), - } -} - -func (s *ListService) ListProducts(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { - return s.listRepo.ListProducts(id_lang, p, filters) -} - -func (s *ListService) ListUsers(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.UserInList], error) { - return s.listRepo.ListUsers(id_lang, p, filters) -} diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index 66245f1..1a1620e 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -3,8 +3,11 @@ package productService import ( "encoding/json" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) type ProductService struct { @@ -25,3 +28,7 @@ func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_ return products, nil } + +func (s *ProductService) Find(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { + return s.productsRepo.Find(id_lang, p, filters) +} diff --git a/bruno/api_v1/product/Products List.yml b/bruno/api_v1/product/Products List.yml index cc07f08..6763495 100644 --- a/bruno/api_v1/product/Products List.yml +++ b/bruno/api_v1/product/Products List.yml @@ -5,7 +5,7 @@ info: http: method: GET - url: "{{bas_url}}/restricted/list/list-products?p=1&elems=30&sort=product_id,asc&category_id_in=243&reference=~62" + url: "{{bas_url}}/restricted/product/list?p=1&elems=30&sort=product_id,asc&category_id_in=243&reference=~62" params: - name: p value: "1" @@ -25,9 +25,6 @@ http: body: type: json data: "" - auth: - type: bearer - token: "{{token}}" settings: encodeUrl: true diff --git a/repository/currencyRepo/currencyRepo.go b/repository/currencyRepo/currencyRepo.go deleted file mode 100644 index 97b1b5e..0000000 --- a/repository/currencyRepo/currencyRepo.go +++ /dev/null @@ -1,53 +0,0 @@ -package currencyRepo - -import ( - "git.ma-al.com/goc_daniel/b2b/app/db" - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" -) - -type UICurrencyRepo interface { - CreateConversionRate(currencyRate *model.CurrencyRate) error - Get(id uint) (*model.Currency, error) -} - -type CurrencyRepo struct{} - -func New() UICurrencyRepo { - return &CurrencyRepo{} -} - -func (repo *CurrencyRepo) CreateConversionRate(currencyRate *model.CurrencyRate) error { - return db.DB.Debug().Create(currencyRate).Error -} - -func (repo *CurrencyRepo) Get(id uint) (*model.Currency, error) { - var currency model.Currency - - err := db.DB.Table("b2b_currencies c"). - Select("c.*, r.conversion_rate"). - Joins(` - LEFT JOIN b2b_currency_rates r - ON r.b2b_id_currency = c.id - AND r.created_at = ( - SELECT MAX(created_at) - FROM b2b_currency_rates - WHERE b2b_id_currency = c.id - ) - `). - Where("c.id = ?", id). - Scan(¤cy).Error - - return ¤cy, err -} - -func (repo *CurrencyRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.Currency], error) { - - found, err := find.Paginate[model.Currency](langId, p, db.DB. - Model(&model.Currency{}). - Scopes(filt.All()...), - ) - - return &found, err -} From 2e645f3368a03fcdfeb4a4c5547041ab611bcce5 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Tue, 7 Apr 2026 13:36:43 +0200 Subject: [PATCH 10/11] fix: google provider auth --- app/delivery/web/api/restricted/customer.go | 8 +- app/repos/customerRepo/customerRepo.go | 81 +++++++++++++++++++++ app/repos/rolesRepo/rolesRepo.go | 22 ++++++ app/service/authService/auth.go | 18 +++-- app/service/authService/google_oauth.go | 30 ++++++-- 5 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 app/repos/rolesRepo/rolesRepo.go diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go index 6f953b3..7c04b7e 100644 --- a/app/delivery/web/api/restricted/customer.go +++ b/app/delivery/web/api/restricted/customer.go @@ -96,8 +96,8 @@ func (h *customerHandler) listCustomers(fc fiber.Ctx) error { } var columnMappingListUsers map[string]string = map[string]string{ - "user_id": "users.id", - "email": "users.email", - "first_name": "users.first_name", - "second_name": "users.second_name", + "user_id": "users.id", + "email": "users.email", + "first_name": "users.first_name", + "last_name": "users.last_name", } diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go index 9f325c2..668785f 100644 --- a/app/repos/customerRepo/customerRepo.go +++ b/app/repos/customerRepo/customerRepo.go @@ -9,7 +9,11 @@ import ( type UICustomerRepo interface { Get(id uint) (*model.Customer, error) + GetByEmail(email string) (*model.Customer, error) + GetByExternalProviderId(provider model.AuthProvider, id string) (*model.Customer, error) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) + Save(customer *model.Customer) error + Create(customer *model.Customer) error } type CustomerRepo struct{} @@ -29,6 +33,30 @@ func (repo *CustomerRepo) Get(id uint) (*model.Customer, error) { return &customer, err } +func (repo *CustomerRepo) GetByEmail(email string) (*model.Customer, error) { + var customer model.Customer + + err := db.DB. + Preload("Role.Permissions"). + Where("email = ?", email). + First(&customer). + Error + + return &customer, err +} + +func (repo *CustomerRepo) GetByExternalProviderId(provider model.AuthProvider, id string) (*model.Customer, error) { + var customer model.Customer + + err := db.DB. + Preload("Role.Permissions"). + Where("provider = ? AND provider_id = ?", provider, id). + First(&customer). + Error + + return &customer, err +} + func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) { found, err := find.Paginate[model.UserInList](langId, p, db.DB. Table("b2b_customers AS users"). @@ -44,6 +72,59 @@ func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.Filters return &found, err } +func (repo *CustomerRepo) Save(customer *model.Customer) error { + return db.DB.Save(customer).Error +} + +func (repo *CustomerRepo) Create(customer *model.Customer) error { + return db.DB.Create(customer).Error +} + +// func (repo *CustomerRepo) Search( +// customerId uint, +// partnerCode string, +// p find.Paging, +// filt *filters.FiltersList, +// search string, +// ) (found find.Found[model.UserInList], err error) { +// words := strings.Fields(search) +// if len(words) > 5 { +// words = words[:5] +// } + +// query := ctx.DB(). +// Model(&model.Customer{}). +// Select("customer.id AS id, customer.first_name as first_name, customer.last_name as last_name, customer.phone_number AS phone_number, customer.email AS email, count(distinct investment_plan_contract.id) as iiplan_purchases, count(distinct `order`.id) as single_purchases, entity.name as entity_name"). +// Where("customer.id <> ?", customerId). +// Where("(customer.id IN (SELECT id FROM customer WHERE partner_code IN (WITH RECURSIVE partners AS (SELECT code AS dst FROM partner WHERE code = ? UNION SELECT code FROM partner JOIN partners ON partners.dst = partner.superior_code) SELECT dst FROM partners)) OR customer.recommender_code = ?)", partnerCode, partnerCode). +// Scopes(view.CustomerListQuery()) + +// var conditions []string +// var args []interface{} +// for _, word := range words { + +// conditions = append(conditions, ` +// (LOWER(first_name) LIKE ? OR +// LOWER(last_name) LIKE ? OR +// phone_number LIKE ? OR +// LOWER(email) LIKE ?) +// `) + +// for i := 0; i < 4; i++ { +// args = append(args, "%"+strings.ToLower(word)+"%") +// } +// } + +// finalQuery := strings.Join(conditions, " AND ") + +// query = query.Where(finalQuery, args...). +// Scopes(filt.All()...) + +// found, err = find.Paginate[V](ctx, p, query) + +// return found, errs.Recorded(span, err) +// } + // func (repo *ListRepo) ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) { // var list []model.UserInList // var total int64 diff --git a/app/repos/rolesRepo/rolesRepo.go b/app/repos/rolesRepo/rolesRepo.go new file mode 100644 index 0000000..e87e10f --- /dev/null +++ b/app/repos/rolesRepo/rolesRepo.go @@ -0,0 +1,22 @@ +package roleRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UIRolesRepo interface { + Get(id uint) (*model.Role, error) +} + +type RolesRepo struct{} + +func New() UIRolesRepo { + return &RolesRepo{} +} + +func (r *RolesRepo) Get(id uint) (*model.Role, error) { + var role model.Role + err := db.DB.First(&role, id).Error + return &role, err +} diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index ba1fa67..ebc9e32 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -11,6 +11,8 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/customerRepo" + roleRepo "git.ma-al.com/goc_daniel/b2b/app/repos/rolesRepo" "git.ma-al.com/goc_daniel/b2b/app/service/emailService" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -35,17 +37,21 @@ type JWTClaims struct { // AuthService handles authentication operations type AuthService struct { - db *gorm.DB - config *config.AuthConfig - email *emailService.EmailService + db *gorm.DB + config *config.AuthConfig + email *emailService.EmailService + customerRepo customerRepo.UICustomerRepo + roleRepo roleRepo.UIRolesRepo } // NewAuthService creates a new AuthService instance func NewAuthService() *AuthService { svc := &AuthService{ - db: db.Get(), - config: &config.Get().Auth, - email: emailService.NewEmailService(), + db: db.Get(), + config: &config.Get().Auth, + email: emailService.NewEmailService(), + customerRepo: customerRepo.New(), + roleRepo: roleRepo.New(), } // Auto-migrate the refresh_tokens table if svc.db != nil { diff --git a/app/service/authService/google_oauth.go b/app/service/authService/google_oauth.go index d8c1820..d517c6d 100644 --- a/app/service/authService/google_oauth.go +++ b/app/service/authService/google_oauth.go @@ -108,26 +108,32 @@ func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, st // findOrCreateGoogleUser finds an existing user by Google provider ID or email, // or creates a new one. func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model.Customer, error) { - var user model.Customer + var user *model.Customer // Try to find by provider + provider_id - err := s.db.Where("provider = ? AND provider_id = ?", model.ProviderGoogle, info.ID).First(&user).Error + user, err := s.customerRepo.GetByExternalProviderId(model.ProviderGoogle, info.ID) if err == nil { // Update avatar in case it changed user.AvatarURL = info.Picture - s.db.Save(&user) - return &user, nil + err = s.customerRepo.Save(user) + if err != nil { + return nil, err + } + return user, nil } // Try to find by email (user may have registered locally before) - err = s.db.Where("email = ?", info.Email).First(&user).Error + user, err = s.customerRepo.GetByEmail(info.Email) if err == nil { // Link Google provider to existing account user.Provider = model.ProviderGoogle user.ProviderID = info.ID user.AvatarURL = info.Picture user.IsActive = true - s.db.Save(&user) + err = s.customerRepo.Save(user) + if err != nil { + return nil, err + } // If email has not been verified yet, send email to admin. if !user.EmailVerified { @@ -139,7 +145,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. } user.EmailVerified = true - return &user, nil + return user, nil } // Create new user @@ -148,6 +154,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. FirstName: info.GivenName, LastName: info.FamilyName, Provider: model.ProviderGoogle, + RoleID: 1, // user ProviderID: info.ID, AvatarURL: info.Picture, IsActive: true, @@ -156,7 +163,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. CountryID: 2, // default is England } - if err := s.db.Create(&newUser).Error; err != nil { + if err := s.customerRepo.Create(&newUser); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } @@ -169,6 +176,13 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. } } + var role *model.Role + role, err = s.roleRepo.Get(newUser.RoleID) + if err != nil { + return nil, err + } + newUser.Role = role + return &newUser, nil } From d56650ae5da258176f5f64aa35950b9ab2dbad9f Mon Sep 17 00:00:00 2001 From: Wiktor Date: Tue, 7 Apr 2026 14:42:45 +0200 Subject: [PATCH 11/11] feat: searching on customer list --- app/delivery/web/api/restricted/customer.go | 22 +++++++--- app/repos/customerRepo/customerRepo.go | 43 ++++++++++++++++--- .../customerService/customerService.go | 4 +- app/utils/query/find/find.go | 8 ++-- bruno/api_v1/customer/Customer list.yml | 6 ++- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go index 7c04b7e..6e1a41c 100644 --- a/app/delivery/web/api/restricted/customer.go +++ b/app/delivery/web/api/restricted/customer.go @@ -70,12 +70,6 @@ func (h *customerHandler) customerData(fc fiber.Ctx) error { } func (h *customerHandler) listCustomers(fc fiber.Ctx) error { - p, filt, err := query_params.ParseFilters[model.Customer](fc, columnMappingListUsers) - if err != nil { - return fc.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) - } - user, ok := localeExtractor.GetCustomer(fc) if !ok || user == nil { return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). @@ -86,7 +80,21 @@ func (h *customerHandler) listCustomers(fc fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) } - customer, err := h.service.Find(user.LangID, p, filt) + p, filt, err := query_params.ParseFilters[model.Customer](fc, columnMappingListUsers) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } + + search := fc.Query("search") + if search != "" { + if !user.HasPermission(perms.UserReadAny) { + return fc.Status(fiber.StatusForbidden). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) + } + } + + customer, err := h.service.Find(user.LangID, p, filt, search) if err != nil { return fc.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go index 668785f..18dea15 100644 --- a/app/repos/customerRepo/customerRepo.go +++ b/app/repos/customerRepo/customerRepo.go @@ -1,6 +1,8 @@ package customerRepo import ( + "strings" + "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" @@ -11,7 +13,7 @@ type UICustomerRepo interface { Get(id uint) (*model.Customer, error) GetByEmail(email string) (*model.Customer, error) GetByExternalProviderId(provider model.AuthProvider, id string) (*model.Customer, error) - Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) + Find(langId uint, p find.Paging, filt *filters.FiltersList, search string) (*find.Found[model.UserInList], error) Save(customer *model.Customer) error Create(customer *model.Customer) error } @@ -57,17 +59,46 @@ func (repo *CustomerRepo) GetByExternalProviderId(provider model.AuthProvider, i return &customer, err } -func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) { - found, err := find.Paginate[model.UserInList](langId, p, db.DB. +func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList, search string) (*find.Found[model.UserInList], error) { + + query := db.DB. Table("b2b_customers AS users"). Select(` users.id AS id, users.email AS email, users.first_name AS first_name, users.last_name AS last_name - `). - Scopes(filt.All()...), - ) + `) + + if search != "" { + words := strings.Fields(search) + if len(words) > 5 { + words = words[:5] + } + var conditions []string + var args []interface{} + for _, word := range words { + + conditions = append(conditions, ` + (LOWER(first_name) LIKE ? OR + LOWER(last_name) LIKE ? OR + LOWER(email) LIKE ?) + `) + + for range 3 { + args = append(args, "%"+strings.ToLower(word)+"%") + } + } + + conditionsQuery := strings.Join(conditions, " AND ") + + query = query.Where(conditionsQuery, args...) + + } + + query = query.Scopes(filt.All()...) + + found, err := find.Paginate[model.UserInList](langId, p, query) return &found, err } diff --git a/app/service/customerService/customerService.go b/app/service/customerService/customerService.go index f9f2f4a..bce463d 100644 --- a/app/service/customerService/customerService.go +++ b/app/service/customerService/customerService.go @@ -21,6 +21,6 @@ func (s *CustomerService) GetById(id uint) (*model.Customer, error) { return s.repo.Get(id) } -func (s *CustomerService) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) { - return s.repo.Find(langId, p, filt) +func (s *CustomerService) Find(langId uint, p find.Paging, filt *filters.FiltersList, search string) (*find.Found[model.UserInList], error) { + return s.repo.Find(langId, p, filt, search) } diff --git a/app/utils/query/find/find.go b/app/utils/query/find/find.go index 57ef813..487c1d1 100644 --- a/app/utils/query/find/find.go +++ b/app/utils/query/find/find.go @@ -42,14 +42,14 @@ func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error return Found[T]{}, err } - columnsSpec := GetColumnsSpec[T](langID) + // columnsSpec := GetColumnsSpec[T](langID) return Found[T]{ Items: items, Count: uint(count), - Spec: map[string]interface{}{ - "columns": columnsSpec, - }, + // Spec: map[string]interface{}{ + // "columns": columnsSpec, + // }, }, err } diff --git a/bruno/api_v1/customer/Customer list.yml b/bruno/api_v1/customer/Customer list.yml index 0d5bc26..11c286b 100644 --- a/bruno/api_v1/customer/Customer list.yml +++ b/bruno/api_v1/customer/Customer list.yml @@ -5,7 +5,11 @@ info: http: method: GET - url: "{{bas_url}}/restricted/customer/list" + url: "{{bas_url}}/restricted/customer/list?search=" + params: + - name: search + value: "" + type: query auth: inherit settings: