From 0ed9d792b64ce88903b62b7f7d411634624a57e5 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Thu, 2 Apr 2026 15:06:00 +0200 Subject: [PATCH] feat: roles, permissions --- app/delivery/middleware/auth.go | 4 +- app/delivery/middleware/permissions.go | 28 +++++ app/delivery/middleware/perms/permissions.go | 11 ++ app/delivery/web/api/public/auth.go | 2 +- app/delivery/web/api/restricted/customer.go | 70 ++++++++++++ app/delivery/web/api/restricted/menu.go | 5 +- app/delivery/web/api/restricted/product.go | 13 ++- app/delivery/web/init.go | 6 +- app/model/customer.go | 72 ++++++------ app/model/permission.go | 12 ++ app/model/role.go | 19 ++++ app/repos/currencyRepo/currencyRepo.go | 2 +- app/repos/customerRepo/customerRepo.go | 27 +++++ app/repos/routesRepo/routesRepo.go | 14 ++- app/service/authService/auth.go | 22 ++-- app/service/authService/google_oauth.go | 2 +- .../customerService/customerService.go | 20 ++++ app/service/menuService/menuService.go | 4 +- app/utils/const_data/consts.go | 1 + app/utils/responseErrors/responseErrors.go | 5 + .../20260302163122_create_tables.sql | 105 ++++++++++++++++-- i18n/migrations/20260319163200_procedures.sql | 27 ++++- 22 files changed, 391 insertions(+), 80 deletions(-) create mode 100644 app/delivery/middleware/permissions.go create mode 100644 app/delivery/middleware/perms/permissions.go create mode 100644 app/delivery/web/api/restricted/customer.go create mode 100644 app/model/permission.go create mode 100644 app/model/role.go create mode 100644 app/repos/customerRepo/customerRepo.go create mode 100644 app/service/customerService/customerService.go diff --git a/app/delivery/middleware/auth.go b/app/delivery/middleware/auth.go index 2aefce0..30651f8 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -63,7 +63,7 @@ func AuthMiddleware() fiber.Handler { // Set user in context c.Locals(constdata.USER_LOCALES_NAME, user.ToSession()) c.Locals(constdata.USER_LOCALES_ID, user.ID) - + c.Locals(constdata.LANG_LOCALES_ID, user.LangID) return c.Next() } } @@ -85,7 +85,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..d4b9f07 --- /dev/null +++ b/app/delivery/middleware/perms/permissions.go @@ -0,0 +1,11 @@ +package perms + +type Permission string + +const ( + UserRead Permission = "user.read" + UserWrite Permission = "user.write" + UserReadAny Permission = "user.read.any" + UserWriteAny Permission = "user.write.any" + UserDeleteAny Permission = "user.delete.any" +) diff --git a/app/delivery/web/api/public/auth.go b/app/delivery/web/api/public/auth.go index 852a4ac..edf67f3 100644 --- a/app/delivery/web/api/public/auth.go +++ b/app/delivery/web/api/public/auth.go @@ -360,7 +360,7 @@ func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error { user := model.Customer{ ID: userLocals.UserID, Email: userLocals.Email, - Role: userLocals.Role, + Role: model.Role{ID: userLocals.RoleID, Name: userLocals.RoleName}, LangID: userLocals.LangID, CountryID: userLocals.CountryID, IsActive: userLocals.IsActive, diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go new file mode 100644 index 0000000..a15695d --- /dev/null +++ b/app/delivery/web/api/restricted/customer.go @@ -0,0 +1,70 @@ +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/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 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) + return r +} + +func (h *customerHandler) customerData(fc fiber.Ctx) error { + var customerId uint + customerIdStr := fc.Query("id") + if customerIdStr != "" { + user, ok := fc.Locals("user").(*model.UserSession) + if !ok { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + id, err := strconv.ParseUint(customerIdStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if user.UserID != 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 { + id, ok := fc.Locals("userID").(uint) + if !ok { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + customerId = 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))) +} diff --git a/app/delivery/web/api/restricted/menu.go b/app/delivery/web/api/restricted/menu.go index ee7e615..960c901 100644 --- a/app/delivery/web/api/restricted/menu.go +++ b/app/delivery/web/api/restricted/menu.go @@ -1,6 +1,7 @@ package restricted import ( + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/menuService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" @@ -45,12 +46,12 @@ func (h *MenuHandler) GetMenu(c fiber.Ctx) error { } func (h *MenuHandler) GetTopMenu(c fiber.Ctx) error { - lang_id, ok := c.Locals("langID").(uint) + session, ok := c.Locals("user").(*model.UserSession) if !ok { 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(session.LangID, session.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/product.go b/app/delivery/web/api/restricted/product.go index d9c40a3..8670c1a 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -1,10 +1,12 @@ package restricted import ( + "fmt" "strconv" "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/service/productService" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" "git.ma-al.com/goc_daniel/b2b/app/utils/response" @@ -30,7 +32,7 @@ func ProductsHandlerRoutes(r fiber.Router) fiber.Router { handler := NewProductsHandler() //TODO: WIP doesn't work yet - r.Get("/product/:id/:country_id/:quantity", handler.GetProductJson) + r.Get("/:id/:country_id/:quantity", handler.GetProductJson) return r } @@ -60,19 +62,18 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - id_lang, ok := c.Locals("lang_id").(int) + p_id_customer, ok := c.Locals(constdata.USER_LOCALES_ID).(uint) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - - p_id_customer, ok := c.Locals("user_id").(int) + fmt.Printf("p_id_customer: %v\n", p_id_customer) + id_lang, ok := c.Locals(constdata.LANG_LOCALES_ID).(uint) if !ok { 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, id_lang, p_id_customer, b2b_id_country, p_quantity) + productJson, err := h.productService.GetJSON(p_id_product, int(id_lang), int(p_id_customer), b2b_id_country, p_quantity) 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/init.go b/app/delivery/web/init.go index d7ca9a8..be7730b 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -90,6 +90,9 @@ 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) @@ -98,7 +101,8 @@ func (s *Server) Setup() error { list := s.restricted.Group("/list") restricted.ListHandlerRoutes(list) - restricted.ProductsHandlerRoutes(s.restricted) + product := s.restricted.Group("/product") + restricted.ProductsHandlerRoutes(product) // locale selector (restricted) // this is basically for changing user's selected language and country diff --git a/app/model/customer.go b/app/model/customer.go index ec7b63d..f7db443 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"` 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,14 +34,6 @@ 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" -) - // AuthProvider represents the authentication provider type AuthProvider string @@ -53,16 +47,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,27 +57,51 @@ 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 } // 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"` 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..3c663b5 --- /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:"-"` +} + +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 index 97b1b5e..9cc153c 100644 --- a/app/repos/currencyRepo/currencyRepo.go +++ b/app/repos/currencyRepo/currencyRepo.go @@ -19,7 +19,7 @@ func New() UICurrencyRepo { } func (repo *CurrencyRepo) CreateConversionRate(currencyRate *model.CurrencyRate) error { - return db.DB.Debug().Create(currencyRate).Error + return db.DB.Create(currencyRate).Error } func (repo *CurrencyRepo) Get(id uint) (*model.Currency, error) { diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go new file mode 100644 index 0000000..058d5fd --- /dev/null +++ b/app/repos/customerRepo/customerRepo.go @@ -0,0 +1,27 @@ +package customerRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UICustomerRepo interface { + Get(id uint) (*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"). + First(&customer, id). + Error + + return &customer, 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 2fc4a7d..6effc43 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 } @@ -144,7 +144,7 @@ func (s *AuthService) Register(req *model.RegisterRequest) error { Password: string(hashedPassword), FirstName: req.FirstName, LastName: req.LastName, - Role: model.RoleUser, + Role: model.Role{}, Provider: model.ProviderLocal, IsActive: false, EmailVerified: false, @@ -422,7 +422,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 } @@ -489,7 +489,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..c26da16 100644 --- a/app/service/authService/google_oauth.go +++ b/app/service/authService/google_oauth.go @@ -150,7 +150,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. Provider: model.ProviderGoogle, ProviderID: info.ID, AvatarURL: info.Picture, - Role: model.RoleUser, + Role: model.Role{}, IsActive: true, EmailVerified: true, LangID: 2, // default is english diff --git a/app/service/customerService/customerService.go b/app/service/customerService/customerService.go new file mode 100644 index 0000000..7af553c --- /dev/null +++ b/app/service/customerService/customerService.go @@ -0,0 +1,20 @@ +package customerService + +import ( + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/customerRepo" +) + +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) +} diff --git a/app/service/menuService/menuService.go b/app/service/menuService/menuService.go index e689ede..19efbe1 100644 --- a/app/service/menuService/menuService.go +++ b/app/service/menuService/menuService.go @@ -98,8 +98,8 @@ func (a ByPosition) Len() int { return len(a) } func (a ByPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByPosition) Less(i, j int) bool { return a[i].Position < a[j].Position } -func (s *MenuService) 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/utils/const_data/consts.go b/app/utils/const_data/consts.go index 812364f..664e55b 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -8,3 +8,4 @@ const DEFAULT_NEW_CART_NAME = "new cart" const USER_LOCALES_NAME = "user" const USER_LOCALES_ID = "userID" +const LANG_LOCALES_ID = "langID" diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 2b8715d..8036d8b 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") @@ -83,6 +84,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): @@ -167,6 +170,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), diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index 75a75a0..71b1344 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -43,7 +43,42 @@ 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, + PRIMARY KEY (`id`) +); +CREATE UNIQUE INDEX `IX_b2b_roles_id` +ON `b2b_roles` ( + `id` ASC +); +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` BIGINT UNSIGNED NOT NULL, + `role_id` BIGINT UNSIGNED NOT NULL +); -- customers CREATE TABLE IF NOT EXISTS b2b_customers ( @@ -52,7 +87,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, @@ -77,6 +112,9 @@ 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 ( @@ -175,16 +213,7 @@ CREATE TABLE b2b_specific_price ( 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, - CONSTRAINT fk_b2b_specific_price_country FOREIGN KEY (b2b_id_country) REFERENCES b2b_countries(id) ON DELETE - SET - NULL ON UPDATE CASCADE, - CONSTRAINT fk_b2b_specific_price_customer FOREIGN KEY (b2b_id_customer) REFERENCES b2b_customers(id) ON DELETE - SET - NULL ON UPDATE CASCADE, - CONSTRAINT fk_b2b_specific_price_currency FOREIGN KEY (b2b_id_currency) REFERENCES b2b_currencies(id) ON DELETE - SET - NULL ON UPDATE CASCADE + is_active BOOLEAN DEFAULT TRUE ) ENGINE = InnoDB; CREATE INDEX idx_b2b_scope ON b2b_specific_price(scope); CREATE INDEX idx_b2b_customer ON b2b_specific_price(b2b_id_customer); @@ -344,6 +373,60 @@ END$$ DELIMITER ; +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); -- +goose Down DROP TABLE IF EXISTS b2b_countries; diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql index 8207466..8f7d5ab 100644 --- a/i18n/migrations/20260319163200_procedures.sql +++ b/i18n/migrations/20260319163200_procedures.sql @@ -321,19 +321,36 @@ AND ( ) ) - ORDER BY - /* 🔥 STRICT PRIORITY */ + ORDER BY + /* 🔥 SCOPE PRIORITY */ bsp.scope = 'product' DESC, bsp.scope = 'category' DESC, bsp.scope = 'shop' DESC, - bsp.b2b_id_customer DESC, - bsp.b2b_id_country 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 + LIMIT 1 ) bsp ON 1=1 LEFT JOIN b2b_currency_rates br_bsp ON br_bsp.b2b_id_currency = bsp.b2b_id_currency