diff --git a/app/api/openapi.json b/app/api/openapi.json index caf17fd..398bb8b 100644 --- a/app/api/openapi.json +++ b/app/api/openapi.json @@ -1127,21 +1127,32 @@ } } }, - "/api/v1/restricted/menu/get-menu": { + "/api/v1/restricted/menu/get-category-tree": { "get": { "tags": ["Menu"], - "summary": "Get menu structure", - "description": "Returns the menu structure for the current language. Requires authentication.", - "operationId": "getMenu", + "summary": "Get category tree", + "description": "Returns the category tree rooted at the given category ID for the current language. Requires authentication.", + "operationId": "getCategoryTree", "security": [ { "CookieAuth": [], "BearerAuth": [] } ], + "parameters": [ + { + "name": "root_category_id", + "in": "query", + "description": "Root category ID to build the tree from", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { - "description": "Menu retrieved successfully", + "description": "Category tree retrieved successfully", "content": { "application/json": { "schema": { @@ -1151,7 +1162,73 @@ } }, "400": { - "description": "Invalid request", + "description": "Invalid request or root category not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/restricted/menu/get-breadcrumb": { + "get": { + "tags": ["Menu"], + "summary": "Get breadcrumb", + "description": "Returns the breadcrumb path from the root category to the specified category for the current language. Requires authentication.", + "operationId": "getBreadcrumb", + "security": [ + { + "CookieAuth": [], + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "root_category_id", + "in": "query", + "description": "Root category ID (breadcrumb starting point)", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "category_id", + "in": "query", + "description": "Target category ID (breadcrumb destination)", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Breadcrumb retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "Invalid request, category not found, or root never reached", "content": { "application/json": { "schema": { @@ -1221,7 +1298,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse" + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/B2BTopMenu" + }, + "description": "Root menu items with nested children" + }, + "count": { + "type": "integer", + "description": "Number of root menu items" + } + } } } } @@ -1995,46 +2088,6 @@ } } }, - "MenuItem": { - "type": "object", - "description": "Menu item structure", - "properties": { - "category_id": { - "type": "integer", - "format": "uint", - "description": "Category ID" - }, - "label": { - "type": "string", - "description": "Menu item label" - }, - "params": { - "$ref": "#/components/schemas/MenuItemParams" - }, - "children": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MenuItem" - }, - "description": "Child menu items" - } - } - }, - "MenuItemParams": { - "type": "object", - "properties": { - "category_id": { - "type": "integer", - "format": "uint" - }, - "link_rewrite": { - "type": "string" - }, - "locale": { - "type": "string" - } - } - }, "Route": { "type": "object", "description": "Application route", @@ -2338,6 +2391,58 @@ "description": "Build date in RFC3339 format" } } + }, + "CategoryInBreadcrumb": { + "type": "object", + "description": "A single item in a category breadcrumb path", + "properties": { + "category_id": { + "type": "integer", + "format": "uint", + "description": "Category ID" + }, + "name": { + "type": "string", + "description": "Category name" + } + } + }, + "B2BTopMenu": { + "type": "object", + "description": "Top-level menu item for B2B back-office", + "properties": { + "menu_id": { + "type": "integer", + "description": "Menu item ID" + }, + "label": { + "type": "object", + "description": "Menu label as JSON (multilingual, e.g. {\"en\": \"Dashboard\", \"pl\": \"Panel\"})" + }, + "parent_id": { + "type": "integer", + "description": "Parent menu ID (null for root items)" + }, + "params": { + "type": "object", + "description": "Menu item parameters as JSON" + }, + "active": { + "type": "integer", + "description": "Active status (1 = active, 0 = inactive)" + }, + "position": { + "type": "integer", + "description": "Sort position" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/B2BTopMenu" + }, + "description": "Child menu items" + } + } } }, "securitySchemes": { diff --git a/app/delivery/web/api/restricted/menu.go b/app/delivery/web/api/restricted/menu.go index cee5673..b269fb7 100644 --- a/app/delivery/web/api/restricted/menu.go +++ b/app/delivery/web/api/restricted/menu.go @@ -26,6 +26,7 @@ func MenuHandlerRoutes(r fiber.Router) fiber.Router { handler := NewMenuHandler() r.Get("/get-category-tree", handler.GetCategoryTree) + r.Get("/get-breadcrumb", handler.GetBreadcrumb) r.Get("/get-top-menu", handler.GetTopMenu) return r @@ -54,6 +55,36 @@ func (h *MenuHandler) GetCategoryTree(c fiber.Ctx) error { return c.JSON(response.Make(&category_tree, 0, i18n.T_(c, response.Message_OK))) } +func (h *MenuHandler) GetBreadcrumb(c fiber.Ctx) error { + lang_id, ok := c.Locals("langID").(uint) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + root_category_id_attribute := c.Query("root_category_id") + root_category_id, err := strconv.Atoi(root_category_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + category_id_attribute := c.Query("category_id") + category_id, err := strconv.Atoi(category_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + breadcrumb, err := h.menuService.GetBreadcrumb(uint(root_category_id), uint(category_id), lang_id) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&breadcrumb, 0, i18n.T_(c, response.Message_OK))) +} + func (h *MenuHandler) GetTopMenu(c fiber.Ctx) error { lang_id, ok := c.Locals("langID").(uint) if !ok { diff --git a/app/delivery/web/api/settings.go b/app/delivery/web/api/settings.go index eaa4acc..fb7af6c 100644 --- a/app/delivery/web/api/settings.go +++ b/app/delivery/web/api/settings.go @@ -21,10 +21,12 @@ type SettingsResponse struct { // AppSettings represents app configuration type AppSettings struct { - Name string `json:"name"` - Environment string `json:"environment"` - BaseURL string `json:"base_url"` - PasswordRegex string `json:"password_regex"` + Name string `json:"name"` + Environment string `json:"environment"` + BaseURL string `json:"base_url"` + PasswordRegex string `json:"password_regex"` + CategoryTreeRootID uint `json:"category_tree_root_id"` + ShopDefaultLanguage uint `json:"shop_default_language"` // Config config.Config `json:"config"` } @@ -65,10 +67,12 @@ func (h *SettingsHandler) GetSettings(cfg *config.Config) fiber.Handler { return func(c fiber.Ctx) error { settings := SettingsResponse{ App: AppSettings{ - Name: cfg.App.Name, - Environment: cfg.App.Environment, - BaseURL: cfg.App.BaseURL, - PasswordRegex: constdata.PASSWORD_VALIDATION_REGEX, + Name: cfg.App.Name, + Environment: cfg.App.Environment, + BaseURL: cfg.App.BaseURL, + PasswordRegex: constdata.PASSWORD_VALIDATION_REGEX, + CategoryTreeRootID: constdata.CATEGORY_TREE_ROOT_ID, + ShopDefaultLanguage: constdata.SHOP_DEFAULT_LANGUAGE, // Config: *config.Get(), }, Server: ServerSettings{ diff --git a/app/model/category.go b/app/model/category.go new file mode 100644 index 0000000..50e6ce9 --- /dev/null +++ b/app/model/category.go @@ -0,0 +1,33 @@ +package model + +type ScannedCategory struct { + CategoryID uint `gorm:"column:category_id;primaryKey"` + Name string `gorm:"column:name"` + Active uint `gorm:"column:active"` + Position uint `gorm:"column:position"` + ParentID uint `gorm:"column:id_parent"` + IsRoot uint `gorm:"column:is_root_category"` + LinkRewrite string `gorm:"column:link_rewrite"` + IsoCode string `gorm:"column:iso_code"` + + Visited bool //this is for internal backend use only +} + +type Category struct { + CategoryID uint `json:"category_id" form:"category_id"` + Label string `json:"label" form:"label"` + // Active bool `json:"active" form:"active"` + Params CategoryParams `json:"params" form:"params"` + Children []Category `json:"children" form:"children"` +} + +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"` +} + +type CategoryInBreadcrumb struct { + CategoryID uint `json:"category_id" form:"category_id"` + Name string `json:"name" form:"name"` +} diff --git a/app/model/product.go b/app/model/product.go index 51a646b..fa47790 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -84,30 +84,4 @@ type ProductFilters struct { InStock uint `query:"stock,omitempty"` } -type ScannedCategory struct { - CategoryID uint `gorm:"column:category_id;primaryKey"` - Name string `gorm:"column:name"` - Active uint `gorm:"column:active"` - Position uint `gorm:"column:position"` - ParentID uint `gorm:"column:id_parent"` - IsRoot uint `gorm:"column:is_root_category"` - LinkRewrite string `gorm:"column:link_rewrite"` - IsoCode string `gorm:"column:iso_code"` - - Visited bool //this is for internal backend use only -} -type Category struct { - CategoryID uint `json:"category_id" form:"category_id"` - Label string `json:"label" form:"label"` - // Active bool `json:"active" form:"active"` - Params CategoryParams `json:"params" form:"params"` - Children []Category `json:"children" form:"children"` -} - -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"` -} - type FeatVal = map[uint][]uint diff --git a/app/model/productDescription.go b/app/model/productDescription.go index cb84fc8..3e7d5c1 100644 --- a/app/model/productDescription.go +++ b/app/model/productDescription.go @@ -19,6 +19,8 @@ type ProductDescription struct { 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"` + + ExistsInDatabse bool `gorm:"-" json:"exists_in_database"` } type ProductRow struct { diff --git a/app/repos/productDescriptionRepo/productDescriptionRepo.go b/app/repos/productDescriptionRepo/productDescriptionRepo.go index 76463cd..d569a75 100644 --- a/app/repos/productDescriptionRepo/productDescriptionRepo.go +++ b/app/repos/productDescriptionRepo/productDescriptionRepo.go @@ -1,6 +1,7 @@ package productDescriptionRepo import ( + "errors" "fmt" "git.ma-al.com/goc_daniel/b2b/app/db" @@ -8,6 +9,7 @@ import ( "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" ) type UIProductDescriptionRepo interface { @@ -35,8 +37,14 @@ func (r *ProductDescriptionRepo) GetProductDescription(productID uint, productid IDLang: int32(productid_lang), }). First(&ProductDescription).Error - if err != nil { + + if errors.Is(err, gorm.ErrRecordNotFound) { + // handle "not found" case only + ProductDescription.ExistsInDatabse = false + } else if err != nil { return nil, fmt.Errorf("database error: %w", err) + } else { + ProductDescription.ExistsInDatabse = true } return &ProductDescription, nil diff --git a/app/service/menuService/menuService.go b/app/service/menuService/menuService.go index 4e4b100..2d72cea 100644 --- a/app/service/menuService/menuService.go +++ b/app/service/menuService/menuService.go @@ -1,6 +1,7 @@ package menuService import ( + "slices" "sort" "git.ma-al.com/goc_daniel/b2b/app/model" @@ -112,6 +113,69 @@ 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) GetBreadcrumb(root_category_id uint, start_category_id uint, id_lang uint) ([]model.CategoryInBreadcrumb, error) { + all_categories, err := s.categoriesRepo.GetAllCategories(id_lang) + if err != nil { + return []model.CategoryInBreadcrumb{}, err + } + + breadcrumb := []model.CategoryInBreadcrumb{} + + start_index := 0 + start_found := false + for i := 0; i < len(all_categories); i++ { + if all_categories[i].CategoryID == start_category_id { + start_index = i + start_found = true + break + } + } + if !start_found { + return []model.CategoryInBreadcrumb{}, responseErrors.ErrStartCategoryNotFound + } + + // map category ids to indices + id_to_index := make(map[uint]int) + for i := 0; i < len(all_categories); i++ { + all_categories[i].Visited = false + id_to_index[all_categories[i].CategoryID] = i + } + + // do a simple graph traversal, always jumping from node to its parent + index := start_index + success := true + for { + if all_categories[index].Visited { + success = false + break + } + all_categories[index].Visited = true + + var next_category model.CategoryInBreadcrumb + next_category.CategoryID = all_categories[index].CategoryID + next_category.Name = all_categories[index].Name + breadcrumb = append(breadcrumb, next_category) + + if all_categories[index].CategoryID == root_category_id { + break + } + next_index, ok := id_to_index[all_categories[index].ParentID] + if !ok { + success = false + break + } + index = next_index + } + + slices.Reverse(breadcrumb) + + if !success { + return breadcrumb, responseErrors.ErrRootNeverReached + } + + return breadcrumb, nil +} + func (s *MenuService) GetTopMenu(id uint) ([]*model.B2BTopMenu, error) { items, err := s.routesRepo.GetTopMenu(id) if err != nil { diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index 812364f..9c64ee5 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -3,6 +3,11 @@ package constdata // PASSWORD_VALIDATION_REGEX is used by the frontend (JavaScript supports lookaheads). const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$` const SHOP_ID = 1 +const SHOP_DEFAULT_LANGUAGE = 1 + +// CATEGORY_TREE_ROOT_ID corresponds to id_category in ps_category which has is_root_category=1 +const CATEGORY_TREE_ROOT_ID = 2 + const MAX_AMOUNT_OF_CARTS_PER_USER = 10 const DEFAULT_NEW_CART_NAME = "new cart" diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 2656a3b..c4247ea 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -50,8 +50,10 @@ var ( ErrBadPaging = errors.New("bad or missing paging attribute value in header") // Typed errors for menu handler - ErrNoRootFound = errors.New("no root found in categories table") - ErrCircularDependency = errors.New("circular dependency structure in tree (could be caused by improper root id)") + ErrNoRootFound = errors.New("no root found in categories table") + ErrCircularDependency = errors.New("circular dependency structure in tree (could be caused by improper root id)") + ErrStartCategoryNotFound = errors.New("the start category has not been found") + ErrRootNeverReached = errors.New("the root category is not an ancestor of start category") // Typed errors for carts handler ErrMaxAmtOfCartsReached = errors.New("maximal amount of carts reached") @@ -148,6 +150,10 @@ func GetErrorCode(c fiber.Ctx, err error) string { return i18n.T_(c, "error.no_root_found") case errors.Is(err, ErrCircularDependency): return i18n.T_(c, "error.circular_dependency") + case errors.Is(err, ErrStartCategoryNotFound): + return i18n.T_(c, "error.start_category_not_found") + case errors.Is(err, ErrRootNeverReached): + return i18n.T_(c, "error.root_never_reached") case errors.Is(err, ErrMaxAmtOfCartsReached): return i18n.T_(c, "error.max_amt_of_carts_reached") @@ -193,6 +199,8 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrBadPaging), errors.Is(err, ErrNoRootFound), errors.Is(err, ErrCircularDependency), + errors.Is(err, ErrStartCategoryNotFound), + errors.Is(err, ErrRootNeverReached), errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrUserHasNoSuchCart), errors.Is(err, ErrProductOrItsVariationDoesNotExist): diff --git a/bruno/b2b-daniel/get-breadcrumb.yml b/bruno/b2b-daniel/get-breadcrumb.yml new file mode 100644 index 0000000..8b10c00 --- /dev/null +++ b/bruno/b2b-daniel/get-breadcrumb.yml @@ -0,0 +1,22 @@ +info: + name: get-breadcrumb + type: http + seq: 18 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/menu/get-breadcrumb?root_category_id=10&category_id=13 + params: + - name: root_category_id + value: "10" + type: query + - name: category_id + value: "13" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/get-category-tree.yml b/bruno/b2b-daniel/get-category-tree.yml index 6073e4f..c6b436e 100644 --- a/bruno/b2b-daniel/get-category-tree.yml +++ b/bruno/b2b-daniel/get-category-tree.yml @@ -5,10 +5,10 @@ info: http: method: GET - url: http://localhost:3000/api/v1/restricted/menu/get-category-tree?root_category_id=3 + url: http://localhost:3000/api/v1/restricted/menu/get-category-tree?root_category_id=10 params: - name: root_category_id - value: "3" + value: "10" type: query auth: inherit diff --git a/bruno/b2b-daniel/get-product-description.yml b/bruno/b2b-daniel/get-product-description.yml index 23e3c8e..326b790 100644 --- a/bruno/b2b-daniel/get-product-description.yml +++ b/bruno/b2b-daniel/get-product-description.yml @@ -5,13 +5,13 @@ info: http: method: GET - url: http://localhost:3000/api/v1/restricted/product-translation/get-product-description?productID=51&productLangID=2 + url: http://localhost:3000/api/v1/restricted/product-translation/get-product-description?productID=51&productLangID=4 params: - name: productID value: "51" type: query - name: productLangID - value: "2" + value: "4" type: query auth: inherit