update_categories #62

Merged
goc_daniel merged 6 commits from update_categories into main 2026-04-14 10:35:28 +00:00
25 changed files with 365 additions and 344 deletions

View File

@@ -10,7 +10,6 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/service/authService"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor"
"github.com/gofiber/fiber/v3"
)
@@ -115,26 +114,6 @@ func AuthMiddleware() fiber.Handler {
}
}
// RequireAdmin creates admin-only middleware
func RequireAdmin() fiber.Handler {
return func(c fiber.Ctx) error {
originalUserRole, ok := localeExtractor.GetOriginalUserRole(c)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "not authenticated",
})
}
if model.CustomerRole(originalUserRole.Name) != model.RoleAdmin {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "admin access required",
})
}
return c.Next()
}
}
// Webdav
func Webdav() fiber.Handler {
authService := authService.NewAuthService()

View File

@@ -8,4 +8,8 @@ const (
UserDeleteAny Permission = "user.delete.any"
CurrencyWrite Permission = "currency.write"
SpecificPriceManage Permission = "specific_price.manage"
WebdavCreateToken Permission = "webdav.create_token"
ProductTranslationSave Permission = "product_translation.save"
ProductTranslationTranslate Permission = "product_translation.translate"
SearchCreateIndex Permission = "search.create_index"
)

View File

@@ -103,14 +103,15 @@ func (h *ProductsHandler) ListProducts(c fiber.Ctx) error {
return c.JSON(response.Make(&list.Items, int(list.Count), i18n.T_(c, response.Message_OK)))
}
// These are all the filterable fields
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",
"is_favorite": "ps.is_favorite",
"product_id": "bp.product_id",
"name": "bp.name",
"reference": "bp.reference",
"category_id": "bp.category_id",
"quantity": "bp.quantity",
"is_favorite": "bp.is_favorite",
"is_new": "bp.is_new",
}
func (h *ProductsHandler) AddToFavorites(c fiber.Ctx) error {

View File

@@ -4,7 +4,8 @@ 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/delivery/middleware"
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms"
"git.ma-al.com/goc_daniel/b2b/app/service/productTranslationService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor"
@@ -35,8 +36,8 @@ func ProductTranslationHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewProductTranslationHandler()
r.Get("/get-product-description", handler.GetProductDescription)

I just remembered that the purpose of defining the permissions in permissions.go is to minimize the usage of hardcoded strings in handler and instead use perms.Permission const.

in this instance it would be perms.ProductTranslationSave

I just remembered that the purpose of defining the permissions in permissions.go is to minimize the usage of hardcoded strings in handler and instead use perms.Permission const. in this instance it would be perms.ProductTranslationSave
r.Post("/save-product-description", handler.SaveProductDescription)
r.Get("/translate-product-description", handler.TranslateProductDescription)
r.Post("/save-product-description", middleware.Require(perms.ProductTranslationSave), handler.SaveProductDescription)
r.Get("/translate-product-description", middleware.Require(perms.ProductTranslationTranslate), handler.TranslateProductDescription)
return r
}
@@ -80,12 +81,6 @@ func (h *ProductTranslationHandler) SaveProductDescription(c fiber.Ctx) error {
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
}
userRole, ok := localeExtractor.GetOriginalUserRole(c)
if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired)))
}
productID_attribute := c.Query("productID")
productID, err := strconv.Atoi(productID_attribute)
if err != nil {
@@ -123,12 +118,6 @@ func (h *ProductTranslationHandler) TranslateProductDescription(c fiber.Ctx) err
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
}
userRole, ok := localeExtractor.GetOriginalUserRole(c)
if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired)))
}
productID_attribute := c.Query("productID")
productID, err := strconv.Atoi(productID_attribute)
if err != nil {

View File

@@ -4,7 +4,8 @@ import (
"encoding/json"
"fmt"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware"
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms"
"git.ma-al.com/goc_daniel/b2b/app/service/meiliService"
searchservice "git.ma-al.com/goc_daniel/b2b/app/service/searchService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
@@ -30,7 +31,7 @@ func NewMeiliSearchHandler() *MeiliSearchHandler {
func MeiliSearchHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewMeiliSearchHandler()
r.Get("/create-index", handler.CreateIndex)
r.Get("/create-index", middleware.Require(perms.SearchCreateIndex), handler.CreateIndex)
r.Post("/search", handler.Search)
r.Post("/settings", handler.GetSettings)
@@ -44,12 +45,6 @@ func (h *MeiliSearchHandler) CreateIndex(c fiber.Ctx) error {
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
userRole, ok := localeExtractor.GetOriginalUserRole(c)
if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired)))
}
err := h.meiliService.CreateIndex(id_lang)
if err != nil {
fmt.Printf("CreateIndex error: %v\n", err)

View File

@@ -5,6 +5,7 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware"
"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/specificPriceService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
@@ -30,13 +31,13 @@ func NewSpecificPriceHandler() *SpecificPriceHandler {
func SpecificPriceHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewSpecificPriceHandler()
r.Post("/", middleware.Require("specific_price.manage"), handler.Create)
r.Put("/:id", middleware.Require("specific_price.manage"), handler.Update)
r.Delete("/:id", middleware.Require("specific_price.manage"), handler.Delete)
r.Get("/", middleware.Require("specific_price.manage"), handler.List)
r.Get("/:id", middleware.Require("specific_price.manage"), handler.GetByID)
r.Patch("/:id/activate", middleware.Require("specific_price.manage"), handler.Activate)
r.Patch("/:id/deactivate", middleware.Require("specific_price.manage"), handler.Deactivate)
r.Post("/", middleware.Require(perms.SpecificPriceManage), handler.Create)
r.Put("/:id", middleware.Require(perms.SpecificPriceManage), handler.Update)
r.Delete("/:id", middleware.Require(perms.SpecificPriceManage), handler.Delete)
r.Get("/", middleware.Require(perms.SpecificPriceManage), handler.List)
r.Get("/:id", middleware.Require(perms.SpecificPriceManage), handler.GetByID)
r.Patch("/:id/activate", middleware.Require(perms.SpecificPriceManage), handler.Activate)
r.Patch("/:id/deactivate", middleware.Require(perms.SpecificPriceManage), handler.Deactivate)
return r
}

View File

@@ -4,7 +4,8 @@ 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/delivery/middleware"
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms"
"git.ma-al.com/goc_daniel/b2b/app/service/storageService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor"
@@ -34,7 +35,7 @@ func StorageHandlerRoutes(r fiber.Router) fiber.Router {
r.Get("/download-file/*", handler.DownloadFile)
// for admins only
r.Get("/create-new-webdav-token", handler.CreateNewWebdavToken)
r.Get("/create-new-webdav-token", middleware.Require(perms.WebdavCreateToken), handler.CreateNewWebdavToken)
return r
}
@@ -84,12 +85,6 @@ func (h *StorageHandler) CreateNewWebdavToken(c fiber.Ctx) error {
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
}
userRole, ok := localeExtractor.GetOriginalUserRole(c)
if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired)))
}
new_token, err := h.storageService.NewWebdavToken(userID)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).

