This commit is contained in:
2026-03-25 02:23:12 +01:00
parent c13365916c
commit 0f21ed1f81
6 changed files with 151 additions and 119 deletions

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "b2b API",
"description": "Authentication, user management, and repository time tracking API",
"description": "Authentication, user management, and repository time tracking API\n\n## Filter Operators\nAll filter parameters support the following operators by adding a suffix to the parameter name:\n\n| Suffix | Operator | Example |\n|--------|----------|---------|\n| `_eq` | equals | `product_id_eq=12` |\n| `_neq` | not equals | `active_neq=0` |\n| `_gt` | greater than | `price_gt=100` |\n| `_gte` | greater than or equal | `price_gte=10` |\n| `_lt` | less than | `price_lt=500` |\n| `_lte` | less than or equal | `price_lte=500` |\n| `_in` | IN (comma-separated) | `product_id_in=1,2,3,4` |\n\n## Special Filters\n\n| Parameter | Example | Description |\n|-----------|---------|-------------|\n| `name=~text` | `name=~gold` | LIKE search (case-insensitive partial match) |\n| `price=[min,max]` | `price=[100,500]` | BETWEEN range (inclusive) |\n| `sort=field,direction` | `sort=price,desc` | ORDER BY clause (direction: asc/desc, default: desc) |\n| `p` | `p=1` | Page number (1-based, default: 1) |\n| `elems` | `elems=30` | Elements per page (max: 100, default: 30) |",
"version": "1.0.0",
"contact": {
"name": "API Support",
@@ -1043,7 +1043,7 @@
"get": {
"tags": ["Products"],
"summary": "Get product listing",
"description": "Returns a paginated list of products with their basic information. Requires authentication.",
"description": "Returns a paginated list of products with their basic information. Supports filtering via query parameters with operators (e.g., product_id_eq=12, name=~gold). Use sort parameter for ordering. Requires authentication.",
"operationId": "getProductListing",
"security": [
{
@@ -1064,12 +1064,77 @@
{
"name": "elems",
"in": "query",
"description": "Number of items per page",
"description": "Number of items per page (max: 100, default: 30)",
"required": false,
"schema": {
"type": "integer",
"default": 30
}
},
{
"name": "sort",
"in": "query",
"description": "Sort field and direction. Format: field,direction (e.g., 'product_id,desc' or 'name,asc')",
"required": false,
"schema": {
"type": "string",
"example": "product_id,desc"
}
},
{
"name": "product_id",
"in": "query",
"description": "Filter by product ID. Use suffix _eq, _gt, _gte, _lt, _lte, _neq, _in for operators.",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "name",
"in": "query",
"description": "Filter by product name using LIKE (case-insensitive). Use ~ prefix for partial match (e.g., '~gold')",
"required": false,
"schema": {
"type": "string",
"example": "~gold"
}
},
{
"name": "reference",
"in": "query",
"description": "Filter by product reference using LIKE (case-insensitive)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "id_category",
"in": "query",
"description": "Filter by category ID. Use suffix _eq, _gt, _gte, _lt, _lte, _neq, _in for operators.",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "category_name",
"in": "query",
"description": "Filter by category name using LIKE (case-insensitive)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "quantity",
"in": "query",
"description": "Filter by quantity. Use suffix _eq, _gt, _gte, _lt, _lte for operators.",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {

View File

@@ -1,14 +1,11 @@
package restricted
import (
"fmt"
"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/listProductsService"
"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/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
"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"
@@ -38,66 +35,8 @@ func ListProductsHandlerRoutes(r fiber.Router) fiber.Router {
return r
}
// var columnMapping map[string]string = map[string]string{
// "product_id": "id",
// "price": "price_taxed",
// "name": "name",
// "category_id": "category_id",
// "feature_id": "feature_id",
// "feature": "feature_name",
// "value_id": "value_id",
// "value": "value_name",
// "status": "active_sale",
// "stock": "in_stock",
// }
// type FeatVal = map[uint][]uint
// func featureValueFilters(feats FeatVal) filters.Filter {
// filt := func(db *gorm.DB) *gorm.DB {
// return db.Where("value_id IN ?", lo.Flatten(lo.Values(feats))).Group("id").Having("COUNT(id) = ?", len(lo.Keys(feats)))
// }
// return filters.NewFilter(filters.FEAT_VAL_PRODUCT_FILTER, filt)
// }
// func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) {
// var p find.Paging
// fl := filters.NewFiltersList()
// productFilters := new(model.ProductFilters)
// fmt.Printf("fl: %v\n", fl)
// err := c.Bind().Query(productFilters)
// if err != nil {
// return p, &fl, err
// }
// if productFilters.Name != "" {
// fl.Append(filters.Where("name LIKE ?", fmt.Sprintf("%%%s%%", productFilters.Name)))
// }
// if productFilters.Sort != "" {
// ord, err := query_params.ParseOrdering[model.Product](c, columnMapping)
// if err != nil {
// return p, &fl, err
// }
// for _, o := range ord {
// fl.Append(filters.Order(o.Column, o.IsDesc))
// }
// }
// if len(productFilters.Features) > 0 {
// fl.Append(featureValueFilters(productFilters.Features))
// }
// fl.Append(query_params.ParseWhereScopes[model.Product](c, []string{"name"}, columnMapping)...)
// pageNum, pageElems := query_params.ParsePagination(c)
// p = find.Paging{Page: pageNum, Elements: pageElems}
// return p, &fl, nil
// }
func (h *ListProductsHandler) GetListing(c fiber.Ctx) error {
paging, filters, err := ParseProductFilters(c)
paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingGetListing)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
@@ -118,16 +57,11 @@ func (h *ListProductsHandler) GetListing(c fiber.Ctx) error {
return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK)))
}
var columnMapping map[string]string = map[string]string{}
func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) {
var p find.Paging
fl := filters.NewFiltersList()
fmt.Printf("fl: %v\n", fl.All())
pageNum, pageElems := query_params.ParsePagination(c)
p = find.Paging{Page: pageNum, Elements: pageElems}
return p, &fl, nil
var columnMappingGetListing map[string]string = map[string]string{
"product_id": "ps.id_product",
"name": "pl.name",
"reference": "ps.reference",
"category_name": "cl.name",
"id_category": "cp.id_category",
"quantity": "sa.quantity",
}

View File

@@ -62,11 +62,14 @@ type Product struct {
DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"`
}
type ProductInList struct {
ProductID uint `gorm:"column:product_id;primaryKey" json:"product_id" form:"product_id"`
ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"`
Name string `gorm:"column:name" json:"name" form:"name"`
ImageID string `gorm:"column:id_image"`
LinkRewrite string `gorm:"column:link_rewrite"`
Active uint `gorm:"column:active" json:"active" form:"active"`
LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"`
ImageLink string `gorm:"column:image_link" json:"image_link"`
CategoryName string `gorm:"column:category_name" json:"category_name" form:"category_name"`
Reference string `gorm:"column:reference" json:"reference"`
VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"`
Quantity uint `gorm:"column:quantity" json:"quantity"`
}
type ProductFilters struct {

View File

@@ -1,10 +1,8 @@
package listProductsRepo
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"
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"
)
@@ -23,43 +21,44 @@ func (repo *ListProductsRepo) GetListing(id_lang uint, p find.Paging, filt *filt
var listing []model.ProductInList
var total int64
subQuery := db.DB.
Table("ps_image").
Select("id_product, MIN(id_image) AS id_image").
Group("id_product")
err := db.DB.
Table("ps_product").
query := db.Get().
Table("ps_product_shop AS ps").
Select(`
ps_product.id_product AS product_id,
ps_product_lang.name AS name,
ps_product.active AS active,
ps_product_lang.link_rewrite AS link_rewrite,
COALESCE(CONCAT( ?, '/', ps_image_shop.id_image, '-small_default/', ps_product_lang.link_rewrite, '.webp'), CONCAT( ?, '/', any_image.id_image, '-small_default/', ps_product_lang.link_rewrite, '.webp')) AS id_image
`, config.Get().Image.ImagePrefix, config.Get().Image.ImagePrefix).
Joins(`
LEFT JOIN ps_product_lang
ON ps_product_lang.id_product = ps_product.id_product
AND ps_product_lang.id_shop = ?
AND ps_product_lang.id_lang = ?
`, constdata.SHOP_ID, id_lang).
Joins(`
LEFT JOIN ps_image_shop
ON ps_image_shop.id_product = ps_product.id_product
AND ps_image_shop.id_shop = ?
AND ps_image_shop.cover = 1
`, constdata.SHOP_ID).
Joins("LEFT JOIN (?) AS any_image ON ps_product.id_product = any_image.id_product", subQuery).
Limit(p.Limit()).
Offset(p.Offset()).
Scan(&listing).Error
ps.id_product AS product_id,
pl.name AS name,
pl.link_rewrite AS link_rewrite,
CONCAT('https://www.naluconcept.com', '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link,
cl.name AS category_name,
p.reference AS reference,
COUNT(DISTINCT pas.id_product_attribute) AS variants_number,
sa.quantity AS quantity
`).
Joins("JOIN ps_product p ON p.id_product = ps.id_product").
Joins("JOIN ps_category_product cp ON ps.id_product = cp.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("LEFT JOIN ps_product_attribute_shop pas ON pas.id_product = cp.id_product").
Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product").
Where("ps.active = ?", 1).
Group("cp.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 = db.DB.
Table("ps_product").
Count(&total).Error
err = query.
Order("ps.id_product DESC").
Limit(p.Limit()).
Offset(p.Offset()).
Scan(&listing).Error
if err != nil {
return find.Found[model.ProductInList]{}, err
}

View File

@@ -55,7 +55,9 @@ func WhereFromStrings(column, conditionOperator, value string) Filter {
value = strings.ReplaceAll(value, "~", "")
filt = func(d *gorm.DB) *gorm.DB {
return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%")
// return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%")
// (jeśli masz collation case-insensitive, np. utf8mb4_general_ci)
return d.Where(column+` LIKE ?`, "%"+value+"%")
}
@@ -65,6 +67,33 @@ func WhereFromStrings(column, conditionOperator, value string) Filter {
}
}
// Handle IN operator for comma-separated values (e.g., product_id_in=1,2,3,4)
if conditionOperator == "IN" {
parts := strings.Split(value, ",")
var values []interface{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// Try to parse as int first
if i, err := strconv.ParseInt(p, 10, 64); err == nil {
values = append(values, i)
} else if f, err := strconv.ParseFloat(p, 64); err == nil {
values = append(values, f)
} else {
values = append(values, p)
}
}
filt = func(d *gorm.DB) *gorm.DB {
return d.Where(column+" IN ?", values)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
if strings.Contains(value, "]") && strings.Contains(value, "[") {
period := strings.ReplaceAll(value, "[", "")
period = strings.ReplaceAll(period, "]", "")

View File

@@ -46,7 +46,7 @@ func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMappin
}
func extractOperator(key string) (base string, operatorSuffix string) {
suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq"}
suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq", "_in"}
for _, suf := range suffixes {
if strings.HasSuffix(key, suf) {
return strings.TrimSuffix(key, suf), suf[1:]
@@ -69,6 +69,8 @@ func resolveOperator(suffix string) string {
return "!="
case "eq":
return "="
case "in":
return "IN"
default:
return "LIKE"
}