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/middleware/auth.go b/app/delivery/middleware/auth.go index 8d5a906..b8837c4 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -79,7 +79,7 @@ func AuthMiddleware() fiber.Handler { } // We now populate the target user - if user.Role != model.RoleAdmin { + if model.CustomerRole(user.Role.Name) != 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..7528921 --- /dev/null +++ b/app/delivery/middleware/perms/permissions.go @@ -0,0 +1,10 @@ +package perms + +type Permission string + +const ( + UserReadAny Permission = "user.read.any" + UserWriteAny Permission = "user.write.any" + UserDeleteAny Permission = "user.delete.any" + CurrencyWrite Permission = "currency.write" +) diff --git a/app/delivery/web/api/restricted/currency.go b/app/delivery/web/api/restricted/currency.go new file mode 100644 index 0000000..d3bda40 --- /dev/null +++ b/app/delivery/web/api/restricted/currency.go @@ -0,0 +1,70 @@ +package restricted + +import ( + "strconv" + + "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/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", middleware.Require(perms.CurrencyWrite), handler.PostCurrencyRate) + r.Get("/currency-rate/:id", handler.GetCurrencyRate) + 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))) + } + + return c.JSON(response.Make(currency, 0, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go new file mode 100644 index 0000000..6e1a41c --- /dev/null +++ b/app/delivery/web/api/restricted/customer.go @@ -0,0 +1,111 @@ +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/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" +) + +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) + 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 != "" { + id, err := strconv.ParseUint(customerIdStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + 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 { + customerId = user.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))) +} + +func (h *customerHandler) listCustomers(fc fiber.Ctx) error { + 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))) + } + + 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))) + } + + 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", + "last_name": "users.last_name", +} diff --git a/app/delivery/web/api/restricted/menu.go b/app/delivery/web/api/restricted/menu.go index 4ed8300..8114e9c 100644 --- a/app/delivery/web/api/restricted/menu.go +++ b/app/delivery/web/api/restricted/menu.go @@ -87,12 +87,12 @@ func (h *MenuHandler) GetBreadcrumb(c fiber.Ctx) error { } func (h *MenuHandler) GetTopMenu(c fiber.Ctx) error { - lang_id, ok := localeExtractor.GetLangID(c) - if !ok { + customer, ok := localeExtractor.GetCustomer(c) + if !ok || customer == nil { 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(customer.LangID, customer.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/list.go b/app/delivery/web/api/restricted/product.go similarity index 55% rename from app/delivery/web/api/restricted/list.go rename to app/delivery/web/api/restricted/product.go index c6b3116..ddd8677 100644 --- a/app/delivery/web/api/restricted/list.go +++ b/app/delivery/web/api/restricted/product.go @@ -1,9 +1,11 @@ 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/listService" + "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" @@ -13,31 +15,69 @@ import ( "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 +type ProductsHandler struct { + productService *productService.ProductService + config *config.Config } -// NewListHandler creates a new ListHandler instance -func NewListHandler() *ListHandler { - listService := listService.New() - return &ListHandler{ - listService: listService, - config: config.Get(), +// NewListProductsHandler creates a new ListProductsHandler instance +func NewProductsHandler() *ProductsHandler { + productService := productService.New() + return &ProductsHandler{ + productService: productService, + config: config.Get(), } } -func ListHandlerRoutes(r fiber.Router) fiber.Router { - handler := NewListHandler() +func ProductsHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewProductsHandler() - r.Get("/list-products", handler.ListProducts) - r.Get("/list-users", handler.ListUsers) + r.Get("/:id/:country_id/:quantity", handler.GetProductJson) + r.Get("/list", handler.ListProducts) return r } -func (h *ListHandler) ListProducts(c fiber.Ctx) error { +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))) + } + + customer, ok := localeExtractor.GetCustomer(c) + if !ok || customer == nil { + 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, int(customer.LangID), int(customer.ID), 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))) +} + +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)). @@ -50,7 +90,7 @@ func (h *ListHandler) ListProducts(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - list, err := h.listService.ListProducts(id_lang, paging, filters) + 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))) @@ -67,33 +107,3 @@ var columnMappingListProducts map[string]string = map[string]string{ "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/init.go b/app/delivery/web/init.go index 2139073..51d9f51 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -105,13 +105,15 @@ 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) - // lists of things routes (restricted) - list := s.restricted.Group("/list") - restricted.ListHandlerRoutes(list) + product := s.restricted.Group("/product") + restricted.ProductsHandlerRoutes(product) // locale selector (restricted) // this is basically for changing user's selected language and country @@ -136,6 +138,8 @@ func (s *Server) Setup() error { restricted.StorageHandlerRoutes(restrictedStorage) webdav.StorageHandlerRoutes(webdavStorage) + restricted.CurrencyHandlerRoutes(s.restricted) + s.api.All("*", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNotFound) }) 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/customer.go b/app/model/customer.go index 60164ae..f79d282 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,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"` @@ -34,13 +36,14 @@ 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" -) +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 @@ -55,16 +58,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 == "" { @@ -75,13 +68,24 @@ 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 } type UserLocale struct { @@ -95,16 +99,29 @@ type UserLocale struct { // 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"` @@ -162,5 +179,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/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/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/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/model/role.go b/app/model/role.go new file mode 100644 index 0000000..2ea0789 --- /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:"permissions"` +} + +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 new file mode 100644 index 0000000..9cc153c --- /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.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/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go new file mode 100644 index 0000000..18dea15 --- /dev/null +++ b/app/repos/customerRepo/customerRepo.go @@ -0,0 +1,197 @@ +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" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" +) + +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, search string) (*find.Found[model.UserInList], error) + Save(customer *model.Customer) error + Create(customer *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.Permissions"). + First(&customer, id). + 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, 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 + `) + + 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 +} + +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 + +// 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/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/repos/listRepo/listRepo.go b/app/repos/productsRepo/productsRepo.go similarity index 64% rename from app/repos/listRepo/listRepo.go rename to app/repos/productsRepo/productsRepo.go index d31ebda..341b348 100644 --- a/app/repos/listRepo/listRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -1,6 +1,9 @@ -package listRepo +package productsRepo 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" @@ -11,18 +14,39 @@ import ( "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 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 ListRepo struct{} +type ProductsRepo struct{} -func New() UIListRepo { - return &ListRepo{} +func New() UIProductsRepo { + return &ProductsRepo{} } -func (repo *ListRepo) ListProducts(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { +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(&productStr). + Error + + 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 +} + +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 @@ -52,7 +76,8 @@ func (repo *ListRepo) ListProducts(id_lang uint, p find.Paging, filt *filters.Fi 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 { @@ -66,7 +91,6 @@ func (repo *ListRepo) ListProducts(id_lang uint, p find.Paging, filt *filters.Fi } err = query. - Order("ps.id_product DESC"). Limit(p.Limit()). Offset(p.Offset()). Find(&list).Error @@ -79,43 +103,3 @@ func (repo *ListRepo) ListProducts(id_lang uint, p find.Paging, filt *filters.Fi 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/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/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 4b19a13..83b6b2f 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" @@ -23,29 +25,33 @@ 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 } // 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 { @@ -59,7 +65,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 } @@ -153,7 +159,6 @@ func (s *AuthService) Register(req *model.RegisterRequest) error { Password: string(hashedPassword), FirstName: req.FirstName, LastName: req.LastName, - Role: model.RoleUser, Provider: model.ProviderLocal, IsActive: false, EmailVerified: false, @@ -431,7 +436,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 } @@ -511,7 +516,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..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,16 +154,16 @@ 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, - Role: model.RoleUser, IsActive: true, EmailVerified: true, LangID: 2, // default is english 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) } @@ -170,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 } 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/customerService/customerService.go b/app/service/customerService/customerService.go new file mode 100644 index 0000000..bce463d --- /dev/null +++ b/app/service/customerService/customerService.go @@ -0,0 +1,26 @@ +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 { + 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) +} + +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/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/menuService/menuService.go b/app/service/menuService/menuService.go index 2d72cea..de2498d 100644 --- a/app/service/menuService/menuService.go +++ b/app/service/menuService/menuService.go @@ -176,8 +176,8 @@ func (s *MenuService) GetBreadcrumb(root_category_id uint, start_category_id uin return breadcrumb, nil } -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/service/productService/productService.go b/app/service/productService/productService.go new file mode 100644 index 0000000..1a1620e --- /dev/null +++ b/app/service/productService/productService.go @@ -0,0 +1,34 @@ +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 { + 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) (*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 + } + + 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/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 f71ed51..1ed8a7c 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -18,3 +18,28 @@ const USER_LOCALE = "user" const NBYTES_IN_WEBDAV_TOKEN = 32 const WEBDAV_HREF_ROOT = "http://localhost:3000/api/v1/webdav/storage" const WEBDAV_TRIMMED_ROOT = "localhost:3000/api/v1/webdav/storage" + +// 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/localeExtractor/localeExtractor.go b/app/utils/localeExtractor/localeExtractor.go index 4b641d9..37bdb0a 100644 --- a/app/utils/localeExtractor/localeExtractor.go +++ b/app/utils/localeExtractor/localeExtractor.go @@ -22,10 +22,18 @@ func GetUserID(c fiber.Ctx) (uint, bool) { return user_locale.User.ID, true } -func GetOriginalUserRole(c fiber.Ctx) (model.CustomerRole, bool) { +func GetOriginalUserRole(c fiber.Ctx) (model.Role, bool) { user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) - if !ok || user_locale.OriginalUser == nil { - return "", false + if !ok || user_locale.OriginalUser == nil || user_locale.OriginalUser.Role == nil { + return model.Role{}, false } - return user_locale.OriginalUser.Role, true + 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 { + return nil, false + } + return user_locale.User, true } diff --git a/app/utils/query/find/find.go b/app/utils/query/find/find.go index 7d810ec..487c1d1 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,22 +42,14 @@ 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) + // 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/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index d81ecbb..b3fe72f 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") @@ -16,7 +17,7 @@ var ( ErrInvalidToken = errors.New("invalid token") ErrTokenExpired = errors.New("token has expired") ErrTokenRequired = errors.New("token is required") - ErrAdminAccessRequired = errors.New("admin access is required") + ErrAdminAccessRequired = errors.New("admin access required") // Typed errors for logging in and registering ErrInvalidCredentials = errors.New("invalid email or password") @@ -43,6 +44,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") @@ -67,6 +69,9 @@ var ( ErrFileDoesNotExist = errors.New("file does not exist") ErrNameTaken = errors.New("name taken") ErrMissingFileFieldDocument = errors.New("missing file field 'document'") + + // Typed errors for data parsing + ErrJSONBody = errors.New("invalid JSON body") ) // Error represents an error with HTTP status code @@ -91,6 +96,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): @@ -146,6 +153,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): @@ -183,6 +192,9 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrMissingFileFieldDocument): return i18n.T_(c, "error.missing_file_field_document") + case errors.Is(err, ErrJSONBody): + return i18n.T_(c, "error.err_json_body") + default: return i18n.T_(c, "error.err_internal_server_error") } @@ -191,6 +203,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), @@ -217,6 +231,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), @@ -230,7 +245,8 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrFolderDoesNotExist), errors.Is(err, ErrFileDoesNotExist), errors.Is(err, ErrNameTaken), - errors.Is(err, ErrMissingFileFieldDocument): + errors.Is(err, ErrMissingFileFieldDocument), + errors.Is(err, ErrJSONBody): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict 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..b741b82 --- /dev/null +++ b/bruno/api_v1/currency/currency-rate.yml @@ -0,0 +1,22 @@ +info: + name: currency-rate + type: http + seq: 2 + +http: + method: POST + url: "{{bas_url}}/restricted/currency-rate" + body: + type: json + data: |- + { + "b2b_id_currency" : 1, + "conversion_rate": 4.2 + } + 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..891919e --- /dev/null +++ b/bruno/api_v1/customer/Customer (me).yml @@ -0,0 +1,15 @@ +info: + name: Customer (me) + type: http + seq: 2 + +http: + method: GET + url: "{{bas_url}}/restricted/customer" + 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/Customer list.yml b/bruno/api_v1/customer/Customer list.yml new file mode 100644 index 0000000..11c286b --- /dev/null +++ b/bruno/api_v1/customer/Customer list.yml @@ -0,0 +1,19 @@ +info: + name: Customer list + type: http + seq: 3 + +http: + method: GET + url: "{{bas_url}}/restricted/customer/list?search=" + params: + - name: search + value: "" + 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 74% rename from bruno/api_v1/Products List.yml rename to bruno/api_v1/product/Products List.yml index cc07f08..6763495 100644 --- a/bruno/api_v1/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/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/bruno/b2b-daniel/save-product-description.yml b/bruno/b2b-daniel/save-product-description.yml index 3ea103c..e843995 100644 --- a/bruno/b2b-daniel/save-product-description.yml +++ b/bruno/b2b-daniel/save-product-description.yml @@ -5,19 +5,30 @@ info: http: method: POST - url: http://localhost:3000/api/v1/restricted/product-translation/save-product-description?productID=1&productLangID=3 + 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: "3" + value: "1" type: query body: type: json data: |- { - "description": "

