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 c5a87cc..312cbe5 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -76,7 +76,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", }) @@ -129,7 +129,7 @@ func RequireAdmin() fiber.Handler { }) } - if userSession.Role != model.RoleAdmin { + if model.CustomerRole(userSession.RoleName) != model.RoleAdmin { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "admin access required", }) diff --git a/app/delivery/middleware/permissions.go b/app/delivery/middleware/permissions.go new file mode 100644 index 0000000..96ab057 --- /dev/null +++ b/app/delivery/middleware/permissions.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" + "git.ma-al.com/goc_daniel/b2b/app/model" + "github.com/gofiber/fiber/v3" +) + +func Require(p perms.Permission) fiber.Handler { + return func(c fiber.Ctx) error { + u := c.Locals("user") + if u == nil { + return c.SendStatus(fiber.StatusUnauthorized) + } + + user, ok := u.(*model.UserSession) + if !ok { + return c.SendStatus(fiber.StatusInternalServerError) + } + + for _, perm := range user.Permissions { + if perm == p { + return c.Next() + } + } + return c.SendStatus(fiber.StatusForbidden) + } +} diff --git a/app/delivery/middleware/perms/permissions.go b/app/delivery/middleware/perms/permissions.go new file mode 100644 index 0000000..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..6f953b3 --- /dev/null +++ b/app/delivery/web/api/restricted/customer.go @@ -0,0 +1,103 @@ +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 { + p, filt, err := query_params.ParseFilters[model.Customer](fc, columnMappingListUsers) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } + + user, ok := localeExtractor.GetCustomer(fc) + if !ok || user == nil { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + 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))) + } + + customer, err := h.service.Find(user.LangID, p, filt) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } + + return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) +} + +var columnMappingListUsers map[string]string = map[string]string{ + "user_id": "users.id", + "email": "users.email", + "first_name": "users.first_name", + "second_name": "users.second_name", +} diff --git a/app/delivery/web/api/restricted/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 eaf41d9..9d673f5 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -90,13 +90,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 @@ -115,6 +117,7 @@ func (s *Server) Setup() error { carts := s.restricted.Group("/carts") restricted.CartsHandlerRoutes(carts) + 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 3934dcd..77102ad 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"` @@ -32,13 +34,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 @@ -53,16 +56,6 @@ func (Customer) TableName() string { return "b2b_customers" } -// IsAdmin checks if the user has admin role -func (u *Customer) IsAdmin() bool { - return u.Role == RoleAdmin -} - -// CanManageUsers checks if the user can manage other users -func (u *Customer) CanManageUsers() bool { - return u.Role == RoleAdmin -} - // FullName returns the user's full name func (u *Customer) FullName() string { if u.FirstName == "" && u.LastName == "" { @@ -73,13 +66,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 { @@ -93,16 +97,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"` @@ -160,5 +177,4 @@ type UserInList struct { Email string `gorm:"column:email" json:"email"` FirstName string `gorm:"column:first_name" json:"first_name"` LastName string `gorm:"column:last_name" json:"last_name"` - Role string `gorm:"column:role" json:"role"` } diff --git a/app/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/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..9f325c2 --- /dev/null +++ b/app/repos/customerRepo/customerRepo.go @@ -0,0 +1,85 @@ +package customerRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" +) + +type UICustomerRepo interface { + Get(id uint) (*model.Customer, error) + Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], 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) Find(langId uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.UserInList], error) { + found, err := find.Paginate[model.UserInList](langId, p, db.DB. + Table("b2b_customers AS users"). + Select(` + users.id AS id, + users.email AS email, + users.first_name AS first_name, + users.last_name AS last_name + `). + Scopes(filt.All()...), + ) + + return &found, err +} + +// func (repo *ListRepo) ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) { +// var list []model.UserInList +// var total int64 + +// query := db.Get(). +// Table("b2b_customers AS users"). +// Select(` +// users.id AS id, +// users.email AS email, +// users.first_name AS first_name, +// users.last_name AS last_name, +// users.role AS role +// `) + +// // Apply all filters +// if filt != nil { +// filt.ApplyAll(query) +// } + +// // run counter first as query is without limit and offset +// err := query.Count(&total).Error +// if err != nil { +// return find.Found[model.UserInList]{}, err +// } + +// err = query. +// Order("users.id DESC"). +// Limit(p.Limit()). +// Offset(p.Offset()). +// Find(&list).Error +// if err != nil { +// return find.Found[model.UserInList]{}, err +// } + +// return find.Found[model.UserInList]{ +// Items: list, +// Count: uint(total), +// }, nil +// } diff --git a/app/repos/listRepo/listRepo.go b/app/repos/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/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 c873ce0..ba1fa67 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -23,13 +23,13 @@ import ( // JWTClaims represents the JWT claims type JWTClaims struct { - UserID uint `json:"user_id"` - Email string `json:"email"` - Username string `json:"username"` - Role model.CustomerRole `json:"customer_role"` - CartsIDs []uint `json:"carts_ids"` - LangID uint `json:"lang_id"` - CountryID uint `json:"country_id"` + UserID uint `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + Role string `json:"customer_role"` + CartsIDs []uint `json:"carts_ids"` + LangID uint `json:"lang_id"` + CountryID uint `json:"country_id"` jwt.RegisteredClaims } @@ -59,7 +59,7 @@ func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, strin var user model.Customer // Find user by email - if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil { + if err := s.db.Preload("Role.Permissions").Where("email = ?", req.Email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, "", responseErrors.ErrInvalidCredentials } @@ -153,7 +153,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 +430,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 } @@ -498,7 +497,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..d8c1820 100644 --- a/app/service/authService/google_oauth.go +++ b/app/service/authService/google_oauth.go @@ -150,7 +150,6 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. Provider: model.ProviderGoogle, ProviderID: info.ID, AvatarURL: info.Picture, - Role: model.RoleUser, IsActive: true, EmailVerified: true, LangID: 2, // default is english 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..f9f2f4a --- /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) (*find.Found[model.UserInList], error) { + return s.repo.Find(langId, p, filt) +} diff --git a/app/service/listService/listService.go b/app/service/listService/listService.go deleted file mode 100644 index d3d168b..0000000 --- a/app/service/listService/listService.go +++ /dev/null @@ -1,26 +0,0 @@ -package listService - -import ( - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/repos/listRepo" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" -) - -type ListService struct { - listRepo listRepo.UIListRepo -} - -func New() *ListService { - return &ListService{ - listRepo: listRepo.New(), - } -} - -func (s *ListService) ListProducts(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { - return s.listRepo.ListProducts(id_lang, p, filters) -} - -func (s *ListService) ListUsers(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.UserInList], error) { - return s.listRepo.ListUsers(id_lang, p, filters) -} diff --git a/app/service/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/utils/localeExtractor/localeExtractor.go b/app/utils/localeExtractor/localeExtractor.go index 735397c..7dcd0cc 100644 --- a/app/utils/localeExtractor/localeExtractor.go +++ b/app/utils/localeExtractor/localeExtractor.go @@ -21,3 +21,11 @@ func GetUserID(c fiber.Ctx) (uint, bool) { } return user_locale.User.ID, 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..57ef813 100644 --- a/app/utils/query/find/find.go +++ b/app/utils/query/find/find.go @@ -1,7 +1,6 @@ package find import ( - "errors" "reflect" "strings" @@ -28,18 +27,13 @@ type Found[T any] struct { Spec map[string]interface{} `json:"spec,omitempty"` } -// Wraps given query adding limit, offset clauses and SQL_CALC_FOUND_ROWS to it -// and running SELECT FOUND_ROWS() afterwards to fetch the total number -// (ignoring LIMIT) of results. The final results are wrapped into the -// [find.Found] type. func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error) { var items []T - var count uint64 + var count int64 - // stmt.Debug() + stmt.Count(&count) err := stmt. - Clauses(SqlCalcFound()). Offset(paging.Offset()). Limit(paging.Limit()). Find(&items). @@ -48,14 +42,6 @@ func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error return Found[T]{}, err } - countInterface, ok := stmt.Get(FOUND_ROWS_CTX_KEY) - if !ok { - return Found[T]{}, errors.New(FOUND_ROWS_CTX_KEY + " value was not found in the gorm db context") - } - if count, ok = countInterface.(uint64); !ok { - return Found[T]{}, errors.New("failed to cast value under " + FOUND_ROWS_CTX_KEY + " to uint64") - } - columnsSpec := GetColumnsSpec[T](langID) return Found[T]{ diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index d20c173..dc54a4f 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") @@ -60,6 +61,9 @@ var ( ErrMaxAmtOfCartsReached = errors.New("maximal amount of carts reached") ErrUserHasNoSuchCart = errors.New("user does not have cart with given id") ErrProductOrItsVariationDoesNotExist = errors.New("product or its variation with given ids does not exist") + + // Typed errors for data parsing + ErrJSONBody = errors.New("invalid JSON body") ) // Error represents an error with HTTP status code @@ -84,6 +88,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): @@ -165,6 +171,9 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrProductOrItsVariationDoesNotExist): return i18n.T_(c, "error.product_or_its_variation_does_not_exist") + case errors.Is(err, ErrJSONBody): + return i18n.T_(c, "error.err_json_body") + default: return i18n.T_(c, "error.err_internal_server_error") } @@ -173,6 +182,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), @@ -207,7 +218,8 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrRootNeverReached), errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrUserHasNoSuchCart), - errors.Is(err, ErrProductOrItsVariationDoesNotExist): + errors.Is(err, ErrProductOrItsVariationDoesNotExist), + errors.Is(err, ErrJSONBody): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict diff --git a/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..0d5bc26 --- /dev/null +++ b/bruno/api_v1/customer/Customer list.yml @@ -0,0 +1,15 @@ +info: + name: Customer list + type: http + seq: 3 + +http: + method: GET + url: "{{bas_url}}/restricted/customer/list" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 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/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index bfe5401..b88f14e 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, @@ -71,8 +106,8 @@ CREATE TABLE IF NOT EXISTS b2b_customers ( password_reset_expires DATETIME(6) NULL, last_password_reset_request DATETIME(6) NULL, last_login_at DATETIME(6) NULL, - lang_id BIGINT NULL DEFAULT 2, - country_id BIGINT NULL DEFAULT 2, + lang_id INT NULL DEFAULT 2, + country_id INT NULL DEFAULT 2, created_at DATETIME(6) NULL, updated_at DATETIME(6) NULL, deleted_at DATETIME(6) NULL @@ -84,10 +119,13 @@ ON b2b_customers (email); CREATE INDEX IF NOT EXISTS idx_customers_deleted_at ON b2b_customers (deleted_at); +ALTER TABLE b2b_customers +ADD CONSTRAINT fk_customer_role +FOREIGN KEY (role_id) REFERENCES b2b_roles(id); -- customer_carts CREATE TABLE IF NOT EXISTS b2b_customer_carts ( - cart_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + cart_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, name VARCHAR(255) NULL, CONSTRAINT fk_customer_carts_customers FOREIGN KEY (user_id) REFERENCES b2b_customers(id) ON DELETE CASCADE ON UPDATE CASCADE @@ -97,10 +135,10 @@ CREATE INDEX IF NOT EXISTS idx_customer_carts_user_id ON b2b_customer_carts (use -- carts_products CREATE TABLE IF NOT EXISTS b2b_carts_products ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - cart_id BIGINT UNSIGNED NOT NULL, + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + cart_id INT UNSIGNED NOT NULL, product_id INT UNSIGNED NOT NULL, - product_attribute_id BIGINT NULL, + product_attribute_id INT NULL, amount INT UNSIGNED NOT NULL, CONSTRAINT fk_carts_products_customer_carts FOREIGN KEY (cart_id) REFERENCES b2b_customer_carts (cart_id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_carts_products_product FOREIGN KEY (product_id) REFERENCES ps_product (id_product) ON DELETE CASCADE ON UPDATE CASCADE @@ -110,7 +148,7 @@ CREATE INDEX IF NOT EXISTS idx_carts_products_cart_id ON b2b_carts_products (car -- refresh_tokens CREATE TABLE IF NOT EXISTS b2b_refresh_tokens ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT UNSIGNED NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME(6) NOT NULL, @@ -120,24 +158,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 // @@ -238,3 +385,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