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 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/currency.go b/app/delivery/web/api/restricted/currency.go new file mode 100644 index 0000000..52dee21 --- /dev/null +++ b/app/delivery/web/api/restricted/currency.go @@ -0,0 +1,68 @@ +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/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", 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..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 b269fb7..5173d3f 100644 --- a/app/delivery/web/api/restricted/menu.go +++ b/app/delivery/web/api/restricted/menu.go @@ -3,6 +3,8 @@ package restricted import ( "strconv" + "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" @@ -86,12 +88,12 @@ func (h *MenuHandler) GetBreadcrumb(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 new file mode 100644 index 0000000..eaade19 --- /dev/null +++ b/app/delivery/web/api/restricted/product.go @@ -0,0 +1,82 @@ +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" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type ProductsHandler struct { + productService *productService.ProductService + config *config.Config +} + +// NewListProductsHandler creates a new ListProductsHandler instance +func NewProductsHandler() *ProductsHandler { + productService := productService.New() + return &ProductsHandler{ + productService: productService, + config: config.Get(), + } +} + +func ProductsHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewProductsHandler() + + r.Get("/:id/:country_id/:quantity", handler.GetProductJson) + + return r +} + +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))) + } + + 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))) + } + 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, 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))) + } + + return c.JSON(response.Make(&productJson, 1, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index eaf41d9..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,6 +101,9 @@ func (s *Server) Setup() error { 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 localeSelector := s.restricted.Group("/langs-and-countries") @@ -119,6 +125,8 @@ func (s *Server) Setup() error { return c.SendStatus(fiber.StatusNotFound) }) + restricted.CurrencyHandlerRoutes(s.restricted) + // // Restricted routes example // restricted := s.api.Group("/restricted") // restricted.Use(middleware.AuthMiddleware()) 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 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/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..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 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..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/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go new file mode 100644 index 0000000..7c6c08f --- /dev/null +++ b/app/repos/productsRepo/productsRepo.go @@ -0,0 +1,39 @@ +package productsRepo + +import ( + "encoding/json" + "fmt" + + "git.ma-al.com/goc_daniel/b2b/app/db" +) + +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) +} + +type ProductsRepo struct{} + +func New() UIProductsRepo { + return &ProductsRepo{} +} + +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 +} 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/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..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 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..66245f1 --- /dev/null +++ b/app/service/productService/productService.go @@ -0,0 +1,27 @@ +package productService + +import ( + "encoding/json" + + "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" +) + +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 +} diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index 9c64ee5..9fb53b5 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -13,3 +13,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 c4247ea..d2fce1a 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") @@ -59,6 +60,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 @@ -83,6 +87,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): @@ -162,6 +168,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") } @@ -170,6 +179,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), @@ -203,7 +214,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/bo/components.d.ts b/bo/components.d.ts index 2581158..f0f9cc4 100644 --- a/bo/components.d.ts +++ b/bo/components.d.ts @@ -33,6 +33,7 @@ declare module 'vue' { Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default'] ProductCustomization: typeof import('./src/components/customer/components/ProductCustomization.vue')['default'] ProductDetailView: typeof import('./src/components/admin/ProductDetailView.vue')['default'] + 'ProductDetailView copy': typeof import('./src/components/admin/ProductDetailView copy.vue')['default'] ProductVariants: typeof import('./src/components/customer/components/ProductVariants.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/bo/src/components/admin/ProductDetailView copy.vue b/bo/src/components/admin/ProductDetailView copy.vue new file mode 100644 index 0000000..aacca62 --- /dev/null +++ b/bo/src/components/admin/ProductDetailView copy.vue @@ -0,0 +1,526 @@ + + + diff --git a/bo/src/components/admin/ProductDetailView.vue b/bo/src/components/admin/ProductDetailView.vue index ce31ae0..9ebd902 100644 --- a/bo/src/components/admin/ProductDetailView.vue +++ b/bo/src/components/admin/ProductDetailView.vue @@ -5,7 +5,7 @@

Back to products

-
+
@@ -35,6 +35,10 @@
+ +

Change Text

+ +
Translate from Polish to {{langs.find(l => l.id === toLangId)?.name}} @@ -50,45 +54,9 @@ Save translations
- - -
- -
@@ -96,29 +64,62 @@

{{ productStore.error }}

-
-
-
+
+
+
Product Image
-
-

- {{ productStore.productDescription.name || 'Product Name' }} -

-

+
+
+
+

+ {{ productStore.productDescription.name || 'Product Name' }} +

+
+

Title:

+ + + + + +
+
+ +
+

+
+

Short description:

+ + + + + +
+
+
-

- {{ productStore.productDescription.available_now }} +

+ {{ productStore.productDescription.available_now || 'Available now' }}

-

+

{{ productStore.productDescription.delivery_in_stock || 'Delivery information' }}

@@ -131,7 +132,7 @@ root: 'items-start!' }"> diff --git a/bo/src/layouts/default.vue b/bo/src/layouts/default.vue index 49a58e2..bdd47f7 100644 --- a/bo/src/layouts/default.vue +++ b/bo/src/layouts/default.vue @@ -16,6 +16,7 @@ @@ -151,7 +152,7 @@ function getItems(state: 'collapsed' | 'expanded') { ] satisfies NavigationMenuItem[] } -// zsdasdad +// import { useRouter } from 'vue-router' import { currentLang } from '@/router/langs' import { useFetchJson } from '@/composable/useFetchJson' 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..d5e5bac --- /dev/null +++ b/bruno/api_v1/currency/currency-rate.yml @@ -0,0 +1,15 @@ +info: + name: currency-rate + type: http + seq: 2 + +http: + method: POST + url: "{{bas_url}}/restricted/currency-rate" + 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..253bead --- /dev/null +++ b/bruno/api_v1/customer/Customer (me).yml @@ -0,0 +1,19 @@ +info: + name: Customer (me) + type: http + seq: 2 + +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 (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/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 100% rename from bruno/api_v1/Products List.yml rename to bruno/api_v1/product/Products List.yml 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..88875c5 --- /dev/null +++ b/i18n/migrations/20260302163123_create_tables_data.sql @@ -0,0 +1,35 @@ +-- +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); + + + +-- +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/repository/currencyRepo/currencyRepo.go b/repository/currencyRepo/currencyRepo.go new file mode 100644 index 0000000..97b1b5e --- /dev/null +++ b/repository/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.Debug().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/taskfiles/db.yml b/taskfiles/db.yml index b7a349b..21ac231 100644 --- a/taskfiles/db.yml +++ b/taskfiles/db.yml @@ -59,6 +59,7 @@ 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}}