Der Einsatz von Rehabilitationsrollen in verschiedenen Übungen und Behandlungen wirkt sich positiv auf die Reduzierung von Verletzungen und die Genesungschancen aus. Sie werden in der Rehabilitation, bei Korrekturgymnastik sowie in der traditionellen und Sportmassage eingesetzt, da sie ideal zum Anheben und Spreizen von Gliedmaßen geeignet sind. Zudem können sie zur Unterstützung von Knien, Füßen, Armen und Schultern verwendet werden. Auch für Kinder sind Rehabilitationsrollen empfehlenswert; ihre spielerische Anwendung fördert die Entwicklung der Grobmotorik.

Dank der großen Auswahl an Farben und Größen lässt sich ein Übungsset zusammenstellen, das in jeder Physiotherapiepraxis, jedem Massageraum, jeder Schule oder jedem Kindergarten benötigt wird.

Die Rehabilitationsrolle ist ein Medizinprodukt, das den grundlegenden Anforderungen an Medizinprodukte und den Bestimmungen des Medizinproduktegesetzes entspricht, im Register für Medizinprodukte des Amtes für die Registrierung von Arzneimitteln, Medizinprodukten und Biozidprodukten eingetragen ist, mit der Konformitätserklärung des Herstellers versehen ist und das CE-Zeichen trägt.