View File

@@ -161,16 +161,6 @@ func (s *Server) Setup() error {
// })
// })
// // Admin routes example
// admin := s.api.Group("/admin")
// admin.Use(middleware.AuthMiddleware())
// admin.Use(middleware.RequireAdmin())
// admin.Get("/users", func(c fiber.Ctx) error {
// return c.JSON(fiber.Map{
// "message": "Admin area - user management",
// })
// })
// keep this at the end because its wilderange
general.InitBo(s.App())

View File

@@ -11,6 +11,7 @@ type ScannedCategory struct {
IsoCode string `gorm:"column:iso_code"`
Visited bool // this is for internal backend use only
Filter string // filter applicable to this category
}
type Category struct {
@@ -25,6 +26,7 @@ type CategoryParams struct {
CategoryID uint `json:"category_id" form:"category_id"`
LinkRewrite string `json:"link_rewrite" form:"link_rewrite"`
Locale string `json:"locale" form:"locale"`
Filter string `json:"filter" form:"filter"`
}
type CategoryInBreadcrumb struct {

View File

@@ -12,6 +12,7 @@ type ProductInList struct {
PriceTaxExcl float64 `gorm:"column:price_tax_excl" json:"price_tax_excl"`
PriceTaxIncl float64 `gorm:"column:price_tax_incl" json:"price_tax_incl"`
IsFavorite bool `gorm:"column:is_favorite" json:"is_favorite"`
IsNew uint `gorm:"column:is_new" json:"is_new"`
}
type ProductFilters struct {

View File

@@ -1,48 +0,0 @@
package categoriesRepo
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/model/dbmodel"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
)
type UICategoriesRepo interface {
GetAllCategories(idLang uint) ([]model.ScannedCategory, error)
}
type CategoriesRepo struct{}
func New() UICategoriesRepo {
return &CategoriesRepo{}
}
func (r *CategoriesRepo) GetAllCategories(idLang uint) ([]model.ScannedCategory, error) {
var allCategories []model.ScannedCategory
categoryTbl := (&dbmodel.PsCategory{}).TableName()
categoryLangTbl := (&dbmodel.PsCategoryLang{}).TableName()
categoryShopTbl := (&dbmodel.PsCategoryShop{}).TableName()
langTbl := (&dbmodel.PsLang{}).TableName()
err := db.Get().
Model(dbmodel.PsCategory{}).
Select(`
ps_category.id_category AS category_id,
ps_category_lang.name AS name,
ps_category.active AS active,
ps_category_shop.position AS position,
ps_category.id_parent AS id_parent,
ps_category.is_root_category AS is_root_category,
ps_category_lang.link_rewrite AS link_rewrite,
ps_lang.iso_code AS iso_code
`).
Joins(`LEFT JOIN `+categoryLangTbl+` ON `+categoryLangTbl+`.id_category = `+categoryTbl+`.id_category AND `+categoryLangTbl+`.id_shop = ? AND `+categoryLangTbl+`.id_lang = ?`,
constdata.SHOP_ID, idLang).
Joins(`LEFT JOIN `+categoryShopTbl+` ON `+categoryShopTbl+`.id_category = `+categoryTbl+`.id_category AND `+categoryShopTbl+`.id_shop = ?`,
constdata.SHOP_ID).
Joins(`JOIN ` + langTbl + ` ON ` + langTbl + `.id_lang = ` + categoryLangTbl + `.id_lang`).
Scan(&allCategories).Error
return allCategories, err
}

View File

@@ -2,11 +2,14 @@ package categoryrepo
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/model/dbmodel"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
)
type UICategoryRepo interface {
GetCategoryTranslations(ids []uint, idLang uint) (map[uint]string, error)
RetrieveMenuCategories(idLang uint) ([]model.ScannedCategory, error)
}
type CategoryRepo struct{}
@@ -42,3 +45,33 @@ func (r *CategoryRepo) GetCategoryTranslations(ids []uint, idLang uint) (map[uin
return translations, nil
}
func (r *CategoryRepo) RetrieveMenuCategories(idLang uint) ([]model.ScannedCategory, error) {
var allCategories []model.ScannedCategory
categoryTbl := (&dbmodel.PsCategory{}).TableName()
categoryLangTbl := (&dbmodel.PsCategoryLang{}).TableName()
categoryShopTbl := (&dbmodel.PsCategoryShop{}).TableName()
langTbl := (&dbmodel.PsLang{}).TableName()
err := db.Get().
Model(dbmodel.PsCategory{}).
Select(`
ps_category.id_category AS category_id,
ps_category_lang.name AS name,
ps_category.active AS active,
ps_category_shop.position AS position,
ps_category.id_parent AS id_parent,
ps_category.is_root_category AS is_root_category,
ps_category_lang.link_rewrite AS link_rewrite,
ps_lang.iso_code AS iso_code
`).
Joins(`LEFT JOIN `+categoryLangTbl+` ON `+categoryLangTbl+`.id_category = `+categoryTbl+`.id_category AND `+categoryLangTbl+`.id_shop = ? AND `+categoryLangTbl+`.id_lang = ?`,
constdata.SHOP_ID, idLang).
Joins(`LEFT JOIN `+categoryShopTbl+` ON `+categoryShopTbl+`.id_category = `+categoryTbl+`.id_category AND `+categoryShopTbl+`.id_shop = ?`,
constdata.SHOP_ID).
Joins(`JOIN ` + langTbl + ` ON ` + langTbl + `.id_lang = ` + categoryLangTbl + `.id_lang`).
Scan(&allCategories).Error
return allCategories, err
}

View File

@@ -9,7 +9,6 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/model/dbmodel"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"github.com/WinterYukky/gorm-extra-clause-plugin/exclause"
"gorm.io/gorm"
)
@@ -17,7 +16,6 @@ type UIProductDescriptionRepo interface {
GetProductDescription(productID uint, productid_lang uint) (*model.ProductDescription, error)
CreateIfDoesNotExist(productID uint, productid_lang uint) error
UpdateFields(productID uint, productid_lang uint, updates map[string]string) error
GetMeiliProducts(id_lang uint, offset, limit int) ([]model.MeiliSearchProduct, error)
}
type ProductDescriptionRepo struct{}
@@ -118,108 +116,3 @@ func (r *ProductDescriptionRepo) UpdateFields(productID uint, productid_lang uin
return nil
}
// GetMeiliProductsBatchedScanned returns a batch of products with LIMIT/OFFSET pagination
// The scanning is done inside the repo to keep the service layer cleaner
func (r *ProductDescriptionRepo) GetMeiliProducts(id_lang uint, offset, limit int) ([]model.MeiliSearchProduct, error) {
var products []model.MeiliSearchProduct
err := db.Get().
Table("ps_product_shop ps").
Select(`
ps.id_product AS id_product,
pl.name AS name,
TRIM(REGEXP_REPLACE(REGEXP_REPLACE(pl.description_short, '<[^>]*>', ' '), '[[:space:]]+', ' ')) AS description,
p.ean13,
p.reference,
ps.price,
ps.id_category_default AS id_category,
cl.name AS cat_name,
cl.link_rewrite AS l_rew,
COALESCE(vary.attributes, JSON_OBJECT()) AS attr,
COALESCE(feat.features, JSON_OBJECT()) AS feat,
img.id_image,
cat.category_ids,
(SELECT COUNT(*) FROM ps_product_attribute_shop pas2 WHERE pas2.id_product = ps.id_product AND pas2.id_shop = ?) AS variations
`, constdata.SHOP_ID).
Joins("JOIN ps_product p ON p.id_product = ps.id_product").
Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_shop = ? AND pl.id_lang = ?", constdata.SHOP_ID, id_lang).
Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_shop = ? AND cl.id_lang = ?", constdata.SHOP_ID, id_lang).
Joins("LEFT JOIN variations vary ON vary.id_product = ps.id_product").
Joins("LEFT JOIN features feat ON feat.id_product = ps.id_product").
Joins("LEFT JOIN images img ON img.id_product = ps.id_product").
Joins("LEFT JOIN categories cat ON cat.id_product = ps.id_product").
Joins("JOIN products_page pp ON pp.id_product = ps.id_product").
Where("ps.active = ?", 1).
Order("ps.id_product").
Clauses(exclause.With{CTEs: []exclause.CTE{
{
Name: "products_page",
Subquery: exclause.Subquery{
DB: db.Get().
Model(&dbmodel.PsProductShop{}).
Select("id_product, price").
Where("id_shop = ? AND active = 1", constdata.SHOP_ID).
Order("id_product").
Limit(limit).
Offset(offset),
},
},
{
Name: "variation_attributes",
Subquery: exclause.Subquery{
DB: db.Get().
Table("ps_product_attribute_shop pas"). // <- explicit alias here
Select(`
pas.id_product,
pag.id_attribute_group AS attribute_name,
JSON_ARRAYAGG(DISTINCT pa.id_attribute) AS attribute_values
`).
Joins("JOIN ps_product_attribute_combination ppac ON ppac.id_product_attribute = pas.id_product_attribute").
Joins("JOIN ps_attribute pa ON pa.id_attribute = ppac.id_attribute").
Joins("JOIN ps_attribute_group pag ON pag.id_attribute_group = pa.id_attribute_group").
Where("pas.id_shop = ?", constdata.SHOP_ID).
Group("pas.id_product, pag.id_attribute_group"),
},
},
{
Name: "variations",
Subquery: exclause.Subquery{
DB: db.Get().
Table("variation_attributes").
Select("id_product, JSON_OBJECTAGG(attribute_name, attribute_values) AS attributes").
Group("id_product"),
},
},
{
Name: "features",
Subquery: exclause.Subquery{
DB: db.Get().
Table("ps_feature_product pfp"). // <- explicit alias
Select("pfp.id_product, JSON_OBJECTAGG(pfp.id_feature, pfp.id_feature_value) AS features").
Group("pfp.id_product"),
},
},
{
Name: "images",
Subquery: exclause.Subquery{
DB: db.Get().
Model(&dbmodel.PsImageShop{}).
Select("id_product, id_image").
Where("id_shop = ? AND cover = 1", constdata.SHOP_ID),
},
},
{
Name: "categories",
Subquery: exclause.Subquery{
DB: db.Get().
Model(&dbmodel.PsCategoryProduct{}).
Select("id_product, JSON_ARRAYAGG(id_category) AS category_ids").
Group("id_product"),
},
},
}}).Find(&products).Error
return products, err
}

View File

@@ -105,57 +105,90 @@ func (repo *ProductsRepo) GetVariants(p_id_product, p_id_shop, p_id_lang, p_id_c
}
func (repo *ProductsRepo) Find(langID uint, userID uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error) {
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,
COALESCE(f.is_favorite, 0) AS is_favorite
`, 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 = ?", langID).
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 = ?", langID).
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 favorites f ON f.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").
query := db.DB.
Table("base_products AS bp").
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"),
},
},
{
Name: "favorites",
Subquery: exclause.Subquery{
DB: db.Get().
DB: db.DB.
Table("b2b_favorites").
Select(`
product_id AS id_product,
product_id AS product_id,
COUNT(*) > 0 AS is_favorite
`).
Where("user_id = ?", userID).
Group("product_id"),
},
},
{
Name: "new_product_days",
Subquery: exclause.Subquery{
DB: db.DB.
Table("ps_configuration").
Select("CAST(value AS SIGNED) AS days").
Where("name = ?", "PS_NB_DAYS_NEW_PRODUCT"),
},
},
{
Name: "variants",
Subquery: exclause.Subquery{
DB: db.DB.
Table("ps_product_attribute_shop").
Select("id_product, COUNT(*) AS variants_number").
Group("id_product"),
},
},
{
Name: "base_products",
Subquery: exclause.Subquery{
DB: db.DB.
Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps").
Select(`
ps.id_product AS product_id,
pl.name AS name,
ps.id_category_default AS category_id,
p.reference AS reference,
sa.quantity AS quantity,
COALESCE(f.is_favorite, 0) AS is_favorite,
CASE
WHEN ps.date_add >= DATE_SUB(
NOW(),
INTERVAL COALESCE(npd.days, 20) DAY
) AND ps.active = 1
THEN 1
ELSE 0
END AS is_new
`).
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 = ?", langID).
Joins("LEFT JOIN favorites f ON f.product_id = ps.id_product").
Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0").
Joins("LEFT JOIN new_product_days npd ON 1 = 1").
Where("ps.active = ?", 1).
Group("ps.id_product"),
},
},
},
}).
Order("ps.id_product DESC")
Select(`
bp.product_id AS product_id,
bp.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,
bp.reference AS reference,
COALESCE(v.variants_number, 0) AS variants_number,
bp.quantity AS quantity,
bp.is_favorite AS is_favorite,
bp.is_new AS is_new
`, config.Get().Image.ImagePrefix).
Joins("JOIN ps_product_lang pl ON pl.id_product = bp.product_id AND pl.id_lang = ?", langID).
Joins("JOIN ps_image_shop ims ON ims.id_product = bp.product_id AND ims.cover = 1").
Joins("JOIN ps_category_lang cl ON cl.id_category = bp.category_id AND cl.id_lang = ?", langID).
Joins("LEFT JOIN variants v ON v.id_product = bp.product_id").
Order("bp.product_id DESC")
query = query.Scopes(filt.All()...)

View File

@@ -7,7 +7,11 @@ import (
"net/http"
"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"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"github.com/WinterYukky/gorm-extra-clause-plugin/exclause"
)
type SearchProxyResponse struct {
@@ -17,6 +21,7 @@ type SearchProxyResponse struct {
type UISearchRepo interface {
Search(index string, body []byte) (*SearchProxyResponse, error)
GetMeiliProducts(id_lang uint, offset, limit int) ([]model.MeiliSearchProduct, error)
GetIndexSettings(index string) (*SearchProxyResponse, error)
GetRoutes(langId uint) ([]model.Route, error)
}
@@ -80,3 +85,108 @@ func (r *SearchRepo) doRequest(method, url string, body []byte) (*SearchProxyRes
func (r *SearchRepo) GetRoutes(langId uint) ([]model.Route, error) {
return nil, nil
}
// GetMeiliProductsProducts returns a batch of products with LIMIT/OFFSET pagination
// The scanning is done inside the repo to keep the service layer cleaner
func (r *SearchRepo) GetMeiliProducts(id_lang uint, offset, limit int) ([]model.MeiliSearchProduct, error) {
var products []model.MeiliSearchProduct
err := db.Get().
Table("ps_product_shop ps").
Select(`
ps.id_product AS id_product,
pl.name AS name,
TRIM(REGEXP_REPLACE(REGEXP_REPLACE(pl.description_short, '<[^>]*>', ' '), '[[:space:]]+', ' ')) AS description,
p.ean13,
p.reference,
ps.price,
ps.id_category_default AS id_category,
cl.name AS cat_name,
cl.link_rewrite AS l_rew,
COALESCE(vary.attributes, JSON_OBJECT()) AS attr,
COALESCE(feat.features, JSON_OBJECT()) AS feat,
img.id_image,
cat.category_ids,
(SELECT COUNT(*) FROM ps_product_attribute_shop pas2 WHERE pas2.id_product = ps.id_product AND pas2.id_shop = ?) AS variations
`, constdata.SHOP_ID).
Joins("JOIN ps_product p ON p.id_product = ps.id_product").
Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_shop = ? AND pl.id_lang = ?", constdata.SHOP_ID, id_lang).
Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_shop = ? AND cl.id_lang = ?", constdata.SHOP_ID, id_lang).
Joins("LEFT JOIN variations vary ON vary.id_product = ps.id_product").
Joins("LEFT JOIN features feat ON feat.id_product = ps.id_product").
Joins("LEFT JOIN images img ON img.id_product = ps.id_product").
Joins("LEFT JOIN categories cat ON cat.id_product = ps.id_product").
Joins("JOIN products_page pp ON pp.id_product = ps.id_product").
Where("ps.active = ?", 1).
Order("ps.id_product").
Clauses(exclause.With{CTEs: []exclause.CTE{
{
Name: "products_page",
Subquery: exclause.Subquery{
DB: db.Get().
Model(&dbmodel.PsProductShop{}).
Select("id_product, price").
Where("id_shop = ? AND active = 1", constdata.SHOP_ID).
Order("id_product").
Limit(limit).
Offset(offset),
},
},
{
Name: "variation_attributes",
Subquery: exclause.Subquery{
DB: db.Get().
Table("ps_product_attribute_shop pas"). // <- explicit alias here
Select(`
pas.id_product,
pag.id_attribute_group AS attribute_name,
JSON_ARRAYAGG(DISTINCT pa.id_attribute) AS attribute_values
`).
Joins("JOIN ps_product_attribute_combination ppac ON ppac.id_product_attribute = pas.id_product_attribute").
Joins("JOIN ps_attribute pa ON pa.id_attribute = ppac.id_attribute").
Joins("JOIN ps_attribute_group pag ON pag.id_attribute_group = pa.id_attribute_group").
Where("pas.id_shop = ?", constdata.SHOP_ID).
Group("pas.id_product, pag.id_attribute_group"),
},
},
{
Name: "variations",
Subquery: exclause.Subquery{
DB: db.Get().
Table("variation_attributes").
Select("id_product, JSON_OBJECTAGG(attribute_name, attribute_values) AS attributes").
Group("id_product"),
},
},
{
Name: "features",
Subquery: exclause.Subquery{
DB: db.Get().
Table("ps_feature_product pfp"). // <- explicit alias
Select("pfp.id_product, JSON_OBJECTAGG(pfp.id_feature, pfp.id_feature_value) AS features").
Group("pfp.id_product"),
},
},
{
Name: "images",
Subquery: exclause.Subquery{
DB: db.Get().
Model(&dbmodel.PsImageShop{}).
Select("id_product, id_image").
Where("id_shop = ? AND cover = 1", constdata.SHOP_ID),
},
},
{
Name: "categories",
Subquery: exclause.Subquery{
DB: db.Get().
Model(&dbmodel.PsCategoryProduct{}).
Select("id_product, JSON_ARRAYAGG(id_category) AS category_ids").
Group("id_product"),
},
},
}}).Find(&products).Error
return products, err
}

View File

@@ -6,7 +6,7 @@ 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/repos/productDescriptionRepo"
searchrepo "git.ma-al.com/goc_daniel/b2b/app/repos/searchRepo"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"github.com/meilisearch/meilisearch-go"
)
@@ -20,7 +20,7 @@ type MeiliIndexSettings struct {
}
type MeiliService struct {
productDescriptionRepo productDescriptionRepo.UIProductDescriptionRepo
searchRepo searchrepo.UISearchRepo
meiliClient meilisearch.ServiceManager
}
@@ -33,7 +33,7 @@ func New() *MeiliService {
return &MeiliService{
meiliClient: client,
productDescriptionRepo: productDescriptionRepo.New(),
searchRepo: searchrepo.New(),
}
}
@@ -50,7 +50,7 @@ func (s *MeiliService) CreateIndex(id_lang uint) error {
for {
// Get batch of products from repo (includes scanning)
products, err := s.productDescriptionRepo.GetMeiliProducts(id_lang, offset, batchSize)
products, err := s.searchRepo.GetMeiliProducts(id_lang, offset, batchSize)
if err != nil {
return fmt.Errorf("failed to get products batch at offset %d: %w", offset, err)
}

View File

@@ -3,31 +3,45 @@ package menuService
import (
"slices"
"sort"
"strconv"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/categoriesRepo"
categoryrepo "git.ma-al.com/goc_daniel/b2b/app/repos/categoryRepo"
routesRepo "git.ma-al.com/goc_daniel/b2b/app/repos/routesRepo"
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/responseErrors"
)
type MenuService struct {
categoriesRepo categoriesRepo.UICategoriesRepo
categoryRepo categoryrepo.UICategoryRepo
routesRepo routesRepo.UIRoutesRepo
}
func New() *MenuService {
return &MenuService{
categoriesRepo: categoriesRepo.New(),
categoryRepo: categoryrepo.New(),
routesRepo: routesRepo.New(),
}
}
func (s *MenuService) GetCategoryTree(root_category_id uint, id_lang uint) (*model.Category, error) {
all_categories, err := s.categoriesRepo.GetAllCategories(id_lang)
all_categories, err := s.categoryRepo.RetrieveMenuCategories(id_lang)
if err != nil {
return &model.Category{}, err
}
// remove blacklisted categories
// to do so, we detach them from the main tree
for i := 0; i < len(all_categories); i++ {
if slices.Contains(constdata.CATEGORY_BLACKLIST, all_categories[i].CategoryID) {
all_categories[i].ParentID = all_categories[i].CategoryID
}
}
iso_code := all_categories[0].IsoCode
s.appendAdditional(&all_categories, id_lang, iso_code)
// find the root
root_index := 0
root_found := false
@@ -98,7 +112,7 @@ func (s *MenuService) scannedToNormalCategory(scanned model.ScannedCategory) mod
normal.CategoryID = scanned.CategoryID
normal.Label = scanned.Name
// normal.Active = scanned.Active == 1
normal.Params = model.CategoryParams{CategoryID: normal.CategoryID, LinkRewrite: scanned.LinkRewrite, Locale: scanned.IsoCode}
normal.Params = model.CategoryParams{CategoryID: normal.CategoryID, LinkRewrite: scanned.LinkRewrite, Locale: scanned.IsoCode, Filter: scanned.Filter}
normal.Children = []model.Category{}
return normal
}
@@ -114,11 +128,14 @@ 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) GetBreadcrumb(root_category_id uint, start_category_id uint, id_lang uint) ([]model.CategoryInBreadcrumb, error) {
all_categories, err := s.categoriesRepo.GetAllCategories(id_lang)
all_categories, err := s.categoryRepo.RetrieveMenuCategories(id_lang)
if err != nil {
return []model.CategoryInBreadcrumb{}, err
}
iso_code := all_categories[0].IsoCode
s.appendAdditional(&all_categories, id_lang, iso_code)
breadcrumb := []model.CategoryInBreadcrumb{}
start_index := 0
@@ -211,3 +228,24 @@ func (s *MenuService) GetTopMenu(languageId uint, roleId uint) ([]*model.B2BTopM
return roots, nil
}
func (s *MenuService) appendAdditional(all_categories *[]model.ScannedCategory, id_lang uint, iso_code string) {
for i := 0; i < len(*all_categories); i++ {
(*all_categories)[i].Filter = "category_id_in=" + strconv.Itoa(int((*all_categories)[i].CategoryID))
}
var additional model.ScannedCategory
additional.CategoryID = 10001
additional.Name = "New Products"
additional.Active = 1
additional.Position = 10
additional.ParentID = 2
additional.IsRoot = 0
additional.LinkRewrite = i18n.T___(id_lang, "category.new_products")
additional.IsoCode = iso_code
additional.Visited = false
additional.Filter = "is_new_in=true"
*all_categories = append(*all_categories, additional)
}

View File

@@ -4,6 +4,7 @@ import (
"strings"
"unicode"
"git.ma-al.com/goc_daniel/b2b/app/db"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"github.com/dlclark/regexp2"
"golang.org/x/text/runes"
@@ -22,7 +23,7 @@ func SanitizeSlug(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
// First apply explicit transliteration for language-specific letters.
s = transliterateWithTable(s)
s = transliterateSlug(s)
// Then normalize and strip any remaining combining marks.
s = removeDiacritics(s)
@@ -40,19 +41,17 @@ func SanitizeSlug(s string) string {
return s
}
func transliterateWithTable(s string) string {
var b strings.Builder
b.Grow(len(s))
func transliterateSlug(s string) string {
var cleared string
for _, r := range s {
if repl, ok := constdata.TRANSLITERATION_TABLE[r]; ok {
b.WriteString(repl)
} else {
b.WriteRune(r)
}
err := db.DB.Raw("SELECT slugify_eu(?)", s).Scan(&cleared).Error
if err != nil {
// log error
_ = err
return s
}
return b.String()
return cleared
}
func removeDiacritics(s string) string {

View File

@@ -10,6 +10,9 @@ const ADMIN_NOTIFICATION_LANGUAGE = 2
// CATEGORY_TREE_ROOT_ID corresponds to id_category in ps_category which has is_root_category=1
const CATEGORY_TREE_ROOT_ID = 2
// since arrays can not be const
var CATEGORY_BLACKLIST = []uint{250}
const MAX_AMOUNT_OF_CARTS_PER_USER = 10
const DEFAULT_NEW_CART_NAME = "new cart"
@@ -26,23 +29,3 @@ const WEBDAV_TRIMMED_ROOT = "localhost:3000/api/v1/webdav/storage"
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",
}

View File

@@ -22,14 +22,6 @@ func GetUserID(c fiber.Ctx) (uint, bool) {
return user_locale.User.ID, true
}
func GetOriginalUserRole(c fiber.Ctx) (model.Role, bool) {
user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale)
if !ok || user_locale.OriginalUser == nil || user_locale.OriginalUser.Role == nil {
return model.Role{}, false
}
return *user_locale.OriginalUser.Role, true
}
func GetCustomer(c fiber.Ctx) (*model.Customer, bool) {
user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale)
if !ok || user_locale.User == nil {

View File

@@ -5,7 +5,7 @@ info:
http:
method: GET
url: "{{bas_url}}/restricted/product/list?p=1&elems=30&sort=product_id,asc&reference=~NC100"
url: "{{bas_url}}/restricted/product/list?p=1&elems=30&reference=~NC100"
params:
- name: p
value: "1"
@@ -16,13 +16,22 @@ http:
- name: sort
value: product_id,asc
type: query
disabled: true
- name: category_id_in
value: "243"
value: "23"
type: query
disabled: true
- name: reference
value: ~NC100
type: query
- name: is_new_eq
value: "0"
type: query
disabled: true
- name: is_favorite_eq
value: "false"
type: query
disabled: true
body:
type: json
data: ""

View File

@@ -5,10 +5,10 @@ info:
http:
method: GET
url: http://localhost:3000/api/v1/restricted/menu/get-breadcrumb?root_category_id=10&category_id=13
url: http://localhost:3000/api/v1/restricted/menu/get-breadcrumb?root_category_id=2&category_id=13
params:
- name: root_category_id
value: "10"
value: "2"
type: query
- name: category_id
value: "13"

View File

@@ -5,10 +5,10 @@ info:
http:
method: GET
url: http://localhost:3000/api/v1/restricted/menu/get-category-tree?root_category_id=10
url: http://localhost:3000/api/v1/restricted/menu/get-category-tree?root_category_id=2
params:
- name: root_category_id
value: "10"
value: "2"
type: query
auth: inherit

View File

@@ -402,16 +402,26 @@ DELIMITER ;
-- +goose Down
DROP TABLE IF EXISTS b2b_countries;
DROP TABLE IF EXISTS b2b_language;
DROP TABLE IF EXISTS b2b_components;
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_addresses;
DROP TABLE IF EXISTS b2b_top_menu_roles;
DROP TABLE IF EXISTS b2b_favorites;
DROP TABLE IF EXISTS b2b_carts_products;
DROP TABLE IF EXISTS b2b_customer_carts;
DROP TABLE IF EXISTS b2b_specific_price_country;
DROP TABLE IF EXISTS b2b_specific_price_customer;
DROP TABLE IF EXISTS b2b_specific_price_product_attribute;
DROP TABLE IF EXISTS b2b_specific_price_category;
DROP TABLE IF EXISTS b2b_specific_price_product;
DROP TABLE IF EXISTS b2b_specific_price;
DROP TABLE IF EXISTS b2b_role_permissions;
DROP TABLE IF EXISTS b2b_permissions;
DROP TABLE IF EXISTS b2b_roles;
DROP TABLE IF EXISTS b2b_countries;
DROP TABLE IF EXISTS b2b_currency_rates;
DROP TABLE IF EXISTS b2b_currencies;
DROP TABLE IF EXISTS b2b_refresh_tokens;
DROP TABLE IF EXISTS b2b_customers;
DROP TABLE IF EXISTS b2b_translations;
DROP TABLE IF EXISTS b2b_scopes;
DROP TABLE IF EXISTS b2b_components;
DROP TABLE IF EXISTS b2b_language;

View File

@@ -35,15 +35,27 @@ 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_permissions` (`id`, `name`) VALUES ('5', 'specific_price.manage');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('6', 'webdav.create_token');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('7', 'product_translation.save');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('8', 'product_translation.translate');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('9', 'search.create_index');
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 ('2', '5');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '6');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '7');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '8');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '9');
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');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '5');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '6');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '7');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '8');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '9');
-- +goose Down