diff --git a/app/delivery/web/api/public/auth.go b/app/delivery/web/api/public/auth.go index 8de03c4..747871f 100644 --- a/app/delivery/web/api/public/auth.go +++ b/app/delivery/web/api/public/auth.go @@ -40,6 +40,7 @@ func AuthHandlerRoutes(r fiber.Router) fiber.Router { r.Post("/reset-password", handler.ResetPassword) r.Post("/logout", handler.Logout) r.Post("/refresh", handler.RefreshToken) + r.Post("/update-choice", handler.UpdateJWTToken) // Google OAuth2 r.Get("/google", handler.GoogleLogin) @@ -344,6 +345,11 @@ func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(response) } +// CompleteRegistration handles completion of registration with password +func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error { + return h.UpdateJWTToken(c) +} + // GoogleLogin redirects the user to Google's OAuth2 consent page func (h *AuthHandler) GoogleLogin(c fiber.Ctx) error { // Generate a random state token and store it in a short-lived cookie @@ -408,9 +414,12 @@ func (h *AuthHandler) GoogleCallback(c fiber.Ctx) error { // Redirect to the locale-prefixed charts page after successful Google login. // The user's preferred language is stored in the auth response; fall back to "en". - lang := response.User.Lang - if lang == "" { - lang = "en" + lang, err := h.authService.GetLangISOCode(response.User.LangID) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadLangID)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrBadLangID), + }) } + return c.Redirect().To(h.config.App.BaseURL + "/" + lang) } diff --git a/app/delivery/web/api/restricted/langsAndCountries.go b/app/delivery/web/api/restricted/langsAndCountries.go new file mode 100644 index 0000000..dfd6aad --- /dev/null +++ b/app/delivery/web/api/restricted/langsAndCountries.go @@ -0,0 +1,52 @@ +package restricted + +import ( + "git.ma-al.com/goc_daniel/b2b/app/service/langsAndCountriesService" + "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" +) + +// LangsAndCountriesHandler for getting languages and countries data +type LangsAndCountriesHandler struct { + langsAndCountriesService *langsAndCountriesService.LangsAndCountriesService +} + +// NewLangsAndCountriesHandler creates a new LangsAndCountriesHandler instance +func NewLangsAndCountriesHandler() *LangsAndCountriesHandler { + langsAndCountriesService := langsAndCountriesService.New() + return &LangsAndCountriesHandler{ + langsAndCountriesService: langsAndCountriesService, + } +} + +func LangsAndCountriesHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewLangsAndCountriesHandler() + + r.Get("/get-languages", handler.GetLanguages) + r.Get("/get-countries", handler.GetCountries) + + return r +} + +func (h *LangsAndCountriesHandler) GetLanguages(c fiber.Ctx) error { + languages, err := h.langsAndCountriesService.GetLanguages() + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&languages, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *LangsAndCountriesHandler) GetCountries(c fiber.Ctx) error { + countries, err := h.langsAndCountriesService.GetCountriesAndCurrencies() + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&countries, 0, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/api/restricted/listProducts.go b/app/delivery/web/api/restricted/listProducts.go new file mode 100644 index 0000000..f2603cf --- /dev/null +++ b/app/delivery/web/api/restricted/listProducts.go @@ -0,0 +1,120 @@ +package restricted + +import ( + "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/service/listProductsService" + "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/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" + "github.com/samber/lo" + "gorm.io/gorm" +) + +// ListProductsHandler handles endpoints that receive, save and translate product descriptions. +type ListProductsHandler struct { + listProductsService *listProductsService.ListProductsService + config *config.Config +} + +// NewListProductsHandler creates a new ListProductsHandler instance +func NewListProductsHandler() *ListProductsHandler { + listProductsService := listProductsService.New() + return &ListProductsHandler{ + listProductsService: listProductsService, + config: config.Get(), + } +} + +func ListProductsHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewListProductsHandler() + + r.Get("/get-listing", handler.GetListing) + + return r +} + +func (h *ListProductsHandler) GetListing(c fiber.Ctx) error { + paging, filters, err := ParseProductFilters(c) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + // overrides := map[string]string{ + // "override_country": c.Query("override_country", ""), + // "override_currency": c.Query("override_currency", ""), + // } + + listing, err := h.listProductsService.GetListing(paging, filters) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK))) +} + +var columnMapping map[string]string = map[string]string{} + +// var columnMapping map[string]string = map[string]string{ +// "product_id": "id", +// "price": "price_taxed", +// "name": "name", +// "category_id": "category_id", +// "feature_id": "feature_id", +// "feature": "feature_name", +// "value_id": "value_id", +// "value": "value_name", +// "status": "active_sale", +// "stock": "in_stock", +// } + +func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) { + var p find.Paging + fl := filters.NewFiltersList() + // productFilters := new(model.ProductFilters) + + // err := c.Bind().Query(productFilters) + // if err != nil { + // return p, &fl, err + // } + + // if productFilters.Name != "" { + // fl.Append(filters.Where("name LIKE ?", fmt.Sprintf("%%%s%%", productFilters.Name))) + // } + + // if productFilters.Sort != "" { + // ord, err := query_params.ParseOrdering[model.Product](c, columnMapping) + // if err != nil { + // return p, &fl, err + // } + // for _, o := range ord { + // fl.Append(filters.Order(o.Column, o.IsDesc)) + // } + // } + + // if len(productFilters.Features) > 0 { + // fl.Append(featureValueFilters(productFilters.Features)) + // } + + // fl.Append(query_params.ParseWhereScopes[model.Product](c, []string{"name"}, columnMapping)...) + + pageNum, pageElems := query_params.ParsePagination(c) + p = find.Paging{Page: pageNum, Elements: pageElems} + + return p, &fl, nil +} + +type FeatVal = map[uint][]uint + +func featureValueFilters(feats FeatVal) filters.Filter { + filt := func(db *gorm.DB) *gorm.DB { + return db.Where("value_id IN ?", lo.Flatten(lo.Values(feats))).Group("id").Having("COUNT(id) = ?", len(lo.Keys(feats))) + } + return filters.NewFilter(filters.FEAT_VAL_PRODUCT_FILTER, filt) +} diff --git a/app/delivery/web/api/restricted/productDescription.go b/app/delivery/web/api/restricted/productDescription.go index 9488e21..4cca8a3 100644 --- a/app/delivery/web/api/restricted/productDescription.go +++ b/app/delivery/web/api/restricted/productDescription.go @@ -70,9 +70,8 @@ func (h *ProductDescriptionHandler) GetProductDescription(c fiber.Ctx) error { description, err := h.productDescriptionService.GetProductDescription(userID, uint(productID), uint(productShopID), uint(productLangID)) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } return c.JSON(response.Make(description, 1, i18n.T_(c, response.Message_OK))) diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index 2fe263b..11c9c50 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -89,10 +89,19 @@ func (s *Server) Setup() error { auth := s.public.Group("/auth") public.AuthHandlerRoutes(auth) - // Repo routes (restricted) + // product description routes (restricted) productDescription := s.restricted.Group("/product-description") restricted.ProductDescriptionHandlerRoutes(productDescription) + // listing products routes (restricted) + listProducts := s.restricted.Group("/list-products") + restricted.ListProductsHandlerRoutes(listProducts) + + // changing the JWT cookies routes (restricted) + // in reality it just handles changing user's country and language + langsAndCountries := s.restricted.Group("/langs-and-countries") + restricted.LangsAndCountriesHandlerRoutes(langsAndCountries) + // // Restricted routes example // restricted := s.api.Group("/restricted") // restricted.Use(middleware.AuthMiddleware()) diff --git a/app/model/countries.go b/app/model/countries.go new file mode 100644 index 0000000..61972d2 --- /dev/null +++ b/app/model/countries.go @@ -0,0 +1,11 @@ +package model + +// Represents a country together with its associated currency +type Country struct { + ID uint `gorm:"primaryKey;column:id" json:"id"` + Name string `gorm:"column:name" json:"name"` + Flag string `gorm:"size:16;not null;column:flag" json:"flag"` + CurrencyID uint `gorm:"column:id_currency" json:"currency_id"` + CurrencyISOCode string `gorm:"column:iso_code" json:"currency_iso_code"` + CurrencyName string `gorm:"column:name" json:"currency_name"` +} diff --git a/app/model/customer.go b/app/model/customer.go index 4ee935b..22e7c0c 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -25,7 +25,8 @@ type Customer struct { PasswordResetExpires *time.Time `json:"-"` LastPasswordResetRequest *time.Time `json:"-"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` - Lang string `gorm:"size:10;default:'en'" json:"lang"` // User's preferred language + LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language + CountryID uint `gorm:"default:2" json:"country_id"` // User's selected country CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` @@ -76,9 +77,8 @@ type UserSession struct { Email string `json:"email"` Username string `json:"username"` Role CustomerRole `json:"role"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Lang string `json:"lang"` + LangID uint `json:"lang_id"` + CountryID uint `json:"country_id"` } // ToSession converts User to UserSession @@ -87,9 +87,8 @@ func (u *Customer) ToSession() *UserSession { UserID: u.ID, Email: u.Email, Role: u.Role, - FirstName: u.FirstName, - LastName: u.LastName, - Lang: u.Lang, + LangID: u.LangID, + CountryID: u.CountryID, } } @@ -107,7 +106,8 @@ type RegisterRequest struct { ConfirmPassword string `json:"confirm_password" form:"confirm_password"` FirstName string `json:"first_name" form:"first_name"` LastName string `json:"last_name" form:"last_name"` - Lang string `form:"lang" json:"lang"` + LangID uint `form:"lang_id" json:"lang_id"` + CountryID uint `form:"country_id" json:"country_id"` } // CompleteRegistrationRequest represents the completion of registration with email verification diff --git a/app/model/product.go b/app/model/product.go new file mode 100644 index 0000000..c0dc2cb --- /dev/null +++ b/app/model/product.go @@ -0,0 +1,77 @@ +package model + +// Product contains each and every column from the table ps_product. +type Product struct { + ProductID uint `gorm:"column:id_product;primaryKey" json:"product_id" form:"product_id"` + SupplierID uint `gorm:"column:id_supplier" json:"supplier_id" form:"supplier_id"` + ManufacturerID uint `gorm:"column:id_manufacturer" json:"manufacturer_id" form:"manufacturer_id"` + CategoryDefaultID uint `gorm:"column:id_category_default" json:"category_default_id" form:"category_default_id"` + ShopDefaultID uint `gorm:"column:id_shop_default" json:"shop_default_id" form:"shop_default_id"` + TaxRulesGroupID uint `gorm:"column:id_tax_rules_group" json:"tax_rules_group_id" form:"tax_rules_group_id"` + OnSale uint `gorm:"column:on_sale" json:"on_sale" form:"on_sale"` + OnlineOnly uint `gorm:"column:online_only" json:"online_only" form:"online_only"` + EAN13 string `gorm:"column:ean13;type:varchar(13)" json:"ean13" form:"ean13"` + ISBN string `gorm:"column:isbn;type:varchar(32)" json:"isbn" form:"isbn"` + UPC string `gorm:"column:upc;type:varchar(12)" json:"upc" form:"upc"` + EkoTax float32 `gorm:"column:eko_tax;type:decimal(20,6)" json:"eko_tax" form:"eko_tax"` + Quantity uint `gorm:"column:quantity" json:"quantity" form:"quantity"` + MinimalQuantity uint `gorm:"column:minimal_quantity" json:"minimal_quantity" form:"minimal_quantity"` + LowStockThreshold uint `gorm:"column:low_stock_threshold" json:"low_stock_threshold" form:"low_stock_threshold"` + LowStockAlert uint `gorm:"column:low_stock_alert" json:"low_stock_alert" form:"low_stock_alert"` + Price float32 `gorm:"column:price;type:decimal(20,6)" json:"price" form:"price"` + WholesalePrice float32 `gorm:"column:wholesale_price;type:decimal(20,6)" json:"wholesale_price" form:"wholesale_price"` + Unity string `gorm:"column:unity;type:varchar(255)" json:"unity" form:"unity"` + UnitPriceRatio float32 `gorm:"column:unit_price_ratio;type:decimal(20,6)" json:"unit_price_ratio" form:"unit_price_ratio"` + UnitID uint `gorm:"column:id_unit;primaryKey" json:"unit_id" form:"unit_id"` + AdditionalShippingCost float32 `gorm:"column:additional_shipping_cost;type:decimal(20,2)" json:"additional_shipping_cost" form:"additional_shipping_cost"` + Reference string `gorm:"column:reference;type:varchar(64)" json:"reference" form:"reference"` + SupplierReference string `gorm:"column:supplier_reference;type:varchar(64)" json:"supplier_reference" form:"supplier_reference"` + Location string `gorm:"column:location;type:varchar(64)" json:"location" form:"location"` + + Width float32 `gorm:"column:width;type:decimal(20,6)" json:"width" form:"width"` + Height float32 `gorm:"column:height;type:decimal(20,6)" json:"height" form:"height"` + Depth float32 `gorm:"column:depth;type:decimal(20,6)" json:"depth" form:"depth"` + Weight float32 `gorm:"column:weight;type:decimal(20,6)" json:"weight" form:"weight"` + OutOfStock uint `gorm:"column:out_of_stock" json:"out_of_stock" form:"out_of_stock"` + AdditionalDeliveryTimes uint `gorm:"column:additional_delivery_times" json:"additional_delivery_times" form:"additional_delivery_times"` + QuantityDiscount uint `gorm:"column:quantity_discount" json:"quantity_discount" form:"quantity_discount"` + Customizable uint `gorm:"column:customizable" json:"customizable" form:"customizable"` + UploadableFiles uint `gorm:"column:uploadable_files" json:"uploadable_files" form:"uploadable_files"` + TextFields uint `gorm:"column:text_fields" json:"text_fields" form:"text_fields"` + + Active uint `gorm:"column:active" json:"active" form:"active"` + RedirectType string `gorm:"column:redirect_type;type:enum('','404','301-product','302-product','301-category','302-category')" json:"redirect_type" form:"redirect_type"` + TypeRedirectedID int `gorm:"column:id_type_redirected" json:"type_redirected_id" form:"type_redirected_id"` + AvailableForOrder uint `gorm:"column:available_for_order" json:"available_for_order" form:"available_for_order"` + AvailableDate string `gorm:"column:available_date;type:date" json:"available_date" form:"available_date"` + ShowCondition uint `gorm:"column:show_condition" json:"show_condition" form:"show_condition"` + Condition string `gorm:"column:condition;type:enum('new','used','refurbished')" json:"condition" form:"condition"` + ShowPrice uint `gorm:"column:show_price" json:"show_price" form:"show_price"` + + Indexed uint `gorm:"column:indexed" json:"indexed" form:"indexed"` + Visibility string `gorm:"column:visibility;type:enum('both','catalog','search','none')" json:"visibility" form:"visibility"` + CacheIsPack uint `gorm:"column:cache_is_pack" json:"cache_is_pack" form:"cache_is_pack"` + CacheHasAttachments uint `gorm:"column:cache_has_attachments" json:"cache_has_attachments" form:"cache_has_attachments"` + IsVirtual uint `gorm:"column:is_virtual" json:"is_virtual" form:"is_virtual"` + CacheDefaultAttribute uint `gorm:"column:cache_default_attribute" json:"cache_default_attribute" form:"cache_default_attribute"` + DateAdd string `gorm:"column:date_add;type:datetime" json:"date_add" form:"date_add"` + DateUpd string `gorm:"column:date_upd;type:datetime" json:"date_upd" form:"date_upd"` + AdvancedStockManagement uint `gorm:"column:advanced_stock_management" json:"advanced_stock_management" form:"advanced_stock_management"` + PackStockType uint `gorm:"column:pack_stock_type" json:"pack_stock_type" form:"pack_stock_type"` + State uint `gorm:"column:state" json:"state" form:"state"` + DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"` +} + +type ProductFilters struct { + Sort string `json:"sort,omitempty" query:"sort,omitempty" example:"price,asc;name,desc"` // sort rule + ProductID uint `json:"product_id,omitempty" query:"product_id,omitempty" example:"1"` + Price float64 `json:"price,omitempty" query:"price,omitempty" example:"123.45"` + Name string `json:"name,omitempty" query:"name,omitempty" example:"Sztabka Złota Britannia"` + CategoryID uint `json:"category_id,omitempty" query:"category_id,omitempty" example:"2"` + CategoryName string `json:"category_name,omitempty" query:"category_name,omitempty" example:"Złote Monety"` + Features FeatVal `query:"features,omitempty"` + ActiveSale bool `query:"sale_active,omitempty"` + InStock uint `query:"stock,omitempty"` +} + +type FeatVal = map[uint][]uint diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index d6c070c..395cda6 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "strconv" "time" "git.ma-al.com/goc_daniel/b2b/app/config" @@ -13,9 +14,13 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/emailService" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/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/dlclark/regexp2" + "github.com/gofiber/fiber/v3" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -27,8 +32,9 @@ type JWTClaims struct { Email string `json:"email"` Username string `json:"username"` Role model.CustomerRole `json:"customer_role"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + CartsIDs []uint `json:"carts_ids"` + LangID uint `json:"lang_id"` + CountryID uint `json:"country_id"` jwt.RegisteredClaims } @@ -149,7 +155,8 @@ func (s *AuthService) Register(req *model.RegisterRequest) error { EmailVerified: false, EmailVerificationToken: token, EmailVerificationExpires: &expiresAt, - Lang: req.Lang, + LangID: req.LangID, + CountryID: req.CountryID, } if err := s.db.Create(&user).Error; err != nil { @@ -158,10 +165,11 @@ func (s *AuthService) Register(req *model.RegisterRequest) error { // Send verification email baseURL := config.Get().App.BaseURL - lang := req.Lang - if lang == "" { - lang = "en" // Default to English + lang, err := s.GetLangISOCode(req.LangID) + if err != nil { + return responseErrors.ErrBadLangID } + if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil { // Log error but don't fail registration - user can request resend _ = err @@ -266,10 +274,11 @@ func (s *AuthService) RequestPasswordReset(emailAddr string) error { // Send password reset email baseURL := config.Get().App.BaseURL - lang := "en" - if user.Lang != "" { - lang = user.Lang + lang, err := s.GetLangISOCode(user.LangID) + if err != nil { + return responseErrors.ErrBadLangID } + if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil { _ = err } @@ -471,13 +480,24 @@ func hashToken(raw string) string { // generateAccessToken generates a short-lived JWT access token func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) { + _, err := s.GetLangISOCode(user.LangID) + if err != nil { + return "", responseErrors.ErrBadLangID + } + + err = s.CheckIfCountryExists(user.CountryID) + if err != nil { + return "", responseErrors.ErrBadCountryID + } + claims := JWTClaims{ UserID: user.ID, Email: user.Email, Username: user.Email, Role: user.Role, - FirstName: user.FirstName, - LastName: user.LastName, + CartsIDs: []uint{}, + LangID: user.LangID, + CountryID: user.CountryID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -488,6 +508,84 @@ func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) return token.SignedString([]byte(s.config.JWTSecret)) } +func (s *AuthService) UpdateJWTToken(c fiber.Ctx) error { + // Get user ID from JWT claims in context (set by auth middleware) + claims, ok := c.Locals("jwt_claims").(*JWTClaims) + if !ok || claims == nil { + return c.Status(fiber.StatusUnauthorized). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrNotAuthenticated))) + } + + var user model.Customer + // Find user by ID + if err := s.db.First(&user, claims.UserID).Error; err != nil { + return err + } + + // Parse language and country_id from query params + langIDStr := c.Query("lang_id") + + var langID uint + if langIDStr != "" { + parsedID, err := strconv.ParseUint(langIDStr, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadLangID))) + } + langID = uint(parsedID) + + _, err = s.GetLangISOCode(langID) + if err != nil { + return responseErrors.ErrBadLangID + } else { + user.LangID = langID + } + } + + countryIDStr := c.Query("country_id") + + var countryID uint + if countryIDStr != "" { + parsedID, err := strconv.ParseUint(countryIDStr, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadCountryID))) + } + countryID = uint(parsedID) + + err = s.CheckIfCountryExists(countryID) + if err != nil { + return responseErrors.ErrBadCountryID + } else { + user.CountryID = countryID + } + } + + // Update choice and get new token using AuthService + newToken, err := s.generateAccessToken(&user) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + // Save the updated user + if err := s.db.Save(&user).Error; err != nil { + return fmt.Errorf("database error: %w", err) + } + + // Set the new JWT cookie + cookie := new(fiber.Cookie) + cookie.Name = "jwt_token" + cookie.Value = newToken + cookie.HTTPOnly = true + cookie.Secure = true + cookie.SameSite = fiber.CookieSameSiteLaxMode + + c.Cookie(cookie) + + return c.JSON(response.Make(&fiber.Map{"token": newToken}, 0, i18n.T_(c, response.Message_OK))) +} + // generateVerificationToken generates a random verification token func (s *AuthService) generateVerificationToken() (string, error) { bytes := make([]byte, 32) @@ -507,3 +605,29 @@ func validatePassword(password string) error { return nil } + +func (s *AuthService) GetLangISOCode(langID uint) (string, error) { + var lang string + + if langID == 0 { // retrieve the default lang + err := db.DB.Table("b2b_language").Where("is_default = ?", 1).First(lang).Error + return lang, err + } else { + err := db.DB.Table("b2b_language").Where("id = ?", langID).Where("active = ?", 1).First(lang).Error + return lang, err + } +} + +func (s *AuthService) CheckIfCountryExists(countryID uint) error { + var count int64 + + err := db.DB.Table("b2b_countries").Where("id = ?", countryID).Count(&count).Error + + if err != nil { + return err + } + if count == 0 { + return responseErrors.ErrBadCountryID + } + return nil +} diff --git a/app/service/authService/google_oauth.go b/app/service/authService/google_oauth.go index fda609b..d56ebf8 100644 --- a/app/service/authService/google_oauth.go +++ b/app/service/authService/google_oauth.go @@ -153,7 +153,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. Role: model.RoleUser, IsActive: true, EmailVerified: true, - Lang: "en", + LangID: 2, } if err := s.db.Create(&newUser).Error; err != nil { diff --git a/app/service/langsAndCountriesService/langsAndCountriesService.go b/app/service/langsAndCountriesService/langsAndCountriesService.go new file mode 100644 index 0000000..3c587b5 --- /dev/null +++ b/app/service/langsAndCountriesService/langsAndCountriesService.go @@ -0,0 +1,26 @@ +package langsAndCountriesService + +import ( + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/repository/langsAndCountriesRepo" +) + +// LangsAndCountriesService literally sends back language and countries information. +type LangsAndCountriesService struct { + repo langsAndCountriesRepo.UILangsAndCountriesRepo +} + +// NewLangsAndCountriesService creates a new LangsAndCountries service +func New() *LangsAndCountriesService { + return &LangsAndCountriesService{ + repo: langsAndCountriesRepo.New(), + } +} + +func (s *LangsAndCountriesService) GetLanguages() ([]model.Language, error) { + return s.repo.GetLanguages() +} + +func (s *LangsAndCountriesService) GetCountriesAndCurrencies() ([]model.Country, error) { + return s.repo.GetCountriesAndCurrencies() +} diff --git a/app/service/listProductsService/listProductsService.go b/app/service/listProductsService/listProductsService.go new file mode 100644 index 0000000..2c99da3 --- /dev/null +++ b/app/service/listProductsService/listProductsService.go @@ -0,0 +1,59 @@ +package listProductsService + +import ( + "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" + "git.ma-al.com/goc_daniel/b2b/repository/listProductsRepo" +) + +type ListProductsService struct { + listProductsRepo listProductsRepo.UIListProductsRepo +} + +func New() *ListProductsService { + return &ListProductsService{ + listProductsRepo: listProductsRepo.New(), + } +} + +func (s *ListProductsService) GetListing(p find.Paging, filters *filters.FiltersList) (find.Found[model.Product], error) { + var products find.Found[model.Product] + + // currencyIso := c.Cookies("currency_iso", "") + // countryIso := c.Cookies("country_iso", "") + + // if overrides["override_currency"] != "" { + // currencyIso = overrides["override_currency"] + // } + // if overrides["override_country"] != "" { + // countryIso = overrides["override_country"] + // } + + products, err := s.listProductsRepo.GetListing(p, filters) + if err != nil { + return products, err + } + + // var loopErr error + // parallel.ForEach(products.Items, func(t model.Product, i int) { + // // products.Items[i].PriceTaxed *= currRate.Rate.InexactFloat64() + // // products.Items[i].PriceTaxed = tiny_util.RoundUpMonetary(products.Items[i].PriceTaxed) + + // if products.Items[i].Name.IsNull() { + // translation, err := s.listProductsRepo.GetTranslation(ctx, products.Items[i].ID, defaults.DefaultLanguageID) + // if err != nil { + // loopErr = err + // return + // } + // products.Items[i].Name = nullable.FromPrimitiveString(translation.Name) + // products.Items[i].DescriptionShort = nullable.FromPrimitiveString(translation.DescriptionShort) + // products.Items[i].LinkRewrite = nullable.FromPrimitiveString(translation.LinkRewrite) + // } + // }) + // if loopErr != nil { + // return products, errs.Handled(span, loopErr, errs.InternalError, errs.ERR_TODO) + // } + + return products, nil +} diff --git a/app/service/productDescriptionService/productDescriptionService.go b/app/service/productDescriptionService/productDescriptionService.go index a8c31d6..4f4f136 100644 --- a/app/service/productDescriptionService/productDescriptionService.go +++ b/app/service/productDescriptionService/productDescriptionService.go @@ -19,7 +19,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/langsService" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" - ProductDescriptionRepo "git.ma-al.com/goc_daniel/b2b/repository/productDescriptionRepo" + "git.ma-al.com/goc_daniel/b2b/repository/productDescriptionRepo" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" @@ -27,7 +27,7 @@ import ( ) type ProductDescriptionService struct { - productDescriptionRepo ProductDescriptionRepo.ProductDescriptionRepo + productDescriptionRepo productDescriptionRepo.UIProductDescriptionRepo ctx context.Context googleCli translate.TranslationClient projectID string @@ -74,10 +74,11 @@ func New() *ProductDescriptionService { option.WithHTTPClient(&http.Client{Timeout: 300 * time.Second})) // five minutes timeout return &ProductDescriptionService{ - ctx: ctx, - openAIClient: openAIClient, - googleCli: *googleCli, - projectID: cfg.GoogleTranslate.ProjectID, + productDescriptionRepo: productDescriptionRepo.New(), + ctx: ctx, + openAIClient: openAIClient, + googleCli: *googleCli, + projectID: cfg.GoogleTranslate.ProjectID, } } diff --git a/app/utils/pagination/pagination.go b/app/utils/pagination/pagination.go deleted file mode 100644 index 34d3125..0000000 --- a/app/utils/pagination/pagination.go +++ /dev/null @@ -1,54 +0,0 @@ -package pagination - -import ( - "gorm.io/gorm" -) - -type Paging struct { - Page uint `json:"page_number" example:"5"` - Elements uint `json:"elements_per_page" example:"30"` -} - -func (p Paging) Offset() int { - return int(p.Elements) * int(p.Page-1) -} - -func (p Paging) Limit() int { - return int(p.Elements) -} - -type Found[T any] struct { - Items []T `json:"items,omitempty"` - Count uint `json:"items_count" example:"56"` -} - -func Paginate[T any](paging Paging, stmt *gorm.DB) (Found[T], error) { - var items []T - var count int64 - - base := stmt.Session(&gorm.Session{}) - - countDB := stmt.Session(&gorm.Session{ - NewDB: true, // critical: do NOT reuse statement - }) - - if err := countDB. - Table("(?) as sub", base). - Count(&count).Error; err != nil { - return Found[T]{}, err - } - - err := base. - Offset(paging.Offset()). - Limit(paging.Limit()). - Find(&items). - Error - if err != nil { - return Found[T]{}, err - } - - return Found[T]{ - Items: items, - Count: uint(count), - }, err -} diff --git a/app/utils/query/filters/filters.go b/app/utils/query/filters/filters.go new file mode 100644 index 0000000..3f7a26d --- /dev/null +++ b/app/utils/query/filters/filters.go @@ -0,0 +1,150 @@ +package filters + +import ( + "fmt" + "strconv" + "strings" + "time" + + "gorm.io/gorm" +) + +type FilterFunction = func(*gorm.DB) *gorm.DB + +func Where(statement string, args ...interface{}) Filter { + filt := func(db *gorm.DB) *gorm.DB { + return db.Where(statement, args...) + } + return Filter{ + category: WHERE_FILTER, + filter: filt, + } +} + +func StructToWhereScope[T any](model T) Filter { + filt := func(db *gorm.DB) *gorm.DB { + return db.Where(model) + } + return Filter{ + category: WHERE_FILTER, + filter: filt, + } +} + +func Order(field string, desc bool) Filter { + var filt FilterFunction + if desc { + filt = func(d *gorm.DB) *gorm.DB { + return d.Order(field + " DESC") + } + } else { + filt = func(d *gorm.DB) *gorm.DB { + return d.Order(field) + } + } + return Filter{ + category: ORDER_FILTER, + filter: filt, + } +} + +func WhereFromStrings(column, conditionOperator, value string) Filter { + var filt func(*gorm.DB) *gorm.DB + + if strings.HasPrefix(value, "~") { + value = strings.ReplaceAll(value, "~", "") + + filt = func(d *gorm.DB) *gorm.DB { + return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%") + + } + + return Filter{ + category: LIKE_FILTER, + filter: filt, + } + } + + if strings.Contains(value, "]") && strings.Contains(value, "[") { + period := strings.ReplaceAll(value, "[", "") + period = strings.ReplaceAll(period, "]", "") + vals := strings.Split(period, ",") + if len(vals) == 2 { + from, errA := time.Parse("2006-01-02", vals[0]) + to, errB := time.Parse("2006-01-02", vals[1]) + if errA == nil && errB == nil { + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+` BETWEEN ? AND ?`, from.Format("2006-01-02"), to.Format("2006-01-02")) + } + + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } else { + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+` BETWEEN ? AND ?`, vals[0], vals[1]) + } + + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } + + } + } + + if conditionOperator == "LIKE" { + value = fmt.Sprintf("%%%s%%", value) + } + + // in future add more grouping functions + if strings.Contains(strings.ToLower(column), "count(") { + filt = func(d *gorm.DB) *gorm.DB { + return d.Having(column+` `+conditionOperator+` ?`, value) + } + + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } + + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+` `+conditionOperator+` ?`, i) + } + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } + if f, err := strconv.ParseFloat(value, 64); err == nil { + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+` `+conditionOperator+` ?`, f) + } + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } + if b, err := strconv.ParseBool(value); err == nil { + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+` `+conditionOperator+` ?`, b) + } + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } + + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+` `+conditionOperator+` ?`, value) + } + + return Filter{ + category: WHERE_FILTER, + filter: filt, + } +} diff --git a/app/utils/query/filters/filters_list.go b/app/utils/query/filters/filters_list.go new file mode 100644 index 0000000..3ee31e1 --- /dev/null +++ b/app/utils/query/filters/filters_list.go @@ -0,0 +1,107 @@ +package filters + +import ( + "fmt" + + "github.com/samber/lo" + "gorm.io/gorm" +) + +// Use one of declared in the package constants to instantiate the type. +type filterCategory = string + +// Enumaration of known types of filters. The assumption is that all filters +// belonging to a single category (type) can be used together at a particular +// step in the query process. +const ( + // Should be safe to use at any step of longer query series to reduce the + // number of results. If it is not, choose a different filter type + WHERE_FILTER filterCategory = "where" + + // An like filter + LIKE_FILTER filterCategory = "where" + + // An order by clause which can be used at any final step of a complex query + // to change the order of results. + ORDER_FILTER filterCategory = "order" + // TODO: document the special case of filters on products + FEAT_VAL_PRODUCT_FILTER filterCategory = "featval_product" +) + +type Filter struct { + category filterCategory + filter func(*gorm.DB) *gorm.DB +} + +func NewFilter(category filterCategory, filter func(*gorm.DB) *gorm.DB) Filter { + return Filter{ + category: category, + filter: filter, + } +} + +type FiltersList struct { + filters []Filter +} + +func NewFiltersList() FiltersList { + return FiltersList{ + // we allocate some extra space beforehand to reduce the overhead of resizing + filters: make([]Filter, 0, 3), + } +} + +func NewListWithFilter(filt Filter) FiltersList { + l := NewFiltersList() + l.filters = append(l.filters, filt) + return l +} + +func (f *FiltersList) NewFilter(category filterCategory, filter func(*gorm.DB) *gorm.DB) { + f.filters = append(f.filters, NewFilter(category, filter)) +} + +func (f *FiltersList) Append(filter ...Filter) { + f.filters = append(f.filters, filter...) +} + +// Return all stored filters as []func(*gorm.DB)*gorm.DB +func (f *FiltersList) All() []func(*gorm.DB) *gorm.DB { + return lo.Map(f.filters, func(filt Filter, _ int) func(*gorm.DB) *gorm.DB { + return filt.filter + }) +} + +func (f *FiltersList) OfCategory(cat filterCategory) []func(*gorm.DB) *gorm.DB { + return lo.Map(lo.Filter(f.filters, func(v Filter, _ int) bool { + return v.category == cat + }), func(el Filter, _ int) func(*gorm.DB) *gorm.DB { + return el.filter + }) +} + +func (f *FiltersList) ApplyAll(d *gorm.DB) { + d.Scopes(f.All()...) +} + +func (f *FiltersList) Apply(d *gorm.DB, cat filterCategory) { + d.Scopes(f.OfCategory(cat)...) +} + +func (f *FiltersList) Merge(another FiltersList) { + f.filters = append(f.filters, another.filters...) +} + +// An implementation of stringer on FiltersList that is meant rather to be used +// for debug display +func (f FiltersList) String() string { + groupMap := lo.GroupBy(f.filters, func(t Filter) string { + return t.category + }) + res := "FiltersList{" + for key := range groupMap { + res += fmt.Sprintf(" \"%s\": %d filters", key, len(groupMap[key])) + } + res += " }" + return res +} diff --git a/app/utils/query/find/find.go b/app/utils/query/find/find.go new file mode 100644 index 0000000..6f27c6e --- /dev/null +++ b/app/utils/query/find/find.go @@ -0,0 +1,159 @@ +package find + +import ( + "errors" + "reflect" + "strings" + + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "gorm.io/gorm" +) + +type Paging struct { + Page uint `json:"page_number" example:"5"` + Elements uint `json:"elements_per_page" example:"30"` +} + +func (p Paging) Offset() int { + return int(p.Elements) * int(p.Page-1) +} + +func (p Paging) Limit() int { + return int(p.Elements) +} + +type Found[T any] struct { + Items []T `json:"items,omitempty"` + Count uint `json:"items_count" example:"56"` + Spec map[string]interface{} `json:"spec,omitempty"` +} + +// Wraps given query adding limit, offset clauses and SQL_CALC_FOUND_ROWS to it +// and running SELECT FOUND_ROWS() afterwards to fetch the total number +// (ignoring LIMIT) of results. The final results are wrapped into the +// [find.Found] type. +func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error) { + var items []T + var count uint64 + + // stmt.Debug() + + err := stmt. + Clauses(SqlCalcFound()). + Offset(paging.Offset()). + Limit(paging.Limit()). + Find(&items). + Error + if err != nil { + return Found[T]{}, err + } + + countInterface, ok := stmt.Get(FOUND_ROWS_CTX_KEY) + if !ok { + return Found[T]{}, errors.New(FOUND_ROWS_CTX_KEY + " value was not found in the gorm db context") + } + if count, ok = countInterface.(uint64); !ok { + return Found[T]{}, errors.New("failed to cast value under " + FOUND_ROWS_CTX_KEY + " to uint64") + } + + columnsSpec := GetColumnsSpec[T](langID) + + return Found[T]{ + Items: items, + Count: uint(count), + Spec: map[string]interface{}{ + "columns": columnsSpec, + }, + }, err +} + +// GetColumnsSpec[T any] generates a column specification map for a given struct type T. +// Each key is the JSON property name, and the value is a map containing: +// - "filter_type": suggested filter type based on field type or `filt` tag +// - To disable filtering for a field, set `filt:"none"` in the struct tag +// - "sortable": currently hardcoded to true +// - "order": order of fields as they appear +// +// Returns nil if T is not a struct. +func GetColumnsSpec[T any](langID uint) map[string]map[string]interface{} { + result := make(map[string]map[string]interface{}) + typ := reflect.TypeOf((*T)(nil)).Elem() + if typ.Kind() != reflect.Struct { + return nil + } + order := 1 + processStructFields(langID, typ, result, &order) + return result +} + +type FilterType string + +const ( + FilterTypeRange FilterType = "range" + FilterTypeTimerange FilterType = "timerange" + FilterTypeLike FilterType = "like" + FilterTypeSwitch FilterType = "switch" + FilterTypeNone FilterType = "none" +) + +func isValidFilterType(ft string) bool { + switch FilterType(ft) { + case FilterTypeRange, FilterTypeTimerange, FilterTypeLike, FilterTypeSwitch: + return true + default: + return false + } +} + +// processStructFields recursively processes struct fields to populate the result map. +// It handles inline structs, reads `json` and `filt` tags, and determines filter types +// based on the field type when `filt` tag is absent. +// `order` is incremented for each field to track field ordering. +func processStructFields(langID uint, typ reflect.Type, result map[string]map[string]interface{}, order *int) { + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + jsonTag := field.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + propName := strings.Split(jsonTag, ",")[0] + if propName == "" { + propName = field.Name + } + if strings.Contains(jsonTag, ",inline") && field.Type.Kind() == reflect.Struct { + processStructFields(langID, field.Type, result, order) + continue + } + + filterType := field.Tag.Get("filt") + if filterType != "" { + if !isValidFilterType(filterType) { + filterType = string(FilterTypeNone) + } + } else { + fieldType := field.Type.String() + switch { + case strings.HasPrefix(fieldType, "int"), strings.HasPrefix(fieldType, "uint"), strings.HasPrefix(fieldType, "float"), strings.HasPrefix(fieldType, "decimal.Decimal"): + filterType = string(FilterTypeRange) + case strings.Contains(fieldType, "Time"): + filterType = string(FilterTypeTimerange) + case fieldType == "string": + filterType = string(FilterTypeLike) + case fieldType == "bool": + filterType = string(FilterTypeSwitch) + default: + filterType = string(FilterTypeNone) + } + } + + result[propName] = map[string]interface{}{ + "filter_type": filterType, + "sortable": func() bool { val, ok := field.Tag.Lookup("sortable"); return !ok || val == "true" }(), + "order": *order, + "title": i18n.T___(langID, field.Tag.Get("title")), + "display": func() bool { val, ok := field.Tag.Lookup("display"); return !ok || val == "true" }(), + "hidden": field.Tag.Get("hidden") == "true", + } + *order++ + } +} diff --git a/app/utils/query/find/found_rows_callback.go b/app/utils/query/find/found_rows_callback.go new file mode 100644 index 0000000..705660b --- /dev/null +++ b/app/utils/query/find/found_rows_callback.go @@ -0,0 +1,46 @@ +package find + +import ( + "errors" + + "gorm.io/gorm" +) + +const ( + // Key under which result of `SELECT FOUND_ROWS()` should be stored in the + // driver context. + FOUND_ROWS_CTX_KEY = "maal:found_rows" + // Suggested name under which [find.FoundRowsCallback] can be registered. + FOUND_ROWS_CALLBACK = "maal:found_rows" +) + +// Searches query clauses for presence of `SQL_CALC_FOUND_ROWS` and runs `SELECT +// FOUND_ROWS();` right after the query containing such clause. The result is +// put in the driver context under key [find.FOUND_ROWS_CTX_KEY]. For the +// callback to work correctly it must be registered and executed before the +// `gorm:preload` callback. +func FoundRowsCallback(d *gorm.DB) { + if _, ok := d.Statement.Clauses["SELECT"].AfterNameExpression.(sqlCalcFound); ok { + var count uint64 + sqlDB, err := d.DB() + if err != nil { + _ = d.AddError(err) + return + } + res := sqlDB.QueryRowContext(d.Statement.Context, "SELECT FOUND_ROWS();") + if res == nil { + _ = d.AddError(errors.New(`fialed to issue SELECT FOUND_ROWS() query`)) + return + } + if res.Err() != nil { + _ = d.AddError(res.Err()) + return + } + err = res.Scan(&count) + if err != nil { + _ = d.AddError(err) + return + } + d.Set(FOUND_ROWS_CTX_KEY, count) + } +} diff --git a/app/utils/query/find/sql_calc_rows.go b/app/utils/query/find/sql_calc_rows.go new file mode 100644 index 0000000..419f948 --- /dev/null +++ b/app/utils/query/find/sql_calc_rows.go @@ -0,0 +1,51 @@ +package find + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type sqlCalcFound struct{} + +// Creates a new Clause which adds `SQL_CALC_FOUND_ROWS` right after `SELECT`. +// If [find.FoundRowsCallback] is registered the presence of this clause will +// cause `FOUND_ROWS()` result to be available in the driver context. +func SqlCalcFound() sqlCalcFound { + return sqlCalcFound{} +} + +// Implements gorm's [clause.Clause] +func (sqlCalcFound) Name() string { + return "SQL_CALC_FOUND_ROWS" +} + +// Implements gorm's [clause.Clause] +func (sqlCalcFound) Build(builder clause.Builder) { + _, _ = builder.WriteString("SQL_CALC_FOUND_ROWS") +} + +// Implements gorm's [clause.Clause] +func (sqlCalcFound) MergeClause(cl *clause.Clause) { +} + +// Implements [gorm.StatementModifier] +func (calc sqlCalcFound) ModifyStatement(stmt *gorm.Statement) { + selectClause := stmt.Clauses["SELECT"] + if selectClause.AfterNameExpression == nil { + selectClause.AfterNameExpression = calc + } else if _, ok := selectClause.AfterNameExpression.(sqlCalcFound); !ok { + selectClause.AfterNameExpression = exprs{selectClause.AfterNameExpression, calc} + } + stmt.Clauses["SELECT"] = selectClause +} + +type exprs []clause.Expression + +func (exprs exprs) Build(builder clause.Builder) { + for idx, expr := range exprs { + if idx > 0 { + _ = builder.WriteByte(' ') + } + expr.Build(builder) + } +} diff --git a/app/utils/query/query_params/key_mapping.go b/app/utils/query/query_params/key_mapping.go new file mode 100644 index 0000000..c6acad8 --- /dev/null +++ b/app/utils/query/query_params/key_mapping.go @@ -0,0 +1,43 @@ +package query_params + +import ( + "fmt" + "reflect" + "strings" + + mreflect "git.ma-al.com/goc_daniel/b2b/app/utils/reflect" +) + +// MapParamsKeyToDbColumn will attempt to map provided key into unique (prefixed +// with table) column name. It will do so using following priority of sources of +// mapping: +// 1. `formColumnMapping` argument. If the mapped values contain a dot, the part +// before the dot will be used for the table name. Otherwise the table name will +// be derived from the generic parameter `T`. +// 2. json tags of provided as generic `T` struct. The table name will be also +// derived from the generic if not provided as dot prefix. +func MapParamsKeyToDbColumn[DEFAULT_TABLE_MODEL any](key string, mapping ...map[string]string) (string, error) { + ERR := "Failed to find appropiate mapping from form field to database column for key: '%s', and default table name: '%s'" + + if len(mapping) > 0 { + if field, ok := (mapping[0])[key]; ok { + return field, nil + } + } else { + var t DEFAULT_TABLE_MODEL + if table, field, ok := strings.Cut(key, "."); ok { + if column, err := mreflect.GetGormColumnFromJsonField(field, reflect.TypeOf(t)); err == nil { + return table + "." + column, nil + } + return "", fmt.Errorf(ERR, key, table) + } else { + table := mreflect.GetTableName[DEFAULT_TABLE_MODEL]() + if column, err := mreflect.GetGormColumnFromJsonField(key, reflect.TypeOf(t)); err == nil { + return table + "." + column, nil + } else { + return "", fmt.Errorf(ERR, key, table) + } + } + } + return "", fmt.Errorf(ERR, key, mreflect.GetTableName[DEFAULT_TABLE_MODEL]()) +} diff --git a/app/utils/query/query_params/params_query.go b/app/utils/query/query_params/params_query.go new file mode 100644 index 0000000..9f5c64a --- /dev/null +++ b/app/utils/query/query_params/params_query.go @@ -0,0 +1,63 @@ +package query_params + +import ( + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" + "github.com/gofiber/fiber/v3" +) + +var FunctionalQueryParams = []string{ + // Used to specidy order of results + "sort", + // Used to specify page of search resulst + "p", + // Used to specify number of elements on a page + "elems", + // Used to specify allowed values of features on products + "values", +} + +func ParseFilters[T any](c fiber.Ctx, formColumnMappimg ...map[string]string) (find.Paging, *filters.FiltersList, error) { + // field/column based filters + filters, err := ParseFieldFilters[T](c, formColumnMappimg...) + if err != nil { + return find.Paging{}, filters, err + } + // pagination + pageNum, pageSize := ParsePagination(c) + + // ret + return find.Paging{Page: pageNum, Elements: pageSize}, filters, nil +} + +// Parse field related filters from params query. Produces where clauses and +// order rules. +func ParseFieldFilters[T any](c fiber.Ctx, formColumnMapping ...map[string]string) (*filters.FiltersList, error) { + // var model T + list := filters.NewFiltersList() + + whereScopefilters := ParseWhereScopes[T](c, []string{}, formColumnMapping...) + list.Append(whereScopefilters...) + + ord, err := ParseOrdering[T](c, formColumnMapping...) + if err != nil { + return &list, err + } + // addDefaultOrderingIfNeeded(&ord, model) + for i := range ord { + if err == nil { + list.Append(filters.Order(ord[i].Column, ord[i].IsDesc)) + } + } + + return &list, nil +} + +// TODO: Add some source of defaults for pagination size here +func ParsePagination(c fiber.Ctx) (uint, uint) { + pageNum, _ := strconv.ParseInt(c.Query("p", "1"), 10, 64) + pageSize, _ := strconv.ParseInt(c.Query("elems", "30"), 10, 64) + return uint(pageNum), uint(pageSize) +} diff --git a/app/utils/query/query_params/parse_sort.go b/app/utils/query/query_params/parse_sort.go new file mode 100644 index 0000000..02e0c33 --- /dev/null +++ b/app/utils/query/query_params/parse_sort.go @@ -0,0 +1,82 @@ +package query_params + +import ( + "strings" + + "github.com/gofiber/fiber/v3" +) + +type Ordering struct { + Column string + IsDesc bool +} + +func ParseOrdering[T any](c fiber.Ctx, columnMapping ...map[string]string) ([]Ordering, error) { + param := c.Query("sort") + if len(param) < 1 { + return []Ordering{}, nil + } + + rules := strings.Split(param, ";") + var orderings []Ordering + for _, r := range rules { + ord, err := parseOrderingRule[T](r, columnMapping...) + if err != nil { + return orderings, err + } + orderings = append(orderings, ord) + } + return orderings, nil +} + +func parseOrderingRule[T any](rule string, columnMapping ...map[string]string) (Ordering, error) { + var desc bool + if key, descStr, ok := strings.Cut(rule, ","); ok { + switch { + case strings.Compare(descStr, "desc") == 0: + desc = true + case strings.Compare(descStr, "asc") == 0: + desc = false + default: + desc = true + } + if col, err := MapParamsKeyToDbColumn[T](key, columnMapping...); err == nil { + return Ordering{ + Column: col, + IsDesc: desc, + }, nil + } else { + return Ordering{}, err + } + } else { + if col, err := MapParamsKeyToDbColumn[T](key, columnMapping...); err == nil { + return Ordering{ + Column: col, + IsDesc: true, + }, nil + } else { + return Ordering{}, err + } + } +} + +// func addDefaultOrderingIfNeeded[T any](previousOrderings *[]Ordering, model T) { +// newOrderings := new([]Ordering) +// var t T +// if len(*previousOrderings) < 1 { +// if col, err := mreflect.GetGormColumnFromJsonField("id", reflect.TypeOf(t)); err == nil { +// *newOrderings = append(*newOrderings, Ordering{ +// Column: mreflect.GetTableName[T]() + "." + col, +// IsDesc: true, +// }) +// } +// if col, err := mreflect.GetGormColumnFromJsonField("iso_code", reflect.TypeOf(t)); err == nil { +// *newOrderings = append(*newOrderings, Ordering{ +// Column: mreflect.GetTableName[T]() + "." + col, +// IsDesc: false, +// }) +// } +// *newOrderings = append(*newOrderings, *previousOrderings...) +// *previousOrderings = *newOrderings +// } +// } diff --git a/app/utils/query/query_params/where_scope_from_query.go b/app/utils/query/query_params/where_scope_from_query.go new file mode 100644 index 0000000..be4de92 --- /dev/null +++ b/app/utils/query/query_params/where_scope_from_query.go @@ -0,0 +1,75 @@ +package query_params + +import ( + "strings" + + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "github.com/gofiber/fiber/v3" +) + +// ParseWhereScopes will attempt to create where scope query filters from url +// query params. It will map form fields to a database column name using +// `MapParamsKeyToDbColumn` function. +func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMapping ...map[string]string) []filters.Filter { + var parsedFilters []filters.Filter + //nolint + for key, value := range c.Request().URI().QueryArgs().All() { + keyStr := string(key) + valStr := string(value) + + isIgnored := false + for _, ignoredKey := range ignoredKeys { + if keyStr == ignoredKey { + isIgnored = true + break + } + } + if isIgnored { + continue + } + + baseKey, operator := extractOperator(keyStr) + + if col, err := MapParamsKeyToDbColumn[T](baseKey, formColumnMapping...); err == nil { + if strings.HasPrefix(valStr, "~") { + parsedFilters = append(parsedFilters, filters.WhereFromStrings(col, "LIKE", valStr)) + continue + } + + op := resolveOperator(operator) + + parsedFilters = append(parsedFilters, filters.WhereFromStrings(col, op, valStr)) + } + } + + return parsedFilters +} + +func extractOperator(key string) (base string, operatorSuffix string) { + suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq"} + for _, suf := range suffixes { + if strings.HasSuffix(key, suf) { + return strings.TrimSuffix(key, suf), suf[1:] + } + } + return key, "" +} + +func resolveOperator(suffix string) string { + switch suffix { + case "gt": + return ">" + case "gte": + return ">=" + case "lt": + return "<" + case "lte": + return "<=" + case "neq": + return "!=" + case "eq": + return "=" + default: + return "LIKE" + } +} diff --git a/app/utils/query/queryparser/queryparser.go b/app/utils/query/queryparser/queryparser.go new file mode 100644 index 0000000..8d9772e --- /dev/null +++ b/app/utils/query/queryparser/queryparser.go @@ -0,0 +1,37 @@ +package queryparser + +import ( + "regexp" + "strconv" + "strings" + + "github.com/gofiber/fiber/v3" +) + +func ParseQuery(c fiber.Ctx) map[string]interface{} { + queryParams := map[string]interface{}{} + re := regexp.MustCompile(`\?(\w.+)$`) + xx := re.FindAllStringSubmatch(c.Request().URI().String(), -1) + + if len(xx) > 0 { + if len(xx[0]) == 2 { + queryParts := strings.Split(xx[0][1], "&") + for _, q := range queryParts { + qq := strings.Split(q, "=") + if len(qq) == 2 { + if num, err := strconv.ParseInt(qq[1], 10, 64); err == nil { + queryParams[qq[0]] = num + } else if float, err := strconv.ParseFloat(qq[1], 64); err == nil { + queryParams[qq[0]] = float + } else { + queryParams[qq[0]] = qq[1] + } + } else { + queryParams[qq[0]] = true + } + } + } + } + + return queryParams +} diff --git a/app/utils/reflect/reflect.go b/app/utils/reflect/reflect.go new file mode 100644 index 0000000..0280ec4 --- /dev/null +++ b/app/utils/reflect/reflect.go @@ -0,0 +1,90 @@ +package reflect + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "git.ma-al.com/goc_daniel/b2b/app/db" +) + +// TODO: instead of matching with string.Contains use something less error-prone +func checkIfContainsJSON(i int, t reflect.Type, name string) string { + if wholeTag, ok := t.Field(i).Tag.Lookup("json"); ok { + tags := strings.Split(wholeTag, ",") + for _, tag := range tags { + if name == strings.TrimSpace(tag) { + return db.DB.NamingStrategy.ColumnName(t.Name(), t.Field(i).Name) + } + } + } + return "" +} + +// Not tail recursive but should do fine. Goes only as deep as the hierarchy of +// inlined structs. +// TODO: improve used internally checkIfContainsJSON +func GetGormColumnFromJsonField(jsonName string, t reflect.Type) (string, error) { + var res string + for i := range make([]bool, t.NumField()) { + if tag, ok := t.Field(i).Tag.Lookup("json"); ok && strings.Contains(tag, "inline") { + var err error + res, err = GetGormColumnFromJsonField(jsonName, t.Field(i).Type) + if err != nil { + return "", fmt.Errorf("no field of struct %q has a name %q in its json form", t.Name(), jsonName) + } + + } else { + res = checkIfContainsJSON(i, t, jsonName) + } + if res != "" { + return res, nil + } + } + return "", fmt.Errorf("no field of struct %q has a name %q in its json form", t.Name(), jsonName) +} + +func GetTableName[T any]() string { + var model T + typ := reflect.TypeOf(model).Name() + return db.DB.NamingStrategy.TableName(typ) +} + +func GetParamFromFieldTag[T any](object T, fieldname string, tagname string, paramname string) string { + if table, ok := reflect.TypeOf(object).FieldByName(fieldname); ok { + if t, ok := table.Tag.Lookup(tagname); ok { + if paramname == "" { + return t + } + re := regexp.MustCompile(`(?m)` + paramname + `:(\w*)`) + f := re.FindAllStringSubmatch(t, -1) + if len(re.FindAllStringSubmatch(t, -1)) > 0 { + return f[0][1] + } + } + } + return "" +} + +func GetPrimaryKey[T any](item T) string { + var search func(T) string = func(item T) string { + val := reflect.ValueOf(item) + typ := reflect.TypeOf(item) + for i := 0; i < val.NumField(); i++ { + if gg, ok := typ.Field(i).Tag.Lookup("gorm"); ok { + xx := strings.Split(gg, ";") + for _, t := range xx { + if strings.HasPrefix(strings.ToLower(t), "primarykey") { + return db.DB.NamingStrategy.TableName(typ.Field(i).Name) + } + } + } + if val.Field(i).Type().String() == "db.Model" { + return db.DB.NamingStrategy.TableName("ID") + } + } + return "" + } + return search(item) +} diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 1b31433..da44d11 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -25,6 +25,8 @@ var ( ErrEmailRequired = errors.New("email is required") ErrEmailPasswordRequired = errors.New("email and password are required") ErrRefreshTokenRequired = errors.New("refresh token is required") + ErrBadLangID = errors.New("bad language id") + ErrBadCountryID = errors.New("bad country id") // Typed errors for password reset ErrInvalidResetToken = errors.New("invalid reset token") @@ -43,6 +45,9 @@ var ( ErrInvalidXHTML = errors.New("text is not in xhtml format") ErrAIResponseFail = errors.New("AI responded with failure") ErrAIBadOutput = errors.New("AI response does not obey the format") + + // Typed errors for product list handler + ErrBadPaging = errors.New("bad or missing paging attribute value in header") ) // Error represents an error with HTTP status code @@ -95,6 +100,10 @@ func GetErrorCode(c fiber.Ctx, err error) string { return i18n.T_(c, "error.err_token_required") case errors.Is(err, ErrRefreshTokenRequired): return i18n.T_(c, "error.err_refresh_token_required") + case errors.Is(err, ErrBadLangID): + return i18n.T_(c, "error.err_bad_lang_id") + case errors.Is(err, ErrBadCountryID): + return i18n.T_(c, "error.err_bad_country_id") case errors.Is(err, ErrInvalidResetToken): return i18n.T_(c, "error.err_invalid_reset_token") @@ -123,6 +132,9 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrAIBadOutput): return i18n.T_(c, "error.err_ai_bad_output") + case errors.Is(err, ErrBadPaging): + return i18n.T_(c, "error.err_bad_paging") + default: return i18n.T_(c, "error.err_internal_server_error") } @@ -145,6 +157,8 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrEmailPasswordRequired), errors.Is(err, ErrTokenRequired), errors.Is(err, ErrRefreshTokenRequired), + errors.Is(err, ErrBadLangID), + errors.Is(err, ErrBadCountryID), errors.Is(err, ErrPasswordsDoNotMatch), errors.Is(err, ErrTokenPasswordRequired), errors.Is(err, ErrInvalidResetToken), @@ -154,7 +168,8 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrBadAttribute), errors.Is(err, ErrBadField), - errors.Is(err, ErrInvalidXHTML): + errors.Is(err, ErrInvalidXHTML), + errors.Is(err, ErrBadPaging): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict diff --git a/go.mod b/go.mod index d11abf3..0dc9c19 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/openai/openai-go/v3 v3.28.0 + github.com/samber/lo v1.53.0 golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.247.0 diff --git a/go.sum b/go.sum index 9bef9b6..3e65cda 100644 --- a/go.sum +++ b/go.sum @@ -124,6 +124,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index e335d0f..721b18e 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -24,7 +24,7 @@ INSERT IGNORE INTO b2b_language 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.865', '2026-03-02 21:24:36.779730', NULL, 'Čeština', 'cs', 'cs', '__-__-____', '__-__', 0, 0, 1, '🇨🇿'), + (3, '2022-09-16 17:10:02.865', '2026-03-02 21:24:36.779730', NULL, 'Čeština', 'cs', 'cs', '__-__-____', '__-__', 0, 0, 0, '🇨🇿'), (4, '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 ( @@ -119,8 +119,26 @@ VALUES (1, 'admin@ma-al.com', '$2a$10$Owy9DjrS0l3Fz4XoOvh5pulgmOMqdwXmb7hYE9BovnSuWS2plGr82', 'Super', 'Admin', 'admin', 'local', '', '', 1, 1, NULL, NULL, '', NULL, NULL, NULL, 'pl', '2026-03-02 16:55:10.252740', '2026-03-02 16:55:10.252740', NULL); ALTER TABLE b2b_customers AUTO_INCREMENT = 1; +-- countries +CREATE TABLE IF NOT EXISTS b2b_countries ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(128) NOT NULL, + currency INT UNSIGNED NOT NULL, + flag VARCHAR(16) NOT NULL, + CONSTRAINT fk_countries_currency FOREIGN KEY (currency) REFERENCES ps_currency(id_currency) ON DELETE RESTRICT ON UPDATE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO b2b_countries + (id, name, currency, flag) +VALUES + (1, 'Polska', 1, '🇵🇱'), + (2, 'England', 2, '🇬🇧'), + (3, 'Čeština', 2, '🇨🇿'), + (4, 'Deutschland', 2, '🇩🇪'); + -- +goose Down +DROP TABLE IF EXISTS b2b_countries; DROP TABLE IF EXISTS b2b_language; DROP TABLE IF EXISTS b2b_components; DROP TABLE IF EXISTS b2b_scopes; diff --git a/repository/langsAndCountriesRepo/langsAndCountriesRepo.go b/repository/langsAndCountriesRepo/langsAndCountriesRepo.go new file mode 100644 index 0000000..22fbacc --- /dev/null +++ b/repository/langsAndCountriesRepo/langsAndCountriesRepo.go @@ -0,0 +1,36 @@ +package langsAndCountriesRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UILangsAndCountriesRepo interface { + GetLanguages() ([]model.Language, error) + GetCountriesAndCurrencies() ([]model.Country, error) +} + +type LangsAndCountriesRepo struct{} + +func New() UILangsAndCountriesRepo { + return &LangsAndCountriesRepo{} +} + +func (repo *LangsAndCountriesRepo) GetLanguages() ([]model.Language, error) { + var languages []model.Language + + err := db.DB.Table("b2b_language").Scan(&languages).Error + + return languages, err +} + +func (repo *LangsAndCountriesRepo) GetCountriesAndCurrencies() ([]model.Country, error) { + var countries []model.Country + + err := db.DB.Table("b2b_countries"). + Select("b2b_countries.id, b2b_countries.name, b2b_countries.flag, ps_currency.id as id_currency, ps_currency.name as currency_name, ps_currency.iso_code as currency_iso_code"). + Joins("JOIN ps_currency ON ps_currency.id = b2b_countries.currency"). + Scan(&countries).Error + + return countries, err +} diff --git a/repository/listProductsRepo/listProductsRepo.go b/repository/listProductsRepo/listProductsRepo.go new file mode 100644 index 0000000..98dd318 --- /dev/null +++ b/repository/listProductsRepo/listProductsRepo.go @@ -0,0 +1,58 @@ +package listProductsRepo + +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 UIListProductsRepo interface { + GetListing(p find.Paging, filt *filters.FiltersList) (find.Found[model.Product], error) +} + +type ListProductsRepo struct{} + +func New() UIListProductsRepo { + return &ListProductsRepo{} +} + +func (repo *ListProductsRepo) GetListing(p find.Paging, filt *filters.FiltersList) (find.Found[model.Product], error) { + var listing []model.Product + var total int64 + + // Apply filters here + q := db.DB.Table("ps_product") + + // var resultIDs []uint + // q := db.DB. + // // SQL_CALC_FOUND_ROWS is a neat trick which works on MariaDB and + // // MySQL. It works when followed by `SELECT FOUND_ROWS();`. To learn + // // more see: https://mariarawmodel.com/kb/en/found_rows/ + // // WARN: This might not work on different SQL databases + // Select("DISTINCT SQL_CALC_FOUND_ROWS id"). + // // Debug(). + // Scopes(view.FromDBViewForDisplay(langID, countryIso)). + // Scopes(scopesForFiltersOnDisplay(db.DB, langID, countryIso, filt)). + // Scopes(filt.OfCategory(filters.ORDER_FILTER)...). + // Limit(p.Limit()). + // Offset(p.Offset()) + + err := q.Count(&total).Error + if err != nil { + return find.Found[model.Product]{}, err + } + + err = q. + Limit(p.Limit()). + Offset(p.Offset()). + Scan(&listing).Error + if err != nil { + return find.Found[model.Product]{}, err + } + + return find.Found[model.Product]{ + Items: listing, + Count: uint(total), + }, nil +} diff --git a/repository/productDescriptionRepo/productDescriptionRepo.go b/repository/productDescriptionRepo/productDescriptionRepo.go index 9a08047..7c9e38f 100644 --- a/repository/productDescriptionRepo/productDescriptionRepo.go +++ b/repository/productDescriptionRepo/productDescriptionRepo.go @@ -1,4 +1,4 @@ -package ProductDescriptionRepo +package productDescriptionRepo import ( "fmt"