\"Medizinprodukt\"

Empfohlene Verwendung:

Materialspezifikationen:

Abdeckung: PVC-beschichtetes Material, das für medizinische Geräte vorgesehen ist und daher sehr leicht zu reinigen und zu desinfizieren ist:

\"ERREICHEN\"\"Öko-Tex\"Enthält\"Feuerfest\"\"Alkoholbeständig\"\"UV-beständig\"\"Für\"Kratzfest\"\"Ölbeständig\"

Füllung: mittelharter Polyurethanschaum mit erhöhter Verformungsbeständigkeit:

\"Öko-Tex\"Hygienezertifikat\"\"Hygienezertifikat\"

" + "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

\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

\"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

\"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" } auth: inherit diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index ae553dd..d975294 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`), @@ -50,7 +43,49 @@ 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 +); +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` 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 CREATE TABLE IF NOT EXISTS b2b_customers ( @@ -59,7 +94,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, @@ -73,8 +108,8 @@ CREATE TABLE IF NOT EXISTS b2b_customers ( webdav_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 @@ -89,9 +124,13 @@ ON b2b_customers (deleted_at); CREATE INDEX IF NOT EXISTS idx_customers_webdav_token ON b2b_customers (webdav_token); +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 ( - 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 @@ -101,10 +140,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 @@ -114,7 +153,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, @@ -124,24 +163,133 @@ 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 +) ENGINE = InnoDB; +CREATE INDEX idx_b2b_scope ON b2b_specific_price(scope); +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, + 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); DELIMITER // @@ -242,3 +390,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..dafebf7 --- /dev/null +++ b/i18n/migrations/20260302163123_create_tables_data.sql @@ -0,0 +1,46 @@ +-- +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 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_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', 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 +('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); + +INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('1', 'user.read.any'); +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 diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql new file mode 100644 index 0000000..8f7d5ab --- /dev/null +++ b/i18n/migrations/20260319163200_procedures.sql @@ -0,0 +1,380 @@ +-- +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 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_tax_excl', + ( + CASE + WHEN bsp.id IS NOT NULL THEN + CASE + /* FIXED PRICE */ + 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 + ) + + /* PERCENTAGE */ + WHEN bsp.reduction_type = 'percentage' THEN + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + * (1 - bsp.percentage_reduction / 100) + + 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 + ), + + '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 + ) + + WHEN bsp.reduction_type = 'percentage' THEN + COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) + * (1 - bsp.percentage_reduction / 100) + + 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 + + /* RELATIONS */ + LEFT JOIN b2b_specific_price_product bsp_p + ON bsp_p.b2b_specific_price_id = bsp.id + + LEFT JOIN b2b_specific_price_category bsp_c + ON bsp_c.b2b_specific_price_id = bsp.id + + WHERE bsp.is_active = TRUE + + /* SCOPE MATCH */ + AND ( + /* PRODUCT */ + (bsp.scope = 'product' AND bsp_p.id_product = p_id_product) + + /* 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 + ) + ) + + /* SHOP (GLOBAL) */ + OR (bsp.scope = 'shop') + ) + + /* 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 + ) +) + +/* 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 + ) +) + + /* QUANTITY */ + AND bsp.from_quantity <= p_quantity + + /* 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()) + ) + ) + + ORDER BY + /* 🔥 SCOPE PRIORITY */ + bsp.scope = 'product' DESC, + bsp.scope = 'category' DESC, + bsp.scope = 'shop' 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 +) 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 ; +-- +goose Down 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/taskfiles/db.yml b/taskfiles/db.yml index b7a349b..d2b4041 100644 --- a/taskfiles/db.yml +++ b/taskfiles/db.yml @@ -59,7 +59,9 @@ 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}} + sed '/-- +goose Down/,$d' i18n/migrations/20260319163200_procedures.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} \ No newline at end of file