diff --git a/.env b/.env index 88771c9..d45c67a 100644 --- a/.env +++ b/.env @@ -48,6 +48,10 @@ EMAIL_FROM=test@ma-al.com EMAIL_FROM_NAME=Gitea Manager EMAIL_ADMIN=goc_marek@ma-al.pl +# STORAGE +STORAGE_ROOT=./storage + + I18N_LANGS=en,pl,cs PDF_SERVER_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore index 0408331..d9058fb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ bin/ i18n/*.json *_templ.go tmp/main -test.go \ No newline at end of file +test.go +storage/* +!storage/.gitkeep \ No newline at end of file diff --git a/app/config/config.go b/app/config/config.go index 586a182..1963d38 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -2,8 +2,10 @@ package config import ( "fmt" + "log" "log/slog" "os" + "path/filepath" "reflect" "strconv" "strings" @@ -24,7 +26,8 @@ type Config struct { GoogleTranslate GoogleTranslateConfig Image ImageConfig Cors CorsConfig - MailiSearch MeiliSearchConfig + MeiliSearch MeiliSearchConfig + Storage StorageConfig } type I18n struct { @@ -95,6 +98,10 @@ type EmailConfig struct { Enabled bool `env:"EMAIL_ENABLED,false"` } +type StorageConfig struct { + RootFolder string `env:"STORAGE_ROOT"` +} + type PdfPrinter struct { ServerUrl string `env:"PDF_SERVER_URL,http://localhost:8000"` } @@ -155,7 +162,7 @@ func load() *Config { err = loadEnv(&cfg.OAuth.Google) if err != nil { - slog.Error("not possible to load env variables for outh google : ", err.Error(), "") + slog.Error("not possible to load env variables for oauth google : ", err.Error(), "") } err = loadEnv(&cfg.App) @@ -170,12 +177,12 @@ func load() *Config { err = loadEnv(&cfg.I18n) if err != nil { - slog.Error("not possible to load env variables for email : ", err.Error(), "") + slog.Error("not possible to load env variables for i18n : ", err.Error(), "") } err = loadEnv(&cfg.Pdf) if err != nil { - slog.Error("not possible to load env variables for email : ", err.Error(), "") + slog.Error("not possible to load env variables for pdf : ", err.Error(), "") } err = loadEnv(&cfg.GoogleTranslate) @@ -185,19 +192,25 @@ func load() *Config { err = loadEnv(&cfg.Image) if err != nil { - slog.Error("not possible to load env variables for google translate : ", err.Error(), "") + slog.Error("not possible to load env variables for image : ", err.Error(), "") } err = loadEnv(&cfg.Cors) if err != nil { - slog.Error("not possible to load env variables for google translate : ", err.Error(), "") + slog.Error("not possible to load env variables for cors : ", err.Error(), "") } - err = loadEnv(&cfg.MailiSearch) + err = loadEnv(&cfg.MeiliSearch) if err != nil { - slog.Error("not possible to load env variables for google translate : ", err.Error(), "") + slog.Error("not possible to load env variables for meili search : ", err.Error(), "") } + err = loadEnv(&cfg.Storage) + if err != nil { + slog.Error("not possible to load env variables for storage : ", err.Error(), "") + } + cfg.Storage.RootFolder = ResolveRelativePath(cfg.Storage.RootFolder) + return cfg } @@ -308,6 +321,22 @@ func setValue(field reflect.Value, val string, key string) error { return nil } +func ResolveRelativePath(relativePath string) string { + // get working directory (where program was started) + wd, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + // convert to absolute path + absPath := relativePath + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(wd, absPath) + } + + return filepath.Clean(absPath) +} + func parseEnvTag(tag string) (key string, def *string) { if tag == "" { return "", nil diff --git a/app/delivery/middleware/auth.go b/app/delivery/middleware/auth.go index 30651f8..756e79f 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -1,12 +1,16 @@ package middleware import ( + "encoding/base64" + "strconv" "strings" + "time" "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/authService" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "github.com/gofiber/fiber/v3" ) @@ -60,10 +64,53 @@ 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) + // Create locale. LangID is overwritten by auth Token + var userLocale model.UserLocale + userLocale.OriginalUser = user + + // Check if target user is present + targetUserIDAttribute := c.Query("target_user_id") + + if targetUserIDAttribute == "" { + userLocale.User = user + c.Locals(constdata.USER_LOCALE, &userLocale) + + return c.Next() + } + + // We now populate the target user + if model.CustomerRole(user.Role.Name) != model.RoleAdmin { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "admin access required", + }) + } + + targetUserID, err := strconv.Atoi(targetUserIDAttribute) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "invalid target user id attribute", + }) + } + + // to verify target user, we use the same functionality as for verifying original user + // Get target user from database + user, err = authService.GetUserByID(uint(targetUserID)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "target user not found", + }) + } + + // Check if target user is active + if !user.IsActive { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "target user account is inactive", + }) + } + + userLocale.User = user + c.Locals(constdata.USER_LOCALE, &userLocale) + return c.Next() } } @@ -71,21 +118,14 @@ func AuthMiddleware() fiber.Handler { // RequireAdmin creates admin-only middleware func RequireAdmin() fiber.Handler { return func(c fiber.Ctx) error { - user := c.Locals("user") - if user == nil { + originalUserRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "error": "not authenticated", }) } - userSession, ok := user.(*model.UserSession) - if !ok { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "invalid user session", - }) - } - - if model.CustomerRole(userSession.RoleName) != model.RoleAdmin { + if model.CustomerRole(originalUserRole.Name) != model.RoleAdmin { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "admin access required", }) @@ -95,22 +135,70 @@ func RequireAdmin() fiber.Handler { } } -// GetUserID extracts user ID from context -func GetUserID(c fiber.Ctx) uint { - userID, ok := c.Locals("userID").(uint) - if !ok { - return 0 - } - return userID -} +// Webdav +func Webdav() fiber.Handler { + authService := authService.NewAuthService() -// GetUser extracts user from context -func GetUser(c fiber.Ctx) *model.UserSession { - user, ok := c.Locals("user").(*model.UserSession) - if !ok { - return nil + return func(c fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "authorization token required", + }) + } + + if !strings.HasPrefix(authHeader, "Basic ") { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization token", + }) + } + + encoded := strings.TrimPrefix(authHeader, "Basic ") + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization token", + }) + } + + credentials := strings.SplitN(string(decoded), ":", 2) + rawToken := "" + if len(credentials) == 1 { + rawToken = credentials[0] + } else if len(credentials) == 2 { + rawToken = credentials[1] + } + if len(rawToken) != constdata.NBYTES_IN_WEBDAV_TOKEN*2 { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization token", + }) + } + + // we identify user based on this token. + user, err := authService.GetUserByWebdavToken(rawToken) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "user not found", + }) + } + + if user.WebdavExpires != nil && user.WebdavExpires.Before(time.Now()) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid or expired token", + }) + } + + var userLocale model.UserLocale + userLocale.OriginalUser = user + userLocale.User = user + c.Locals(constdata.USER_LOCALE, &userLocale) + + return c.Next() } - return user } // GetConfig returns the app config diff --git a/app/delivery/middleware/language.go b/app/delivery/middleware/language.go index e3fd5ca..c23a8e7 100644 --- a/app/delivery/middleware/language.go +++ b/app/delivery/middleware/language.go @@ -4,7 +4,9 @@ import ( "strconv" "strings" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/langsService" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "github.com/gofiber/fiber/v3" ) @@ -22,12 +24,8 @@ func LanguageMiddleware() fiber.Handler { if id, err := strconv.ParseUint(langIDStr, 10, 32); err == nil { langID = uint(id) if langID > 0 { - lang, err := langService.GetLanguageById(langID) - if err == nil { - c.Locals("langID", langID) - c.Locals("lang", lang) - return c.Next() - } + c.Locals(constdata.USER_LOCALE, returnNewLocale(langID)) + return c.Next() } } } @@ -38,12 +36,8 @@ func LanguageMiddleware() fiber.Handler { if id, err := strconv.ParseUint(cookieLang, 10, 32); err == nil { langID = uint(id) if langID > 0 { - lang, err := langService.GetLanguageById(langID) - if err == nil { - c.Locals("langID", langID) - c.Locals("lang", lang) - return c.Next() - } + c.Locals(constdata.USER_LOCALE, returnNewLocale(langID)) + return c.Next() } } } @@ -57,8 +51,7 @@ func LanguageMiddleware() fiber.Handler { lang, err := langService.GetLanguageByISOCode(isoCode) if err == nil && lang != nil { langID = uint(lang.ID) - c.Locals("langID", langID) - c.Locals("lang", lang) + c.Locals(constdata.USER_LOCALE, returnNewLocale(langID)) return c.Next() } } @@ -68,8 +61,7 @@ func LanguageMiddleware() fiber.Handler { defaultLang, err := langService.GetDefaultLanguage() if err == nil && defaultLang != nil { langID = uint(defaultLang.ID) - c.Locals("langID", langID) - c.Locals("lang", defaultLang) + c.Locals(constdata.USER_LOCALE, returnNewLocale(langID)) } return c.Next() @@ -104,11 +96,9 @@ func parseAcceptLanguage(header string) string { return strings.ToLower(first) } -// GetLanguageID extracts language ID from context -func GetLanguageID(c fiber.Ctx) uint { - langID, ok := c.Locals("langID").(uint) - if !ok { - return 0 - } - return langID +func returnNewLocale(lang_id uint) *model.UserLocale { + newLocale := model.UserLocale{} + newLocale.OriginalUser = &model.Customer{} + newLocale.OriginalUser.LangID = lang_id + return &newLocale } diff --git a/app/delivery/middleware/perms/permissions.go b/app/delivery/middleware/perms/permissions.go index d4b9f07..7528921 100644 --- a/app/delivery/middleware/perms/permissions.go +++ b/app/delivery/middleware/perms/permissions.go @@ -3,9 +3,8 @@ 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" + CurrencyWrite Permission = "currency.write" ) diff --git a/app/delivery/web/api/public/auth.go b/app/delivery/web/api/public/auth.go index edf67f3..5cb4e52 100644 --- a/app/delivery/web/api/public/auth.go +++ b/app/delivery/web/api/public/auth.go @@ -268,15 +268,15 @@ func (h *AuthHandler) RefreshToken(c fiber.Ctx) error { // Me returns the current user info func (h *AuthHandler) Me(c fiber.Ctx) error { - user := c.Locals("user") - if user == nil { + userLocale := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) + if userLocale.OriginalUser == nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "error": responseErrors.GetErrorCode(c, responseErrors.ErrNotAuthenticated), }) } return c.JSON(fiber.Map{ - "user": user, + "user": *userLocale.OriginalUser, }) } @@ -351,21 +351,12 @@ func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error { // Updates JWT Tokens. Requires authentication and updates access token only func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error { - userLocals, ok := c.Locals(constdata.USER_LOCALES_NAME).(*model.UserSession) + userLocale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) if !ok { return c.Status(fiber.StatusUnauthorized). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrNotAuthenticated))) } - user := model.Customer{ - ID: userLocals.UserID, - Email: userLocals.Email, - Role: model.Role{ID: userLocals.RoleID, Name: userLocals.RoleName}, - LangID: userLocals.LangID, - CountryID: userLocals.CountryID, - IsActive: userLocals.IsActive, - } - // Parse language and country_id from query params langIDStr := c.Query("lang_id") @@ -375,7 +366,7 @@ func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error { return c.Status(fiber.StatusBadRequest). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadLangID))) } - user.LangID = uint(parsedID) + userLocale.OriginalUser.LangID = uint(parsedID) } countryIDStr := c.Query("country_id") @@ -386,10 +377,10 @@ func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error { return c.Status(fiber.StatusBadRequest). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadCountryID))) } - user.CountryID = uint(parsedID) + userLocale.OriginalUser.CountryID = uint(parsedID) } - newAccessToken, err := h.authService.UpdateJWTToken(&user) + newAccessToken, err := h.authService.UpdateJWTToken(userLocale.OriginalUser) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ diff --git a/app/delivery/web/api/public/routing.go b/app/delivery/web/api/public/routing.go index 9e36274..bad8746 100644 --- a/app/delivery/web/api/public/routing.go +++ b/app/delivery/web/api/public/routing.go @@ -3,6 +3,7 @@ package public import ( "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/localeExtractor" "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" @@ -30,7 +31,7 @@ func RoutingHandlerRoutes(r fiber.Router) fiber.Router { } func (h *RoutingHandler) GetRouting(c fiber.Ctx) error { - lang_id, ok := c.Locals("langID").(uint) + lang_id, ok := localeExtractor.GetLangID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) diff --git a/app/delivery/web/api/restricted/addresses.go b/app/delivery/web/api/restricted/addresses.go new file mode 100644 index 0000000..903f011 --- /dev/null +++ b/app/delivery/web/api/restricted/addresses.go @@ -0,0 +1,157 @@ +package restricted + +import ( + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/service/addressesService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" + "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type AddressesHandler struct { + addressesService *addressesService.AddressesService +} + +func NewAddressesHandler() *AddressesHandler { + addressesService := addressesService.New() + return &AddressesHandler{ + addressesService: addressesService, + } +} + +func AddressesHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewAddressesHandler() + + r.Get("/get-template", handler.GetTemplate) + r.Post("/add-new-address", handler.AddNewAddress) + r.Post("/modify-address", handler.ModifyAddress) + r.Get("/retrieve-addresses", handler.RetrieveAddressesInfo) + r.Delete("/delete-address", handler.DeleteAddress) + + return r +} + +func (h *AddressesHandler) GetTemplate(c fiber.Ctx) error { + country_id_attribute := c.Query("country_id") + country_id, err := strconv.Atoi(country_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + template, err := h.addressesService.GetTemplate(uint(country_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(&template, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *AddressesHandler) AddNewAddress(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + address_info := string(c.Body()) + if address_info == "" { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + country_id_attribute := c.Query("country_id") + country_id, err := strconv.Atoi(country_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + err = h.addressesService.AddNewAddress(userID, address_info, uint(country_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(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) +} + +func (h *AddressesHandler) ModifyAddress(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + address_id_attribute := c.Query("address_id") + address_id, err := strconv.Atoi(address_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + address_info := string(c.Body()) + if address_info == "" { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + country_id_attribute := c.Query("country_id") + country_id, err := strconv.Atoi(country_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + err = h.addressesService.ModifyAddress(userID, uint(address_id), address_info, uint(country_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(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) +} + +func (h *AddressesHandler) RetrieveAddressesInfo(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + addresses_info, err := h.addressesService.RetrieveAddressesInfo(userID) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&addresses_info, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *AddressesHandler) DeleteAddress(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + address_id_attribute := c.Query("address_id") + address_id, err := strconv.Atoi(address_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + err = h.addressesService.DeleteAddress(userID, uint(address_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(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/api/restricted/carts.go b/app/delivery/web/api/restricted/carts.go index aeed1ee..a787620 100644 --- a/app/delivery/web/api/restricted/carts.go +++ b/app/delivery/web/api/restricted/carts.go @@ -5,6 +5,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/service/cartsService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -37,7 +38,7 @@ func CartsHandlerRoutes(r fiber.Router) fiber.Router { } func (h *CartsHandler) AddNewCart(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) @@ -53,7 +54,7 @@ func (h *CartsHandler) AddNewCart(c fiber.Ctx) error { } func (h *CartsHandler) ChangeCartName(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) @@ -78,7 +79,7 @@ func (h *CartsHandler) ChangeCartName(c fiber.Ctx) error { } func (h *CartsHandler) RetrieveCartsInfo(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) @@ -94,7 +95,7 @@ func (h *CartsHandler) RetrieveCartsInfo(c fiber.Ctx) error { } func (h *CartsHandler) RetrieveCart(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) @@ -117,7 +118,7 @@ func (h *CartsHandler) RetrieveCart(c fiber.Ctx) error { } func (h *CartsHandler) AddProduct(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) diff --git a/app/delivery/web/api/restricted/currency.go b/app/delivery/web/api/restricted/currency.go index 52dee21..d3bda40 100644 --- a/app/delivery/web/api/restricted/currency.go +++ b/app/delivery/web/api/restricted/currency.go @@ -4,6 +4,8 @@ import ( "strconv" "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware" + "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/currencyService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" @@ -30,7 +32,7 @@ func NewCurrencyHandler() *CurrencyHandler { func CurrencyHandlerRoutes(r fiber.Router) fiber.Router { handler := NewCurrencyHandler() - r.Post("/currency-rate", handler.PostCurrencyRate) + r.Post("/currency-rate", middleware.Require(perms.CurrencyWrite), handler.PostCurrencyRate) r.Get("/currency-rate/:id", handler.GetCurrencyRate) return r } diff --git a/app/delivery/web/api/restricted/customer.go b/app/delivery/web/api/restricted/customer.go index a15695d..6e1a41c 100644 --- a/app/delivery/web/api/restricted/customer.go +++ b/app/delivery/web/api/restricted/customer.go @@ -7,7 +7,9 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/customerService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "github.com/gofiber/fiber/v3" @@ -28,36 +30,34 @@ func CustomerHandlerRoutes(r fiber.Router) fiber.Router { handler := NewCustomerHandler() r.Get("", handler.customerData) + r.Get("/list", handler.listCustomers) return r } func (h *customerHandler) customerData(fc fiber.Ctx) error { var customerId uint + + user, ok := localeExtractor.GetCustomer(fc) + if !ok || user == nil { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + customerIdStr := fc.Query("id") if customerIdStr != "" { - 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) { + if user.ID != uint(id) && !user.HasPermission(perms.UserReadAny) { return fc.Status(fiber.StatusForbidden). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) } customerId = uint(id) } else { - 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 + customerId = user.ID } customer, err := h.service.GetById(customerId) @@ -68,3 +68,44 @@ func (h *customerHandler) customerData(fc fiber.Ctx) error { return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) } + +func (h *customerHandler) listCustomers(fc fiber.Ctx) error { + user, ok := localeExtractor.GetCustomer(fc) + if !ok || user == nil { + return fc.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrBadAttribute))) + } + if !user.HasPermission(perms.UserReadAny) { + return fc.Status(fiber.StatusForbidden). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) + } + + p, filt, err := query_params.ParseFilters[model.Customer](fc, columnMappingListUsers) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } + + search := fc.Query("search") + if search != "" { + if !user.HasPermission(perms.UserReadAny) { + return fc.Status(fiber.StatusForbidden). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, responseErrors.ErrForbidden))) + } + } + + customer, err := h.service.Find(user.LangID, p, filt, search) + if err != nil { + return fc.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(fc, err))) + } + + return fc.JSON(response.Make(&customer, 0, i18n.T_(fc, response.Message_OK))) +} + +var columnMappingListUsers map[string]string = map[string]string{ + "user_id": "users.id", + "email": "users.email", + "first_name": "users.first_name", + "last_name": "users.last_name", +} diff --git a/app/delivery/web/api/restricted/list.go b/app/delivery/web/api/restricted/list.go deleted file mode 100644 index 7965424..0000000 --- a/app/delivery/web/api/restricted/list.go +++ /dev/null @@ -1,98 +0,0 @@ -package restricted - -import ( - "git.ma-al.com/goc_daniel/b2b/app/config" - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/service/listService" - "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" - "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" - "git.ma-al.com/goc_daniel/b2b/app/utils/response" - "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" - "github.com/gofiber/fiber/v3" -) - -// ListHandler handles endpoints that list various things (e.g. products or users) -type ListHandler struct { - listService *listService.ListService - config *config.Config -} - -// NewListHandler creates a new ListHandler instance -func NewListHandler() *ListHandler { - listService := listService.New() - return &ListHandler{ - listService: listService, - config: config.Get(), - } -} - -func ListHandlerRoutes(r fiber.Router) fiber.Router { - handler := NewListHandler() - - r.Get("/list-products", handler.ListProducts) - r.Get("/list-users", handler.ListUsers) - - return r -} - -func (h *ListHandler) ListProducts(c fiber.Ctx) error { - paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingListProducts) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - id_lang, ok := c.Locals("langID").(uint) - if !ok { - return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) - } - - listing, err := h.listService.ListProducts(id_lang, paging, filters) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK))) -} - -var columnMappingListProducts map[string]string = map[string]string{ - "product_id": "ps.id_product", - "name": "pl.name", - "reference": "p.reference", - "category_name": "cl.name", - "category_id": "cp.id_category", - "quantity": "sa.quantity", -} - -func (h *ListHandler) ListUsers(c fiber.Ctx) error { - paging, filters, err := query_params.ParseFilters[model.Customer](c, columnMappingListUsers) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - id_lang, ok := c.Locals("langID").(uint) - if !ok { - return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) - } - - listing, err := h.listService.ListUsers(id_lang, paging, filters) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK))) -} - -var columnMappingListUsers map[string]string = map[string]string{ - "user_id": "users.id", - "email": "users.email", - "first_name": "users.first_name", - "second_name": "users.second_name", - "role": "users.role", -} diff --git a/app/delivery/web/api/restricted/menu.go b/app/delivery/web/api/restricted/menu.go index 5173d3f..8114e9c 100644 --- a/app/delivery/web/api/restricted/menu.go +++ b/app/delivery/web/api/restricted/menu.go @@ -3,10 +3,9 @@ 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/localeExtractor" "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" @@ -35,7 +34,7 @@ func MenuHandlerRoutes(r fiber.Router) fiber.Router { } func (h *MenuHandler) GetCategoryTree(c fiber.Ctx) error { - lang_id, ok := c.Locals("langID").(uint) + lang_id, ok := localeExtractor.GetLangID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) @@ -58,7 +57,7 @@ func (h *MenuHandler) GetCategoryTree(c fiber.Ctx) error { } func (h *MenuHandler) GetBreadcrumb(c fiber.Ctx) error { - lang_id, ok := c.Locals("langID").(uint) + lang_id, ok := localeExtractor.GetLangID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) @@ -88,12 +87,12 @@ func (h *MenuHandler) GetBreadcrumb(c fiber.Ctx) error { } func (h *MenuHandler) GetTopMenu(c fiber.Ctx) error { - session, ok := c.Locals("user").(*model.UserSession) - if !ok { + customer, ok := localeExtractor.GetCustomer(c) + if !ok || customer == nil { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - menu, err := h.menuService.GetTopMenu(session.LangID, session.RoleID) + menu, err := h.menuService.GetTopMenu(customer.LangID, customer.RoleID) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index eaade19..096d5ec 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -1,14 +1,15 @@ package restricted import ( - "fmt" "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/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/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "github.com/gofiber/fiber/v3" @@ -32,6 +33,9 @@ func ProductsHandlerRoutes(r fiber.Router) fiber.Router { handler := NewProductsHandler() r.Get("/:id/:country_id/:quantity", handler.GetProductJson) + r.Get("/list", handler.ListProducts) + r.Post("/favorite/:product_id", handler.AddToFavorites) + r.Delete("/favorite/:product_id", handler.RemoveFromFavorites) return r } @@ -61,18 +65,12 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - p_id_customer, ok := c.Locals(constdata.USER_LOCALES_ID).(uint) - if !ok { + customer, ok := localeExtractor.GetCustomer(c) + if !ok || customer == nil { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - 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) + productJson, err := h.productService.GetJSON(p_id_product, int(customer.LangID), int(customer.ID), b2b_id_country, p_quantity) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -80,3 +78,89 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { return c.JSON(response.Make(&productJson, 1, i18n.T_(c, response.Message_OK))) } + +func (h *ProductsHandler) ListProducts(c fiber.Ctx) error { + paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingListProducts) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + id_lang, ok := localeExtractor.GetLangID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + list, err := h.productService.Find(id_lang, userID, paging, filters) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&list.Items, int(list.Count), i18n.T_(c, response.Message_OK))) +} + +var columnMappingListProducts map[string]string = map[string]string{ + "product_id": "ps.id_product", + "name": "pl.name", + "reference": "p.reference", + "category_name": "cl.name", + "category_id": "cp.id_category", + "quantity": "sa.quantity", + "is_favorite": "ps.is_favorite", +} + +func (h *ProductsHandler) AddToFavorites(c fiber.Ctx) error { + productIDStr := c.Params("product_id") + + productID, err := strconv.Atoi(productIDStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + err = h.productService.AddToFavorites(userID, uint(productID)) + 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(""), 0, i18n.T_(c, response.Message_OK))) +} + +func (h *ProductsHandler) RemoveFromFavorites(c fiber.Ctx) error { + productIDStr := c.Params("product_id") + + productID, err := strconv.Atoi(productIDStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + err = h.productService.RemoveFromFavorites(userID, uint(productID)) + 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(""), 0, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/api/restricted/productTranslation.go b/app/delivery/web/api/restricted/productTranslation.go index 760ddb3..3dc16bd 100644 --- a/app/delivery/web/api/restricted/productTranslation.go +++ b/app/delivery/web/api/restricted/productTranslation.go @@ -4,8 +4,10 @@ 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/productTranslationService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -41,7 +43,7 @@ func ProductTranslationHandlerRoutes(r fiber.Router) fiber.Router { // GetProductDescription returns the product description for a given product ID func (h *ProductTranslationHandler) GetProductDescription(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) @@ -72,12 +74,18 @@ func (h *ProductTranslationHandler) GetProductDescription(c fiber.Ctx) error { // SaveProductDescription saves the description for a given product ID, in given language func (h *ProductTranslationHandler) SaveProductDescription(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) } + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + productID_attribute := c.Query("productID") productID, err := strconv.Atoi(productID_attribute) if err != nil { @@ -109,12 +117,18 @@ func (h *ProductTranslationHandler) SaveProductDescription(c fiber.Ctx) error { // TranslateProductDescription returns translated product description func (h *ProductTranslationHandler) TranslateProductDescription(c fiber.Ctx) error { - userID, ok := c.Locals("userID").(uint) + userID, ok := localeExtractor.GetUserID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) } + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + productID_attribute := c.Query("productID") productID, err := strconv.Atoi(productID_attribute) if err != nil { diff --git a/app/delivery/web/api/restricted/search.go b/app/delivery/web/api/restricted/search.go index ac6abb1..843c956 100644 --- a/app/delivery/web/api/restricted/search.go +++ b/app/delivery/web/api/restricted/search.go @@ -4,9 +4,11 @@ import ( "encoding/json" "fmt" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/meiliService" searchservice "git.ma-al.com/goc_daniel/b2b/app/service/searchService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -36,12 +38,18 @@ func MeiliSearchHandlerRoutes(r fiber.Router) fiber.Router { } func (h *MeiliSearchHandler) CreateIndex(c fiber.Ctx) error { - id_lang, ok := c.Locals("langID").(uint) + id_lang, ok := localeExtractor.GetLangID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + err := h.meiliService.CreateIndex(id_lang) if err != nil { fmt.Printf("CreateIndex error: %v\n", err) @@ -49,12 +57,11 @@ func (h *MeiliSearchHandler) CreateIndex(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - nothing := "" - return c.JSON(response.Make(¬hing, 0, i18n.T_(c, response.Message_OK))) + return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) } func (h *MeiliSearchHandler) Search(c fiber.Ctx) error { - id_lang, ok := c.Locals("langID").(uint) + id_lang, ok := localeExtractor.GetLangID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) @@ -88,7 +95,7 @@ func (h *MeiliSearchHandler) Search(c fiber.Ctx) error { } func (h *MeiliSearchHandler) GetSettings(c fiber.Ctx) error { - id_lang, ok := c.Locals("langID").(uint) + id_lang, ok := localeExtractor.GetLangID(c) if !ok { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go new file mode 100644 index 0000000..910aae1 --- /dev/null +++ b/app/delivery/web/api/restricted/storage.go @@ -0,0 +1,100 @@ +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/storageService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" + "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type StorageHandler struct { + storageService *storageService.StorageService + config *config.Config +} + +func NewStorageHandler() *StorageHandler { + return &StorageHandler{ + storageService: storageService.New(), + config: config.Get(), + } +} + +func StorageHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewStorageHandler() + + // for all users + r.Get("/list-content/*", handler.ListContent) + r.Get("/download-file/*", handler.DownloadFile) + + // for admins only + r.Get("/create-new-webdav-token", handler.CreateNewWebdavToken) + + return r +} + +// accepted path looks like e.g. "/folder1/" or "folder1" +func (h *StorageHandler) ListContent(c fiber.Ctx) error { + // relative path defaults to root directory + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + entries_in_list, err := h.storageService.ListContent(abs_path) + + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(entries_in_list, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *StorageHandler) DownloadFile(c fiber.Ctx) error { + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + f, filename, filesize, err := h.storageService.DownloadFilePrep(abs_path) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + c.Attachment(filename) + c.Set("Content-Length", strconv.FormatInt(filesize, 10)) + c.Set("Content-Type", "application/octet-stream") + return c.SendStream(f, int(filesize)) +} + +func (h *StorageHandler) CreateNewWebdavToken(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + + new_token, err := h.storageService.NewWebdavToken(userID) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&new_token, 0, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/api/webdav/storage.go b/app/delivery/web/api/webdav/storage.go new file mode 100644 index 0000000..8a01d0d --- /dev/null +++ b/app/delivery/web/api/webdav/storage.go @@ -0,0 +1,198 @@ +package webdav + +import ( + "bytes" + "io" + "net/http" + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/service/storageService" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type StorageHandler struct { + storageService *storageService.StorageService + config *config.Config +} + +func NewStorageHandler() *StorageHandler { + return &StorageHandler{ + storageService: storageService.New(), + config: config.Get(), + } +} + +func StorageHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewStorageHandler() + + // for webdav use only + r.Get("/*", handler.Get) + r.Head("/*", handler.Get) + r.Put("/*", handler.Put) + r.Delete("/*", handler.Delete) + r.Add([]string{"MKCOL"}, "/*", handler.Mkcol) + r.Add([]string{"PROPFIND"}, "/*", handler.Propfind) + r.Add([]string{"PROPPATCH"}, "/*", handler.Proppatch) + r.Add([]string{"MOVE"}, "/*", handler.Move) + r.Add([]string{"COPY"}, "/*", handler.Copy) + + return r +} + +func (h *StorageHandler) Get(c fiber.Ctx) error { + // fmt.Println("GET") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + info, err := h.storageService.EntryInfo(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + if info.IsDir() { + xml, err := h.storageService.Propfind(h.config.Storage.RootFolder, absPath, "1") + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + c.Set("Content-Type", `application/xml; charset="utf-8"`) + return c.Status(http.StatusMultiStatus).SendString(xml) + + } else { + f, filename, filesize, err := h.storageService.DownloadFilePrep(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + c.Attachment(filename) + c.Set("Content-Length", strconv.FormatInt(filesize, 10)) + c.Set("Content-Type", "application/octet-stream") + return c.SendStream(f, int(filesize)) + } +} + +func (h *StorageHandler) Put(c fiber.Ctx) error { + // fmt.Println("PUT") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + var src io.Reader + if bodyStream := c.Request().BodyStream(); bodyStream != nil { + defer c.Request().CloseBodyStream() + src = bodyStream + } else { + src = bytes.NewReader(c.Body()) + } + + err = h.storageService.Put(absPath, src) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + return c.SendStatus(http.StatusCreated) +} + +func (h *StorageHandler) Delete(c fiber.Ctx) error { + // fmt.Println("DELETE") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + if absPath == h.config.Storage.RootFolder { + return c.SendStatus(responseErrors.GetErrorStatus(responseErrors.ErrAccessDenied)) + } + + err = h.storageService.Delete(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + return c.SendStatus(http.StatusNoContent) +} + +func (h *StorageHandler) Mkcol(c fiber.Ctx) error { + // fmt.Println("Mkcol") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + err = h.storageService.Mkcol(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + return c.SendStatus(http.StatusCreated) +} + +func (h *StorageHandler) Propfind(c fiber.Ctx) error { + // fmt.Println("PROPFIND") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + xml, err := h.storageService.Propfind(h.config.Storage.RootFolder, absPath, "1") + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + c.Set("Content-Type", `application/xml; charset="utf-8"`) + return c.Status(http.StatusMultiStatus).SendString(xml) +} + +func (h *StorageHandler) Proppatch(c fiber.Ctx) error { + return c.SendStatus(http.StatusNotImplemented) // 501 +} + +func (h *StorageHandler) Move(c fiber.Ctx) error { + // fmt.Println("MOVE") + srcAbsPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + dest := c.Get("Destination") + if dest == "" { + return c.SendStatus(http.StatusBadRequest) + } + destAbsPath, err := h.storageService.ObtainDestPath(h.config.Storage.RootFolder, dest) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + err = h.storageService.Move(srcAbsPath, destAbsPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + return c.SendStatus(http.StatusCreated) +} + +func (h *StorageHandler) Copy(c fiber.Ctx) error { + // fmt.Println("COPY") + srcAbsPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + dest := c.Get("Destination") + if dest == "" { + return c.SendStatus(http.StatusBadRequest) + } + destAbsPath, err := h.storageService.ObtainDestPath(h.config.Storage.RootFolder, dest) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + err = h.storageService.Copy(srcAbsPath, destAbsPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + return c.SendStatus(http.StatusCreated) +} diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index be7730b..2162d66 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -14,6 +14,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api" "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api/public" "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api/restricted" + "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api/webdav" "git.ma-al.com/goc_daniel/b2b/app/delivery/web/general" "github.com/gofiber/fiber/v3" @@ -25,6 +26,7 @@ import ( type Server struct { app *fiber.App cfg *config.Config + webdav fiber.Router api fiber.Router public fiber.Router restricted fiber.Router @@ -42,12 +44,23 @@ func (s *Server) Cfg() *config.Config { // New creates a new server instance func New() *Server { - return &Server{ - app: fiber.New(fiber.Config{ - ErrorHandler: customErrorHandler, - }), - cfg: config.Get(), - } + var s Server + + app := + fiber.New(fiber.Config{ + ErrorHandler: customErrorHandler, + BodyLimit: 50 * 1024 * 1024, // 50 MB + StreamRequestBody: true, + RequestMethods: []string{ + fiber.MethodGet, fiber.MethodHead, fiber.MethodPost, fiber.MethodPut, + fiber.MethodDelete, fiber.MethodConnect, fiber.MethodOptions, + fiber.MethodTrace, fiber.MethodPatch, "MKCOL", "PROPFIND", "PROPPATCH", "MOVE", "COPY", + }, + }) + + s.app = app + s.cfg = config.Get() + return &s } // Setup configures the server with routes and middleware @@ -76,6 +89,8 @@ func (s *Server) Setup() error { s.public = s.api.Group("/public") s.restricted = s.api.Group("/restricted") s.restricted.Use(middleware.AuthMiddleware()) + s.webdav = s.api.Group("/webdav") + s.webdav.Use(middleware.Webdav()) // initialize language endpoints (general) api.NewLangHandler().InitLanguage(s.api, s.cfg) @@ -97,10 +112,6 @@ func (s *Server) Setup() error { productTranslation := s.restricted.Group("/product-translation") restricted.ProductTranslationHandlerRoutes(productTranslation) - // lists of things routes (restricted) - list := s.restricted.Group("/list") - restricted.ListHandlerRoutes(list) - product := s.restricted.Group("/product") restricted.ProductsHandlerRoutes(product) @@ -121,12 +132,22 @@ func (s *Server) Setup() error { carts := s.restricted.Group("/carts") restricted.CartsHandlerRoutes(carts) + // addresses (restricted) + addresses := s.restricted.Group("/addresses") + restricted.AddressesHandlerRoutes(addresses) + + // storage (uses various authorization means) + restrictedStorage := s.restricted.Group("/storage") + webdavStorage := s.webdav.Group("/storage") + restricted.StorageHandlerRoutes(restrictedStorage) + webdav.StorageHandlerRoutes(webdavStorage) + + restricted.CurrencyHandlerRoutes(s.restricted) + s.api.All("*", func(c fiber.Ctx) 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/address.go b/app/model/address.go new file mode 100644 index 0000000..a84056a --- /dev/null +++ b/app/model/address.go @@ -0,0 +1,79 @@ +package model + +type Address struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CustomerID uint `gorm:"column:b2b_customer_id;not null;index" json:"customer_id"` + AddressInfo string `gorm:"column:address_info;not null" json:"address_info"` + CountryID uint `gorm:"column:b2b_country_id;not null" json:"country_id"` +} + +func (Address) TableName() string { + return "b2b_addresses" +} + +type AddressUnparsed struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CustomerID uint `gorm:"column:b2b_customer_id;not null;index" json:"customer_id"` + AddressInfo AddressField `gorm:"column:address_info;not null" json:"address_info"` + CountryID uint `gorm:"column:b2b_country_id;not null" json:"country_id"` +} + +type AddressField interface { +} + +// Address template in Poland +type AddressPL struct { + PostalCode string `json:"postal_code"` // format: 00-000 + City string `json:"city"` // e.g. Kraków + Voivodeship string `json:"voivodeship"` // e.g. małopolskie (optional but useful) + + Street string `json:"street"` // e.g. Marszałkowska + BuildingNo string `json:"building_no"` // e.g. 10, 221B, 12A + ApartmentNo string `json:"apartment_no"` // e.g. 5, 12B + + AddressLine2 string `json:"address_line2"` // optional extra info + + Recipient string `json:"recipient"` // name/company +} + +// Address template in Great Britain +type AddressGB struct { + PostalCode string `json:"postal_code"` // e.g. SW1A 1AA + PostTown string `json:"post_town"` // e.g. London + County string `json:"county"` // optional + + Thoroughfare string `json:"thoroughfare"` // street name, e.g. Baker Street + BuildingNo string `json:"building_no"` // e.g. 221B + BuildingName string `json:"building_name"` // e.g. Flatiron House + SubBuilding string `json:"sub_building"` // e.g. Flat 5, Apt 2 + + AddressLine2 string `json:"address_line2"` + Recipient string `json:"recipient"` +} + +// Address template in Czech Republic +type AddressCZ struct { + PostalCode string `json:"postal_code"` // usually 110 00 or 11000 + City string `json:"city"` // e.g. Praha + Region string `json:"region"` + + Street string `json:"street"` // may be omitted in some village-style addresses + HouseNumber string `json:"house_number"` // descriptive / conscription no. + OrientationNumber string `json:"orientation_number"` // optional, often after slash + + AddressLine2 string `json:"address_line2"` + Recipient string `json:"recipient"` +} + +// Address template in Germany +type AddressDE struct { + PostalCode string `json:"postal_code"` // e.g. 10115 + City string `json:"city"` // e.g. Berlin + State string `json:"state"` // Bundesland, optional + + Street string `json:"street"` // e.g. Unter den Linden + HouseNumber string `json:"house_number"` // e.g. 77, 12a + + AddressLine2 string `json:"address_line2"` // extra details + Recipient string `json:"recipient"` +} diff --git a/app/model/customer.go b/app/model/customer.go index f7db443..f79d282 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -15,7 +15,7 @@ type Customer struct { FirstName string `gorm:"size:100" json:"first_name"` LastName string `gorm:"size:100" json:"last_name"` RoleID uint `gorm:"column:role_id;not null;default:1" json:"-"` - Role Role `gorm:"foreignKey:RoleID" json:"role"` + Role *Role `gorm:"foreignKey:RoleID" json:"role,omitempty"` Provider AuthProvider `gorm:"type:varchar(20);default:'local'" json:"provider"` ProviderID string `gorm:"size:255" json:"provider_id,omitempty"` // ID from OAuth provider AvatarURL string `gorm:"size:500" json:"avatar_url,omitempty"` @@ -25,6 +25,8 @@ type Customer struct { EmailVerificationExpires *time.Time `json:"-"` PasswordResetToken string `gorm:"size:255" json:"-"` PasswordResetExpires *time.Time `json:"-"` + WebdavToken string `gorm:"size:255" json:"-"` + WebdavExpires *time.Time `json:"-"` LastPasswordResetRequest *time.Time `json:"-"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language @@ -34,6 +36,15 @@ type Customer struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } +func (u *Customer) HasPermission(permission perms.Permission) bool { + for _, p := range u.Role.Permissions { + if p.Name == permission { + return true + } + } + return false +} + // AuthProvider represents the authentication provider type AuthProvider string @@ -77,6 +88,15 @@ func (us *UserSession) HasPermission(permission perms.Permission) bool { return false } +type UserLocale struct { + // User is the Target user if present, otherwise same as Original. + // User ought to be used in applications + User *Customer + // Original user is the one associated with auth token + OriginalUser *Customer + // Importantly, lang_id used in application is stored as OriginalUser.LangID +} + // ToSession converts User to UserSession func (u *Customer) ToSession() *UserSession { @@ -106,6 +126,7 @@ func BuildPermissionSlice(user *Customer) []perms.Permission { type LoginRequest struct { Email string `json:"email" form:"email"` Password string `json:"password" form:"password"` + LangID *uint `json:"lang_id" form:"lang_id"` } // RegisterRequest represents the initial registration form data @@ -158,5 +179,4 @@ type UserInList struct { Email string `gorm:"column:email" json:"email"` FirstName string `gorm:"column:first_name" json:"first_name"` LastName string `gorm:"column:last_name" json:"last_name"` - Role string `gorm:"column:role" json:"role"` } diff --git a/app/model/entry.go b/app/model/entry.go new file mode 100644 index 0000000..ae63646 --- /dev/null +++ b/app/model/entry.go @@ -0,0 +1,6 @@ +package model + +type EntryInList struct { + Name string + IsFolder bool +} diff --git a/app/model/product.go b/app/model/product.go index fa47790..e862259 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -70,6 +70,7 @@ type ProductInList struct { Reference string `gorm:"column:reference" json:"reference"` VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"` Quantity int64 `gorm:"column:quantity" json:"quantity"` + IsFavorite bool `gorm:"column:is_favorite" json:"is_favorite"` } type ProductFilters struct { @@ -85,3 +86,12 @@ type ProductFilters struct { } type FeatVal = map[uint][]uint + +type B2bFavorite struct { + UserID uint `gorm:"column:user_id;not null;primaryKey" json:"user_id"` + ProductID uint `gorm:"column:product_id;not null;primaryKey" json:"product_id"` +} + +func (*B2bFavorite) TableName() string { + return "b2b_favorites" +} diff --git a/app/model/productDescription.go b/app/model/productDescription.go index 985b819..2080b0b 100644 --- a/app/model/productDescription.go +++ b/app/model/productDescription.go @@ -18,7 +18,7 @@ type ProductDescription struct { AvailableLater string `gorm:"column:available_later;type:varchar(255)" json:"available_later" form:"available_later"` DeliveryInStock string `gorm:"column:delivery_in_stock;type:varchar(255)" json:"delivery_in_stock" form:"delivery_in_stock"` DeliveryOutStock string `gorm:"column:delivery_out_stock;type:varchar(255)" json:"delivery_out_stock" form:"delivery_out_stock"` - Usage string `gorm:"column:_usage_;type:text" json:"usage" form:"usage"` + Usage string `gorm:"column:usage;type:text" json:"usage" form:"usage"` ImageLink string `gorm:"column:image_link" json:"image_link"` ExistsInDatabase bool `gorm:"-" json:"exists_in_database"` diff --git a/app/model/role.go b/app/model/role.go index 3c663b5..2ea0789 100644 --- a/app/model/role.go +++ b/app/model/role.go @@ -3,7 +3,7 @@ package model type Role struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:64" json:"name"` - Permissions []Permission `gorm:"many2many:b2b_role_permissions;" json:"-"` + Permissions []Permission `gorm:"many2many:b2b_role_permissions;" json:"permissions"` } func (Role) TableName() string { diff --git a/app/repos/addressesRepo/addressesRepo.go b/app/repos/addressesRepo/addressesRepo.go new file mode 100644 index 0000000..5f674a0 --- /dev/null +++ b/app/repos/addressesRepo/addressesRepo.go @@ -0,0 +1,91 @@ +package addressesRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UIAddressesRepo interface { + UserHasAddress(user_id uint, address_id uint) (uint, error) + UserAddressesAmt(user_id uint) (uint, error) + AddNewAddress(user_id uint, address_info string, country_id uint) error + UpdateAddress(user_id uint, address_id uint, address_info string, country_id uint) error + RetrieveAddresses(user_id uint) (*[]model.Address, error) + DeleteAddress(user_id uint, address_id uint) error +} + +type AddressesRepo struct{} + +func New() UIAddressesRepo { + return &AddressesRepo{} +} + +func (repo *AddressesRepo) UserHasAddress(user_id uint, address_id uint) (uint, error) { + var amt uint + + err := db.DB. + Table("b2b_addresses"). + Select("COUNT(*) AS amt"). + Where("id = ? AND b2b_customer_id = ?", address_id, user_id). + Scan(&amt). + Error + + return amt, err +} + +func (repo *AddressesRepo) UserAddressesAmt(user_id uint) (uint, error) { + var amt uint + + err := db.DB. + Table("b2b_addresses"). + Select("COUNT(*) AS amt"). + Where("b2b_customer_id = ?", user_id). + Scan(&amt). + Error + + return amt, err +} + +func (repo *AddressesRepo) AddNewAddress(user_id uint, address_info string, country_id uint) error { + address := model.Address{ + CustomerID: user_id, + AddressInfo: address_info, + CountryID: country_id, + } + + return db.DB. + Create(&address). + Error +} + +func (repo *AddressesRepo) UpdateAddress(user_id uint, address_id uint, address_info string, country_id uint) error { + address := model.Address{ + ID: address_id, + CustomerID: user_id, + AddressInfo: address_info, + CountryID: country_id, + } + + return db.DB. + Where("id = ? AND b2b_customer_id = ?", address_id, user_id). + Updates(&address). + Error +} + +func (repo *AddressesRepo) RetrieveAddresses(user_id uint) (*[]model.Address, error) { + var addresses []model.Address + + err := db.DB. + Where("b2b_customer_id = ?", user_id). + Find(&addresses). + Error + + return &addresses, err +} + +func (repo *AddressesRepo) DeleteAddress(user_id uint, address_id uint) error { + return db.DB. + Where("id = ? AND b2b_customer_id = ?", address_id, user_id). + Delete(&model.Address{}). + Error +} diff --git a/app/repos/customerRepo/customerRepo.go b/app/repos/customerRepo/customerRepo.go index 058d5fd..18dea15 100644 --- a/app/repos/customerRepo/customerRepo.go +++ b/app/repos/customerRepo/customerRepo.go @@ -1,12 +1,21 @@ package customerRepo import ( + "strings" + "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) type UICustomerRepo interface { Get(id uint) (*model.Customer, error) + GetByEmail(email string) (*model.Customer, error) + GetByExternalProviderId(provider model.AuthProvider, id string) (*model.Customer, error) + Find(langId uint, p find.Paging, filt *filters.FiltersList, search string) (*find.Found[model.UserInList], error) + Save(customer *model.Customer) error + Create(customer *model.Customer) error } type CustomerRepo struct{} @@ -19,9 +28,170 @@ func (repo *CustomerRepo) Get(id uint) (*model.Customer, error) { var customer model.Customer err := db.DB. - Preload("Role"). + Preload("Role.Permissions"). First(&customer, id). Error return &customer, err } + +func (repo *CustomerRepo) GetByEmail(email string) (*model.Customer, error) { + var customer model.Customer + + err := db.DB. + Preload("Role.Permissions"). + Where("email = ?", email). + First(&customer). + Error + + return &customer, err +} + +func (repo *CustomerRepo) GetByExternalProviderId(provider model.AuthProvider, id string) (*model.Customer, error) { + var customer model.Customer + + err := db.DB. + Preload("Role.Permissions"). + Where("provider = ? AND provider_id = ?", provider, id). + First(&customer). + Error + + return &customer, err +} + +func (repo *CustomerRepo) Find(langId uint, p find.Paging, filt *filters.FiltersList, search string) (*find.Found[model.UserInList], error) { + + query := db.DB. + Table("b2b_customers AS users"). + Select(` + users.id AS id, + users.email AS email, + users.first_name AS first_name, + users.last_name AS last_name + `) + + if search != "" { + words := strings.Fields(search) + if len(words) > 5 { + words = words[:5] + } + var conditions []string + var args []interface{} + for _, word := range words { + + conditions = append(conditions, ` + (LOWER(first_name) LIKE ? OR + LOWER(last_name) LIKE ? OR + LOWER(email) LIKE ?) + `) + + for range 3 { + args = append(args, "%"+strings.ToLower(word)+"%") + } + } + + conditionsQuery := strings.Join(conditions, " AND ") + + query = query.Where(conditionsQuery, args...) + + } + + query = query.Scopes(filt.All()...) + + found, err := find.Paginate[model.UserInList](langId, p, query) + + return &found, err +} + +func (repo *CustomerRepo) Save(customer *model.Customer) error { + return db.DB.Save(customer).Error +} + +func (repo *CustomerRepo) Create(customer *model.Customer) error { + return db.DB.Create(customer).Error +} + +// func (repo *CustomerRepo) Search( +// customerId uint, +// partnerCode string, +// p find.Paging, +// filt *filters.FiltersList, +// search string, +// ) (found find.Found[model.UserInList], err error) { +// words := strings.Fields(search) +// if len(words) > 5 { +// words = words[:5] +// } + +// query := ctx.DB(). +// Model(&model.Customer{}). +// Select("customer.id AS id, customer.first_name as first_name, customer.last_name as last_name, customer.phone_number AS phone_number, customer.email AS email, count(distinct investment_plan_contract.id) as iiplan_purchases, count(distinct `order`.id) as single_purchases, entity.name as entity_name"). +// Where("customer.id <> ?", customerId). +// Where("(customer.id IN (SELECT id FROM customer WHERE partner_code IN (WITH RECURSIVE partners AS (SELECT code AS dst FROM partner WHERE code = ? UNION SELECT code FROM partner JOIN partners ON partners.dst = partner.superior_code) SELECT dst FROM partners)) OR customer.recommender_code = ?)", partnerCode, partnerCode). +// Scopes(view.CustomerListQuery()) + +// var conditions []string +// var args []interface{} +// for _, word := range words { + +// conditions = append(conditions, ` +// (LOWER(first_name) LIKE ? OR +// LOWER(last_name) LIKE ? OR +// phone_number LIKE ? OR +// LOWER(email) LIKE ?) +// `) + +// for i := 0; i < 4; i++ { +// args = append(args, "%"+strings.ToLower(word)+"%") +// } +// } + +// finalQuery := strings.Join(conditions, " AND ") + +// query = query.Where(finalQuery, args...). +// Scopes(filt.All()...) + +// found, err = find.Paginate[V](ctx, p, query) + +// return found, errs.Recorded(span, err) +// } + +// func (repo *ListRepo) ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) { +// var list []model.UserInList +// var total int64 + +// query := db.Get(). +// Table("b2b_customers AS users"). +// Select(` +// users.id AS id, +// users.email AS email, +// users.first_name AS first_name, +// users.last_name AS last_name, +// users.role AS role +// `) + +// // Apply all filters +// if filt != nil { +// filt.ApplyAll(query) +// } + +// // run counter first as query is without limit and offset +// err := query.Count(&total).Error +// if err != nil { +// return find.Found[model.UserInList]{}, err +// } + +// err = query. +// Order("users.id DESC"). +// Limit(p.Limit()). +// Offset(p.Offset()). +// Find(&list).Error +// if err != nil { +// return find.Found[model.UserInList]{}, err +// } + +// return find.Found[model.UserInList]{ +// Items: list, +// Count: uint(total), +// }, nil +// } diff --git a/app/repos/listRepo/listRepo.go b/app/repos/listRepo/listRepo.go deleted file mode 100644 index d31ebda..0000000 --- a/app/repos/listRepo/listRepo.go +++ /dev/null @@ -1,121 +0,0 @@ -package listRepo - -import ( - "git.ma-al.com/goc_daniel/b2b/app/config" - "git.ma-al.com/goc_daniel/b2b/app/db" - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" - "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_marek/gormcol" - "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" -) - -type UIListRepo interface { - ListProducts(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) - ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) -} - -type ListRepo struct{} - -func New() UIListRepo { - return &ListRepo{} -} - -func (repo *ListRepo) ListProducts(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { - var list []model.ProductInList - var total int64 - - query := db.Get(). - Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps"). - Select(` - ps.id_product AS product_id, - pl.name AS name, - pl.link_rewrite AS link_rewrite, - CONCAT(?, '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, - cl.name AS category_name, - p.reference AS reference, - COALESCE(v.variants_number, 0) AS variants_number, - sa.quantity AS quantity - `, config.Get().Image.ImagePrefix). - Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product"). - Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). - Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). - Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). - Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product"). - Joins("LEFT JOIN variants v ON v.id_product = ps.id_product"). - Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0"). - Where("ps.active = ?", 1). - Group("ps.id_product"). - Clauses(exclause.With{CTEs: []exclause.CTE{ - { - Name: "variants", - Subquery: exclause.Subquery{DB: db.Get().Model(&dbmodel.PsProductAttributeShop{}).Select("id_product", "COUNT(*) AS variants_number").Group("id_product")}, - }, - }}) - - // Apply all filters - if filt != nil { - filt.ApplyAll(query) - } - - // run counter first as query is without limit and offset - err := query.Count(&total).Error - if err != nil { - return find.Found[model.ProductInList]{}, err - } - - err = query. - Order("ps.id_product DESC"). - Limit(p.Limit()). - Offset(p.Offset()). - Find(&list).Error - if err != nil { - return find.Found[model.ProductInList]{}, err - } - - return find.Found[model.ProductInList]{ - Items: list, - Count: uint(total), - }, nil -} - -func (repo *ListRepo) ListUsers(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.UserInList], error) { - var list []model.UserInList - var total int64 - - query := db.Get(). - Table("b2b_customers AS users"). - Select(` - users.id AS id, - users.email AS email, - users.first_name AS first_name, - users.last_name AS last_name, - users.role AS role - `) - - // Apply all filters - if filt != nil { - filt.ApplyAll(query) - } - - // run counter first as query is without limit and offset - err := query.Count(&total).Error - if err != nil { - return find.Found[model.UserInList]{}, err - } - - err = query. - Order("users.id DESC"). - Limit(p.Limit()). - Offset(p.Offset()). - Find(&list).Error - if err != nil { - return find.Found[model.UserInList]{}, err - } - - return find.Found[model.UserInList]{ - Items: list, - Count: uint(total), - }, nil -} diff --git a/app/repos/productDescriptionRepo/productDescriptionRepo.go b/app/repos/productDescriptionRepo/productDescriptionRepo.go index 391ecb0..5083a42 100644 --- a/app/repos/productDescriptionRepo/productDescriptionRepo.go +++ b/app/repos/productDescriptionRepo/productDescriptionRepo.go @@ -52,7 +52,7 @@ func (r *ProductDescriptionRepo) GetProductDescription(productID uint, productid `+dbmodel.PsProductLangCols.AvailableLater.TabCol()+` AS available_later, `+dbmodel.PsProductLangCols.DeliveryInStock.TabCol()+` AS delivery_in_stock, `+dbmodel.PsProductLangCols.DeliveryOutStock.TabCol()+` AS delivery_out_stock, - `+dbmodel.PsProductLangCols.Usage.TabCol()+` AS _usage_, + `+dbmodel.PsProductLangCols.Usage.TabCol()+` AS `+"`usage`"+`, CONCAT(?, '/', `+dbmodel.PsImageShopCols.IDImage.TabCol()+`, '-large_default/', `+dbmodel.PsProductLangCols.LinkRewrite.TabCol()+`, '.webp') AS image_link `, config.Get().Image.ImagePrefix). Joins("JOIN " + dbmodel.TableNamePsImageShop + diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 7c6c08f..4450b52 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -4,11 +4,23 @@ import ( "encoding/json" "fmt" + "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" + "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_marek/gormcol" + "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" ) type UIProductsRepo interface { GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) + Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) + AddToFavorites(userID uint, productID uint) error + RemoveFromFavorites(userID uint, productID uint) error + ExistsInFavorites(userID uint, productID uint) (bool, error) + ProductInDatabase(productID uint) (bool, error) } type ProductsRepo struct{} @@ -29,7 +41,6 @@ func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_custo return nil, err } - // Optional: validate it's valid JSON if !json.Valid([]byte(productStr)) { return nil, fmt.Errorf("invalid json returned from stored procedure") } @@ -37,3 +48,116 @@ func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_custo raw := json.RawMessage(productStr) return &raw, nil } + +func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { + var list []model.ProductInList + var total int64 + + query := db.Get(). + Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps"). + Select(` + ps.id_product AS product_id, + pl.name AS name, + pl.link_rewrite AS link_rewrite, + CONCAT(?, '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, + cl.name AS category_name, + p.reference AS reference, + COALESCE(v.variants_number, 0) AS variants_number, + sa.quantity AS quantity, + COALESCE(f.is_favorite, 0) AS is_favorite + `, config.Get().Image.ImagePrefix). + Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product"). + Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). + Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). + Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). + Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product"). + Joins("LEFT JOIN variants v ON v.id_product = ps.id_product"). + Joins("LEFT JOIN favorites f ON f.id_product = ps.id_product"). + Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0"). + Where("ps.active = ?", 1). + Group("ps.id_product"). + Clauses(exclause.With{ + CTEs: []exclause.CTE{ + { + Name: "variants", + Subquery: exclause.Subquery{ + DB: db.Get(). + Model(&dbmodel.PsProductAttributeShop{}). + Select("id_product", "COUNT(*) AS variants_number"). + Group("id_product"), + }, + }, + + { + Name: "favorites", + Subquery: exclause.Subquery{ + DB: db.Get(). + Table("b2b_favorites"). + Select(` + product_id AS id_product, + COUNT(*) > 0 AS is_favorite + `). + Where("user_id = ?", userID). + Group("product_id"), + }, + }, + }, + }). + Order("ps.id_product DESC") + + // Apply all filters + if filt != nil { + filt.ApplyAll(query) + } + + // run counter first as query is without limit and offset + err := query.Count(&total).Error + if err != nil { + return find.Found[model.ProductInList]{}, err + } + + err = query. + Limit(p.Limit()). + Offset(p.Offset()). + Find(&list).Error + if err != nil { + return find.Found[model.ProductInList]{}, err + } + + return find.Found[model.ProductInList]{ + Items: list, + Count: uint(total), + }, nil +} + +func (repo *ProductsRepo) AddToFavorites(userID uint, productID uint) error { + fav := model.B2bFavorite{ + UserID: userID, + ProductID: productID, + } + return db.Get().Create(&fav).Error +} + +func (repo *ProductsRepo) RemoveFromFavorites(userID uint, productID uint) error { + return db.Get(). + Where("user_id = ? AND product_id = ?", userID, productID). + Delete(&model.B2bFavorite{}).Error +} + +func (repo *ProductsRepo) ExistsInFavorites(userID uint, productID uint) (bool, error) { + var count int64 + err := db.Get(). + Table("b2b_favorites"). + Where("user_id = ? AND product_id = ?", userID, productID). + Count(&count).Error + return count >= 1, err +} + +func (repo *ProductsRepo) ProductInDatabase(productID uint) (bool, error) { + var count int64 + err := db.Get(). + Table(dbmodel.TableNamePsProduct). + Where(dbmodel.PsProductCols.IDProduct.Col()+" = ?", productID). + Count(&count).Error + return count >= 1, err +} diff --git a/app/repos/rolesRepo/rolesRepo.go b/app/repos/rolesRepo/rolesRepo.go new file mode 100644 index 0000000..e87e10f --- /dev/null +++ b/app/repos/rolesRepo/rolesRepo.go @@ -0,0 +1,22 @@ +package roleRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UIRolesRepo interface { + Get(id uint) (*model.Role, error) +} + +type RolesRepo struct{} + +func New() UIRolesRepo { + return &RolesRepo{} +} + +func (r *RolesRepo) Get(id uint) (*model.Role, error) { + var role model.Role + err := db.DB.First(&role, id).Error + return &role, err +} diff --git a/app/repos/searchRepo/searchRepo.go b/app/repos/searchRepo/searchRepo.go index 05afd3a..de4d5ea 100644 --- a/app/repos/searchRepo/searchRepo.go +++ b/app/repos/searchRepo/searchRepo.go @@ -32,12 +32,12 @@ func New() UISearchRepo { } func (r *SearchRepo) Search(index string, body []byte) (*SearchProxyResponse, error) { - url := fmt.Sprintf("%s/indexes/%s/search", r.cfg.MailiSearch.ServerURL, index) + url := fmt.Sprintf("%s/indexes/%s/search", r.cfg.MeiliSearch.ServerURL, index) return r.doRequest(http.MethodPost, url, body) } func (r *SearchRepo) GetIndexSettings(index string) (*SearchProxyResponse, error) { - url := fmt.Sprintf("%s/indexes/%s/settings", r.cfg.MailiSearch.ServerURL, index) + url := fmt.Sprintf("%s/indexes/%s/settings", r.cfg.MeiliSearch.ServerURL, index) return r.doRequest(http.MethodGet, url, nil) } @@ -55,8 +55,8 @@ func (r *SearchRepo) doRequest(method, url string, body []byte) (*SearchProxyRes } req.Header.Set("Content-Type", "application/json") - if r.cfg.MailiSearch.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.cfg.MailiSearch.ApiKey)) + if r.cfg.MeiliSearch.ApiKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.cfg.MeiliSearch.ApiKey)) } client := &http.Client{} diff --git a/app/repos/storageRepo/storageRepo.go b/app/repos/storageRepo/storageRepo.go new file mode 100644 index 0000000..69dc906 --- /dev/null +++ b/app/repos/storageRepo/storageRepo.go @@ -0,0 +1,178 @@ +package storageRepo + +import ( + "io" + "os" + "path/filepath" + "time" + + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UIStorageRepo interface { + SaveWebdavToken(user_id uint, hash_token string, expires_at *time.Time) error + EntryInfo(abs_path string) (os.FileInfo, error) + ListContent(abs_path string) (*[]model.EntryInList, error) + OpenFile(abs_path string) (*os.File, error) + Put(abs_path string, src io.Reader) error + Delete(abs_path string) error + Mkcol(abs_path string) error + Move(src_abs_path string, dest_abs_path string) error + Copy(src_abs_path string, dest_abs_path string) error +} + +type StorageRepo struct{} + +func New() UIStorageRepo { + return &StorageRepo{} +} + +func (r *StorageRepo) SaveWebdavToken(user_id uint, hash_token string, expires_at *time.Time) error { + return db.DB. + Table("b2b_customers"). + Where("id = ?", user_id). + Updates(map[string]interface{}{ + "webdav_token": hash_token, + "webdav_expires": expires_at, + }). + Error +} + +func (r *StorageRepo) EntryInfo(abs_path string) (os.FileInfo, error) { + return os.Stat(abs_path) +} + +func (r *StorageRepo) ListContent(abs_path string) (*[]model.EntryInList, error) { + entries, err := os.ReadDir(abs_path) + if err != nil { + return nil, err + } + + var entries_in_list []model.EntryInList + + for _, entry := range entries { + var next_entry_in_list model.EntryInList + next_entry_in_list.Name = entry.Name() + next_entry_in_list.IsFolder = entry.IsDir() + + entries_in_list = append(entries_in_list, next_entry_in_list) + } + + return &entries_in_list, nil +} + +func (r *StorageRepo) OpenFile(abs_path string) (*os.File, error) { + return os.Open(abs_path) +} + +func (r *StorageRepo) Put(abs_path string, src io.Reader) error { + // Write to a temp file in the same directory, then atomically rename. + tmp, err := os.CreateTemp(filepath.Dir(abs_path), ".put-*") + if err != nil { + return err + } + tmp_name := tmp.Name() + cleanup_tmp := true + defer func() { + _ = tmp.Close() + if cleanup_tmp { + _ = os.Remove(tmp_name) + } + }() + + _, err = io.Copy(tmp, src) + if err != nil { + return err + } + + err = tmp.Sync() + if err != nil { + return err + } + err = tmp.Close() + if err != nil { + return err + } + + err = os.Chmod(tmp_name, 0o644) + if err != nil { + return err + } + + err = os.Rename(tmp_name, abs_path) + if err != nil { + return err + } + + cleanup_tmp = false + return nil +} + +func (r *StorageRepo) Delete(abs_path string) error { + return os.RemoveAll(abs_path) +} + +func (r *StorageRepo) Mkcol(abs_path string) error { + return os.Mkdir(abs_path, 0755) +} + +func (r *StorageRepo) Move(src_abs_path string, dest_abs_path string) error { + return os.Rename(src_abs_path, dest_abs_path) +} + +func (r *StorageRepo) Copy(src_abs_path string, dest_abs_path string) error { + info, err := os.Stat(src_abs_path) + if err != nil { + return err + } + + if info.IsDir() { + return r.copyDir(src_abs_path, dest_abs_path) + } else { + return r.copyFile(src_abs_path, dest_abs_path) + } +} + +func (r *StorageRepo) copyFile(src_abs_path string, dest_abs_path string) error { + f, err := os.Open(src_abs_path) + if err != nil { + return err + } + defer f.Close() + + err = r.Put(dest_abs_path, f) + return err +} + +func (r *StorageRepo) copyDir(src_abs_path string, dest_abs_path string) error { + if err := os.Mkdir(dest_abs_path, 0755); err != nil { + return err + } + + entries, err := os.ReadDir(src_abs_path) + if err != nil { + return err + } + + for _, entry := range entries { + + entity_src_path := filepath.Join(src_abs_path, entry.Name()) + entity_dst_Path := filepath.Join(dest_abs_path, entry.Name()) + + if entry.IsDir() { + err = r.copyDir(entity_src_path, entity_dst_Path) + if err != nil { + return err + } + + } else { + err = r.copyFile(entity_src_path, entity_dst_Path) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/app/service/addressesService/addressesService.go b/app/service/addressesService/addressesService.go new file mode 100644 index 0000000..b077486 --- /dev/null +++ b/app/service/addressesService/addressesService.go @@ -0,0 +1,152 @@ +package addressesService + +import ( + "encoding/json" + "fmt" + "strings" + + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/addressesRepo" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" +) + +type AddressesService struct { + repo addressesRepo.UIAddressesRepo +} + +func New() *AddressesService { + return &AddressesService{ + repo: addressesRepo.New(), + } +} + +func (s *AddressesService) GetTemplate(country_id uint) (model.AddressField, error) { + switch country_id { + + case 1: // Poland + return model.AddressPL{}, nil + + case 2: // Great Britain + return model.AddressGB{}, nil + + case 3: // Czech Republic + return model.AddressCZ{}, nil + + case 4: // Germany + return model.AddressDE{}, nil + + default: + return nil, responseErrors.ErrInvalidCountryID + } +} + +func (s *AddressesService) AddNewAddress(user_id uint, address_info string, country_id uint) error { + amt, err := s.repo.UserAddressesAmt(user_id) + if err != nil { + return err + } else if amt >= constdata.MAX_AMOUNT_OF_ADDRESSES_PER_USER { + return responseErrors.ErrMaxAmtOfAddressesReached + } + + _, err = s.validateAddressJson(address_info, country_id) + if err != nil { + return err + } + + return s.repo.AddNewAddress(user_id, address_info, country_id) +} + +// country_id = 0 means that country_id remains unchanged +func (s *AddressesService) ModifyAddress(user_id uint, address_id uint, address_info string, country_id uint) error { + amt, err := s.repo.UserHasAddress(user_id, address_id) + if err != nil { + return err + } else if amt != 1 { + return responseErrors.ErrUserHasNoSuchAddress + } + + _, err = s.validateAddressJson(address_info, country_id) + if err != nil { + return err + } + + return s.repo.UpdateAddress(user_id, address_id, address_info, country_id) +} + +func (s *AddressesService) RetrieveAddressesInfo(user_id uint) (*[]model.AddressUnparsed, error) { + parsed_addresses, err := s.repo.RetrieveAddresses(user_id) + if err != nil { + return nil, err + } + + var unparsed_addresses []model.AddressUnparsed + + for i := 0; i < len(*parsed_addresses); i++ { + var next_address model.AddressUnparsed + next_address.ID = (*parsed_addresses)[i].ID + next_address.CustomerID = (*parsed_addresses)[i].CustomerID + next_address.CountryID = (*parsed_addresses)[i].CountryID + + next_address.AddressInfo, err = s.validateAddressJson((*parsed_addresses)[i].AddressInfo, next_address.CountryID) + // log such errors + if err != nil { + fmt.Printf("err: %v\n", err) + } + + unparsed_addresses = append(unparsed_addresses, next_address) + } + + return &unparsed_addresses, nil +} + +func (s *AddressesService) DeleteAddress(user_id uint, address_id uint) error { + amt, err := s.repo.UserHasAddress(user_id, address_id) + if err != nil { + return err + } else if amt != 1 { + return responseErrors.ErrUserHasNoSuchAddress + } + + return s.repo.DeleteAddress(user_id, address_id) +} + +// validateAddressJson makes sure that the info string represents a valid json of address in given country +func (s *AddressesService) validateAddressJson(info string, country_id uint) (model.AddressField, error) { + dec := json.NewDecoder(strings.NewReader(info)) + dec.DisallowUnknownFields() + + switch country_id { + + case 1: // Poland + var address model.AddressPL + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + case 2: // Great Britain + var address model.AddressGB + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + case 3: // Czech Republic + var address model.AddressCZ + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + case 4: // Germany + var address model.AddressDE + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + default: + return nil, responseErrors.ErrInvalidCountryID + } +} diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index 6effc43..83b6b2f 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -11,6 +11,8 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/customerRepo" + roleRepo "git.ma-al.com/goc_daniel/b2b/app/repos/rolesRepo" "git.ma-al.com/goc_daniel/b2b/app/service/emailService" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -35,17 +37,21 @@ type JWTClaims struct { // AuthService handles authentication operations type AuthService struct { - db *gorm.DB - config *config.AuthConfig - email *emailService.EmailService + db *gorm.DB + config *config.AuthConfig + email *emailService.EmailService + customerRepo customerRepo.UICustomerRepo + roleRepo roleRepo.UIRolesRepo } // NewAuthService creates a new AuthService instance func NewAuthService() *AuthService { svc := &AuthService{ - db: db.Get(), - config: &config.Get().Auth, - email: emailService.NewEmailService(), + db: db.Get(), + config: &config.Get().Auth, + email: emailService.NewEmailService(), + customerRepo: customerRepo.New(), + roleRepo: roleRepo.New(), } // Auto-migrate the refresh_tokens table if svc.db != nil { @@ -83,6 +89,15 @@ func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, strin // Update last login time now := time.Now() user.LastLoginAt = &now + + if req.LangID != nil { + _, err := s.GetLangISOCode(*req.LangID) + if err != nil { + return nil, "", responseErrors.ErrBadLangID + } + user.LangID = *req.LangID + } + s.db.Save(&user) // Generate access token (JWT) @@ -144,7 +159,6 @@ func (s *AuthService) Register(req *model.RegisterRequest) error { Password: string(hashedPassword), FirstName: req.FirstName, LastName: req.LastName, - Role: model.Role{}, Provider: model.ProviderLocal, IsActive: false, EmailVerified: false, @@ -443,6 +457,19 @@ func (s *AuthService) GetUserByEmail(email string) (*model.Customer, error) { return &user, nil } +func (s *AuthService) GetUserByWebdavToken(rawToken string) (*model.Customer, error) { + tokenHash := hashToken(rawToken) + + var user model.Customer + if err := s.db.Where("webdav_token = ?", tokenHash).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, responseErrors.ErrUserNotFound + } + return nil, fmt.Errorf("database error: %w", err) + } + return &user, nil +} + // createRefreshToken generates a random opaque token, stores its hash in the DB, and returns the raw token. func (s *AuthService) createRefreshToken(userID uint) (string, error) { // Generate 32 random bytes → 64-char hex string diff --git a/app/service/authService/google_oauth.go b/app/service/authService/google_oauth.go index c26da16..d517c6d 100644 --- a/app/service/authService/google_oauth.go +++ b/app/service/authService/google_oauth.go @@ -108,26 +108,32 @@ func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, st // findOrCreateGoogleUser finds an existing user by Google provider ID or email, // or creates a new one. func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model.Customer, error) { - var user model.Customer + var user *model.Customer // Try to find by provider + provider_id - err := s.db.Where("provider = ? AND provider_id = ?", model.ProviderGoogle, info.ID).First(&user).Error + user, err := s.customerRepo.GetByExternalProviderId(model.ProviderGoogle, info.ID) if err == nil { // Update avatar in case it changed user.AvatarURL = info.Picture - s.db.Save(&user) - return &user, nil + err = s.customerRepo.Save(user) + if err != nil { + return nil, err + } + return user, nil } // Try to find by email (user may have registered locally before) - err = s.db.Where("email = ?", info.Email).First(&user).Error + user, err = s.customerRepo.GetByEmail(info.Email) if err == nil { // Link Google provider to existing account user.Provider = model.ProviderGoogle user.ProviderID = info.ID user.AvatarURL = info.Picture user.IsActive = true - s.db.Save(&user) + err = s.customerRepo.Save(user) + if err != nil { + return nil, err + } // If email has not been verified yet, send email to admin. if !user.EmailVerified { @@ -139,7 +145,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. } user.EmailVerified = true - return &user, nil + return user, nil } // Create new user @@ -148,16 +154,16 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. FirstName: info.GivenName, LastName: info.FamilyName, Provider: model.ProviderGoogle, + RoleID: 1, // user ProviderID: info.ID, AvatarURL: info.Picture, - Role: model.Role{}, IsActive: true, EmailVerified: true, LangID: 2, // default is english CountryID: 2, // default is England } - if err := s.db.Create(&newUser).Error; err != nil { + if err := s.customerRepo.Create(&newUser); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } @@ -170,6 +176,13 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model. } } + var role *model.Role + role, err = s.roleRepo.Get(newUser.RoleID) + if err != nil { + return nil, err + } + newUser.Role = role + return &newUser, nil } diff --git a/app/service/customerService/customerService.go b/app/service/customerService/customerService.go index 7af553c..bce463d 100644 --- a/app/service/customerService/customerService.go +++ b/app/service/customerService/customerService.go @@ -3,6 +3,8 @@ package customerService import ( "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/repos/customerRepo" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) type CustomerService struct { @@ -18,3 +20,7 @@ func New() *CustomerService { func (s *CustomerService) GetById(id uint) (*model.Customer, error) { return s.repo.Get(id) } + +func (s *CustomerService) Find(langId uint, p find.Paging, filt *filters.FiltersList, search string) (*find.Found[model.UserInList], error) { + return s.repo.Find(langId, p, filt, search) +} diff --git a/app/service/emailService/email.go b/app/service/emailService/email.go index 6b1e082..29cc9bb 100644 --- a/app/service/emailService/email.go +++ b/app/service/emailService/email.go @@ -10,6 +10,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/service/langsService" "git.ma-al.com/goc_daniel/b2b/app/templ/emails" + 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/view" ) @@ -133,6 +134,6 @@ func (s *EmailService) passwordResetEmailTemplate(name, resetURL string, langID // newUserAdminNotificationTemplate returns the HTML template for admin notification func (s *EmailService) newUserAdminNotificationTemplate(userEmail, userName, baseURL string) string { buf := bytes.Buffer{} - emails.EmailAdminNotificationWrapper(view.EmailLayout[view.EmailAdminNotificationData]{LangID: 2, Data: view.EmailAdminNotificationData{UserEmail: userEmail, UserName: userName, BaseURL: baseURL}}).Render(context.Background(), &buf) + emails.EmailAdminNotificationWrapper(view.EmailLayout[view.EmailAdminNotificationData]{LangID: constdata.ADMIN_NOTIFICATION_LANGUAGE, Data: view.EmailAdminNotificationData{UserEmail: userEmail, UserName: userName, BaseURL: baseURL}}).Render(context.Background(), &buf) return buf.String() } diff --git a/app/service/listService/listService.go b/app/service/listService/listService.go deleted file mode 100644 index d3d168b..0000000 --- a/app/service/listService/listService.go +++ /dev/null @@ -1,26 +0,0 @@ -package listService - -import ( - "git.ma-al.com/goc_daniel/b2b/app/model" - "git.ma-al.com/goc_daniel/b2b/app/repos/listRepo" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" -) - -type ListService struct { - listRepo listRepo.UIListRepo -} - -func New() *ListService { - return &ListService{ - listRepo: listRepo.New(), - } -} - -func (s *ListService) ListProducts(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { - return s.listRepo.ListProducts(id_lang, p, filters) -} - -func (s *ListService) ListUsers(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.UserInList], error) { - return s.listRepo.ListUsers(id_lang, p, filters) -} diff --git a/app/service/meiliService/meiliService.go b/app/service/meiliService/meiliService.go index 87b196b..6d9120a 100644 --- a/app/service/meiliService/meiliService.go +++ b/app/service/meiliService/meiliService.go @@ -27,8 +27,8 @@ type MeiliService struct { func New() *MeiliService { client := meilisearch.New( - config.Get().MailiSearch.ServerURL, - meilisearch.WithAPIKey(config.Get().MailiSearch.ApiKey), + config.Get().MeiliSearch.ServerURL, + meilisearch.WithAPIKey(config.Get().MeiliSearch.ApiKey), ) return &MeiliService{ diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index 66245f1..de6d70e 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -3,8 +3,12 @@ package productService import ( "encoding/json" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" + "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" ) type ProductService struct { @@ -25,3 +29,47 @@ func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_ return products, nil } + +func (s *ProductService) Find(id_lang, userID uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { + return s.productsRepo.Find(id_lang, userID, p, filters) +} + +func (s *ProductService) AddToFavorites(userID uint, productID uint) error { + exists, err := s.productsRepo.ProductInDatabase(productID) + if err != nil { + return err + } + if !exists { + return responseErrors.ErrProductNotFound + } + + exists, err = s.productsRepo.ExistsInFavorites(userID, productID) + if err != nil { + return err + } + if exists { + return responseErrors.ErrAlreadyInFavorites + } + + return s.productsRepo.AddToFavorites(userID, productID) +} + +func (s *ProductService) RemoveFromFavorites(userID uint, productID uint) error { + exists, err := s.productsRepo.ProductInDatabase(productID) + if err != nil { + return err + } + if !exists { + return responseErrors.ErrProductNotFound + } + + exists, err = s.productsRepo.ExistsInFavorites(userID, productID) + if err != nil { + return err + } + if !exists { + return responseErrors.ErrNotInFavorites + } + + return s.productsRepo.RemoveFromFavorites(userID, productID) +} diff --git a/app/service/productTranslationService/productTranslationService.go b/app/service/productTranslationService/productTranslationService.go index 55f4f66..6c4a559 100644 --- a/app/service/productTranslationService/productTranslationService.go +++ b/app/service/productTranslationService/productTranslationService.go @@ -90,13 +90,24 @@ func (s *ProductTranslationService) GetProductDescription(userID uint, productID // Updates relevant fields with the "updates" map func (s *ProductTranslationService) SaveProductDescription(userID uint, productID uint, productLangID uint, updates map[string]string) error { // only some fields can be affected - allowedFields := []string{"description", "description_short", "meta_description", "meta_title", "name", "available_now", "available_later", "usage"} + allowedFields := []string{"description", "description_short", "link_rewrite", "meta_description", "meta_keywords", "meta_title", "name", + "available_now", "available_later", "delivery_in_stock", "delivery_out_stock", "usage"} for key := range updates { if !slices.Contains(allowedFields, key) { return responseErrors.ErrBadField } } + if text, exists := updates["link_rewrite"]; exists { + // sanitize and check that link_rewrite is a valid url slug + sanitized := SanitizeSlug(text) + if !IsValidSlug(sanitized) { + return responseErrors.ErrInvalidURLSlug + } + + updates["link_rewrite"] = sanitized + } + // check that fields description, description_short and usage, if they exist, have a valid html format mustBeHTML := []string{"description", "description_short", "usage"} for i := 0; i < len(mustBeHTML); i++ { @@ -138,20 +149,28 @@ func (s *ProductTranslationService) TranslateProductDescription(userID uint, pro fields := []*string{&productDescription.Description, &productDescription.DescriptionShort, + &productDescription.LinkRewrite, &productDescription.MetaDescription, + &productDescription.MetaKeywords, &productDescription.MetaTitle, &productDescription.Name, &productDescription.AvailableNow, &productDescription.AvailableLater, + &productDescription.DeliveryInStock, + &productDescription.DeliveryOutStock, &productDescription.Usage, } keys := []string{"translation_of_product_description", "translation_of_product_short_description", + "translation_of_product_url_link", "translation_of_product_meta_description", + "translation_of_product_meta_keywords", "translation_of_product_meta_title", "translation_of_product_name", - "translation_of_product_available_now", - "translation_of_product_available_later", + "translation_of_product_available_now_message", + "translation_of_product_available_later_message", + "translation_of_product_delivery_in_stock_message", + "translation_of_product_delivery_out_stock_message", "translation_of_product_usage", } diff --git a/app/service/productTranslationService/sanitizeURLSlug.go b/app/service/productTranslationService/sanitizeURLSlug.go new file mode 100644 index 0000000..ea69d7c --- /dev/null +++ b/app/service/productTranslationService/sanitizeURLSlug.go @@ -0,0 +1,69 @@ +package productTranslationService + +import ( + "strings" + "unicode" + + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "github.com/dlclark/regexp2" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +func IsValidSlug(s string) bool { + var slug_regex2 = regexp2.MustCompile(constdata.SLUG_REGEX, regexp2.None) + + ok, _ := slug_regex2.MatchString(s) + return ok +} + +func SanitizeSlug(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + + // First apply explicit transliteration for language-specific letters. + s = transliterateWithTable(s) + + // Then normalize and strip any remaining combining marks. + s = removeDiacritics(s) + + // Replace all non-alphanumeric runs with "-" + var non_alphanum_regex2 = regexp2.MustCompile(constdata.NON_ALNUM_REGEX, regexp2.None) + s, _ = non_alphanum_regex2.Replace(s, "-", -1, -1) + + // Collapse repeated "-" and trim edges + var multi_dash_regex2 = regexp2.MustCompile(constdata.MULTI_DASH_REGEX, regexp2.None) + s, _ = multi_dash_regex2.Replace(s, "-", -1, -1) + + s = strings.Trim(s, "-") + + return s +} + +func transliterateWithTable(s string) string { + var b strings.Builder + b.Grow(len(s)) + + for _, r := range s { + if repl, ok := constdata.TRANSLITERATION_TABLE[r]; ok { + b.WriteString(repl) + } else { + b.WriteRune(r) + } + } + + return b.String() +} + +func removeDiacritics(s string) string { + t := transform.Chain( + norm.NFD, + runes.Remove(runes.In(unicode.Mn)), + norm.NFC, + ) + out, _, err := transform.String(t, s) + if err != nil { + return s + } + return out +} diff --git a/app/service/storageService/storageService.go b/app/service/storageService/storageService.go new file mode 100644 index 0000000..f5ffba8 --- /dev/null +++ b/app/service/storageService/storageService.go @@ -0,0 +1,283 @@ +package storageService + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/storageRepo" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" +) + +type StorageService struct { + storageRepo storageRepo.UIStorageRepo +} + +func New() *StorageService { + return &StorageService{ + storageRepo: storageRepo.New(), + } +} + +func (s *StorageService) EntryInfo(abs_path string) (os.FileInfo, error) { + return s.storageRepo.EntryInfo(abs_path) +} + +func (s *StorageService) NewWebdavToken(user_id uint) (string, error) { + b := make([]byte, constdata.NBYTES_IN_WEBDAV_TOKEN) + + _, err := rand.Read(b) + if err != nil { + return "", err + } + + raw_token := hex.EncodeToString(b) + hash_token_bytes := sha256.Sum256([]byte(raw_token)) + hash_token := hex.EncodeToString(hash_token_bytes[:]) + expires_at := time.Now().Add(24 * time.Hour) + + return raw_token, s.storageRepo.SaveWebdavToken(user_id, hash_token, &expires_at) +} + +func (s *StorageService) DownloadFilePrep(abs_path string) (*os.File, string, int64, error) { + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil || info.IsDir() { + return nil, "", 0, responseErrors.ErrFileDoesNotExist + } + + f, err := s.storageRepo.OpenFile(abs_path) + if err != nil { + return nil, "", 0, err + } + + return f, filepath.Base(abs_path), info.Size(), nil +} + +func (s *StorageService) ListContent(abs_path string) (*[]model.EntryInList, error) { + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil || !info.IsDir() { + return nil, responseErrors.ErrFolderDoesNotExist + } + + entries_in_list, err := s.storageRepo.ListContent(abs_path) + return entries_in_list, err +} + +func (s *StorageService) Propfind(root string, abs_path string, depth string) (string, error) { + href := href(root, abs_path) + + max_depth := 0 + switch depth { + case "0": + max_depth = 0 + case "1": + max_depth = 1 + case "infinity": + max_depth = 32 + default: + max_depth = 0 + } + + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil { + return "", err + } + + xml := `` + + `` + + if info.IsDir() { + href = ensureTrailingSlash(href) + next_xml, err := buildDirPropResponse(abs_path, href, info, max_depth) + if err != nil { + return "", err + } + xml += next_xml + } else { + xml += buildFilePropResponse(href, info) + } + + xml += `` + + return xml, nil +} + +func (s *StorageService) Put(abs_path string, src io.Reader) error { + return s.storageRepo.Put(abs_path, src) +} + +func (s *StorageService) Delete(abs_path string) error { + return s.storageRepo.Delete(abs_path) +} + +func (s *StorageService) Mkcol(abs_path string) error { + _, err := s.storageRepo.EntryInfo(abs_path) + if err == nil { + return responseErrors.ErrNameTaken + } else if os.IsNotExist(err) { + return s.storageRepo.Mkcol(abs_path) + } else { + return err + } +} + +func (s *StorageService) Move(src_abs_path string, dest_abs_path string) error { + return s.storageRepo.Move(src_abs_path, dest_abs_path) +} + +func (s *StorageService) Copy(src_abs_path string, dest_abs_path string) error { + return s.storageRepo.Copy(src_abs_path, dest_abs_path) +} + +func buildFilePropResponse(href string, info os.FileInfo) string { + name := info.Name() + return "" + + "" + + "" + xmlEscape(href) + "" + + "" + + "" + + "" + xmlEscape(name) + "" + + "" + strconv.FormatInt(info.Size(), 10) + "" + + "" + xmlEscape(info.ModTime().UTC().Format(http.TimeFormat)) + "" + + "" + + "" + + "HTTP/1.1 200 OK" + + "" + + "" +} + +func buildDirPropResponse(abs_path string, href string, info os.FileInfo, max_depth int) (string, error) { + name := info.Name() + + xml := "" + + "" + + "" + xmlEscape(ensureTrailingSlash(href)) + "" + + "" + + "" + + "" + xmlEscape(name) + "" + + "" + + "" + xmlEscape(info.ModTime().UTC().Format(http.TimeFormat)) + "" + + "" + + "HTTP/1.1 200 OK" + + "" + + "" + + if max_depth <= 0 { + return xml, nil + } + + entries, err := os.ReadDir(abs_path) + if err != nil { + return "", err + } + + for _, entry := range entries { + child_abs_path := filepath.Join(abs_path, entry.Name()) + child_href := path.Join(href, entry.Name()) + + child_info, err := entry.Info() + if err != nil { + return "", err + } + + var xml_next string + if entry.IsDir() { + xml_next, err = buildDirPropResponse(child_abs_path, ensureTrailingSlash(child_href), child_info, max_depth-1) + } else { + xml_next = buildFilePropResponse(child_href, child_info) + } + + if err != nil { + return "", err + } + xml += xml_next + } + + return xml, nil +} + +func ensureTrailingSlash(s string) string { + if s == "/" { + return s + } + if !strings.HasSuffix(s, "/") { + return s + "/" + } + return s +} + +func xmlEscape(s string) string { + var b strings.Builder + xml.EscapeText(&b, []byte(s)) + return b.String() +} + +// Returns href based on file's absolute path. Doesn't validate abs_path +func href(root string, abs_path string) string { + rel, _ := filepath.Rel(root, abs_path) + + if rel == "." { + return constdata.WEBDAV_HREF_ROOT + "/" + } + + rel = filepath.ToSlash(rel) + + parts := strings.Split(rel, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + + return strings.TrimRight(constdata.WEBDAV_HREF_ROOT, "/") + "/" + strings.Join(parts, "/") +} + +// AbsPath extracts an absolute path and validates it +func (s *StorageService) AbsPath(root string, relative_path string) (string, error) { + decoded, err := url.PathUnescape(relative_path) + if err != nil { + return "", err + } + + clean_name := filepath.Clean(decoded) + full_path := filepath.Join(root, clean_name) + + if full_path != root && !strings.HasPrefix(full_path, root+"/") { + return "", responseErrors.ErrAccessDenied + } + + return full_path, nil +} + +// ObtainDestPath extracts the absolute path based on URL absolute path +func (s *StorageService) ObtainDestPath(root string, dest_path string) (string, error) { + idx := strings.Index(dest_path, constdata.WEBDAV_TRIMMED_ROOT) + if idx == -1 { + return "", responseErrors.ErrAccessDenied + } + prefix_removed := dest_path[idx+len(constdata.WEBDAV_TRIMMED_ROOT):] + + decoded, err := url.PathUnescape(prefix_removed) + if err != nil { + return "", err + } + + clean_dest_path := filepath.Clean(decoded) + if clean_dest_path == "" { + return root, nil + } else if strings.HasPrefix(clean_dest_path, "/") { + return root + "/" + strings.TrimPrefix(clean_dest_path, "/"), nil + } else { + return "", responseErrors.ErrAccessDenied + } +} diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index 9fb53b5..aa62f27 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -4,6 +4,7 @@ package constdata const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$` const SHOP_ID = 1 const SHOP_DEFAULT_LANGUAGE = 1 +const ADMIN_NOTIFICATION_LANGUAGE = 2 // CATEGORY_TREE_ROOT_ID corresponds to id_category in ps_category which has is_root_category=1 const CATEGORY_TREE_ROOT_ID = 2 @@ -11,6 +12,36 @@ const CATEGORY_TREE_ROOT_ID = 2 const MAX_AMOUNT_OF_CARTS_PER_USER = 10 const DEFAULT_NEW_CART_NAME = "new cart" -const USER_LOCALES_NAME = "user" -const USER_LOCALES_ID = "userID" -const LANG_LOCALES_ID = "langID" +const MAX_AMOUNT_OF_ADDRESSES_PER_USER = 10 + +const USER_LOCALE = "user" + +// WEBDAV +const NBYTES_IN_WEBDAV_TOKEN = 32 +const WEBDAV_HREF_ROOT = "http://localhost:3000/api/v1/webdav/storage" +const WEBDAV_TRIMMED_ROOT = "localhost:3000/api/v1/webdav/storage" + +// Slug sanitization +const NON_ALNUM_REGEX = `[^a-z0-9]+` +const MULTI_DASH_REGEX = `-+` +const SLUG_REGEX = `^[a-z0-9]+(?:-[a-z0-9]+)*$` + +// Currently supports only German+Polish specific cases +var TRANSLITERATION_TABLE = map[rune]string{ + // German + 'ä': "ae", + 'ö': "oe", + 'ü': "ue", + 'ß': "ss", + + // Polish + 'ą': "a", + 'ć': "c", + 'ę': "e", + 'ł': "l", + 'ń': "n", + 'ó': "o", + 'ś': "s", + 'ż': "z", + 'ź': "z", +} diff --git a/app/utils/i18n/i18n.go b/app/utils/i18n/i18n.go index 3dfec66..5f3b6a0 100644 --- a/app/utils/i18n/i18n.go +++ b/app/utils/i18n/i18n.go @@ -8,6 +8,7 @@ import ( "sync" "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "github.com/gofiber/fiber/v3" ) @@ -177,7 +178,7 @@ func (s *TranslationsStore) ReloadTranslations(translations []model.Translation) // T_ is meant to be used to translate error messages and other system communicates. func T_[T ~string](c fiber.Ctx, key T, params ...interface{}) string { - if langID, ok := c.Locals("langID").(uint); ok { + if langID, ok := localeExtractor.GetLangID(c); ok { parts := strings.Split(string(key), ".") if len(parts) >= 2 { diff --git a/app/utils/localeExtractor/localeExtractor.go b/app/utils/localeExtractor/localeExtractor.go new file mode 100644 index 0000000..37bdb0a --- /dev/null +++ b/app/utils/localeExtractor/localeExtractor.go @@ -0,0 +1,39 @@ +package localeExtractor + +import ( + "git.ma-al.com/goc_daniel/b2b/app/model" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "github.com/gofiber/fiber/v3" +) + +func GetLangID(c fiber.Ctx) (uint, bool) { + user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) + if !ok || user_locale.OriginalUser == nil { + return 0, false + } + return user_locale.OriginalUser.LangID, true +} + +func GetUserID(c fiber.Ctx) (uint, bool) { + user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) + if !ok || user_locale.User == nil { + return 0, false + } + return user_locale.User.ID, true +} + +func GetOriginalUserRole(c fiber.Ctx) (model.Role, bool) { + user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) + if !ok || user_locale.OriginalUser == nil || user_locale.OriginalUser.Role == nil { + return model.Role{}, false + } + return *user_locale.OriginalUser.Role, true +} + +func GetCustomer(c fiber.Ctx) (*model.Customer, bool) { + user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) + if !ok || user_locale.User == nil { + return nil, false + } + return user_locale.User, true +} diff --git a/app/utils/query/find/find.go b/app/utils/query/find/find.go index 7d810ec..487c1d1 100644 --- a/app/utils/query/find/find.go +++ b/app/utils/query/find/find.go @@ -1,7 +1,6 @@ package find import ( - "errors" "reflect" "strings" @@ -28,18 +27,13 @@ type Found[T any] struct { Spec map[string]interface{} `json:"spec,omitempty"` } -// Wraps given query adding limit, offset clauses and SQL_CALC_FOUND_ROWS to it -// and running SELECT FOUND_ROWS() afterwards to fetch the total number -// (ignoring LIMIT) of results. The final results are wrapped into the -// [find.Found] type. func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error) { var items []T - var count uint64 + var count int64 - // stmt.Debug() + stmt.Count(&count) err := stmt. - Clauses(SqlCalcFound()). Offset(paging.Offset()). Limit(paging.Limit()). Find(&items). @@ -48,22 +42,14 @@ func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error return Found[T]{}, err } - countInterface, ok := stmt.Get(FOUND_ROWS_CTX_KEY) - if !ok { - return Found[T]{}, errors.New(FOUND_ROWS_CTX_KEY + " value was not found in the gorm db context") - } - if count, ok = countInterface.(uint64); !ok { - return Found[T]{}, errors.New("failed to cast value under " + FOUND_ROWS_CTX_KEY + " to uint64") - } - - columnsSpec := GetColumnsSpec[T](langID) + // columnsSpec := GetColumnsSpec[T](langID) return Found[T]{ Items: items, Count: uint(count), - Spec: map[string]interface{}{ - "columns": columnsSpec, - }, + // Spec: map[string]interface{}{ + // "columns": columnsSpec, + // }, }, err } diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index d2fce1a..6b3c548 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -9,14 +9,15 @@ 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") - ErrUserInactive = errors.New("user account is inactive") - ErrInvalidToken = errors.New("invalid token") - ErrTokenExpired = errors.New("token has expired") - ErrTokenRequired = errors.New("token is required") + ErrForbidden = errors.New("forbidden") + ErrInvalidBody = errors.New("invalid request body") + ErrNotAuthenticated = errors.New("not authenticated") + ErrUserNotFound = errors.New("user not found") + ErrUserInactive = errors.New("user account is inactive") + ErrInvalidToken = errors.New("invalid token") + ErrTokenExpired = errors.New("token has expired") + ErrTokenRequired = errors.New("token is required") + ErrAdminAccessRequired = errors.New("admin access required") // Typed errors for logging in and registering ErrInvalidCredentials = errors.New("invalid email or password") @@ -43,12 +44,16 @@ var ( // Typed errors for product description handler ErrBadAttribute = errors.New("bad or missing attribute value in header") ErrBadField = errors.New("this field can not be updated") + ErrInvalidURLSlug = errors.New("URL slug does not obey the industry standard") ErrInvalidXHTML = errors.New("text is not in xhtml format") ErrAIResponseFail = errors.New("AI responded with failure") ErrAIBadOutput = errors.New("AI response does not obey the format") - // Typed errors for product list handler - ErrBadPaging = errors.New("bad or missing paging attribute value in header") + // Typed errors for product handler + ErrBadPaging = errors.New("bad or missing paging attribute value in header") + ErrProductNotFound = errors.New("product with provided id does not exist") + ErrAlreadyInFavorites = errors.New("the product already is in your favorites") + ErrNotInFavorites = errors.New("the product already is not in your favorites") // Typed errors for menu handler ErrNoRootFound = errors.New("no root found in categories table") @@ -61,8 +66,21 @@ var ( 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 storage + ErrAccessDenied = errors.New("access denied!") + ErrFolderDoesNotExist = errors.New("folder does not exist") + ErrFileDoesNotExist = errors.New("file does not exist") + ErrNameTaken = errors.New("name taken") + ErrMissingFileFieldDocument = errors.New("missing file field 'document'") + // Typed errors for data parsing ErrJSONBody = errors.New("invalid JSON body") + + // Typed errors for addresses + ErrMaxAmtOfAddressesReached = errors.New("maximal amount of addresses per user reached") + ErrUserHasNoSuchAddress = errors.New("user has no such address") + ErrInvalidCountryID = errors.New("invalid country id") + ErrInvalidAddressJSON = errors.New("invalid address json") ) // Error represents an error with HTTP status code @@ -117,6 +135,8 @@ 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, ErrAdminAccessRequired): + return i18n.T_(c, "error.err_admin_access_required") case errors.Is(err, ErrBadLangID): return i18n.T_(c, "error.err_bad_lang_id") case errors.Is(err, ErrBadCountryID): @@ -142,6 +162,8 @@ func GetErrorCode(c fiber.Ctx, err error) string { return i18n.T_(c, "error.err_bad_attribute") case errors.Is(err, ErrBadField): return i18n.T_(c, "error.err_bad_field") + case errors.Is(err, ErrInvalidURLSlug): + return i18n.T_(c, "error.err_invalid_url_slug") case errors.Is(err, ErrInvalidXHTML): return i18n.T_(c, "error.err_invalid_html") case errors.Is(err, ErrAIResponseFail): @@ -151,26 +173,52 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrBadPaging): return i18n.T_(c, "error.err_bad_paging") + case errors.Is(err, ErrProductNotFound): + return i18n.T_(c, "error.err_product_not_found") + case errors.Is(err, ErrAlreadyInFavorites): + return i18n.T_(c, "error.err_already_in_favorites") + case errors.Is(err, ErrNotInFavorites): + return i18n.T_(c, "error.err_already_not_in_favorites") case errors.Is(err, ErrNoRootFound): - return i18n.T_(c, "error.no_root_found") + return i18n.T_(c, "error.err_no_root_found") case errors.Is(err, ErrCircularDependency): - return i18n.T_(c, "error.circular_dependency") + return i18n.T_(c, "error.err_circular_dependency") case errors.Is(err, ErrStartCategoryNotFound): - return i18n.T_(c, "error.start_category_not_found") + return i18n.T_(c, "error.err_start_category_not_found") case errors.Is(err, ErrRootNeverReached): - return i18n.T_(c, "error.root_never_reached") + return i18n.T_(c, "error.err_root_never_reached") case errors.Is(err, ErrMaxAmtOfCartsReached): - return i18n.T_(c, "error.max_amt_of_carts_reached") + return i18n.T_(c, "error.err_max_amt_of_carts_reached") case errors.Is(err, ErrUserHasNoSuchCart): - return i18n.T_(c, "error.user_has_no_such_cart") + return i18n.T_(c, "error.err_user_has_no_such_cart") case errors.Is(err, ErrProductOrItsVariationDoesNotExist): - return i18n.T_(c, "error.product_or_its_variation_does_not_exist") + return i18n.T_(c, "error.err_product_or_its_variation_does_not_exist") + + case errors.Is(err, ErrAccessDenied): + return i18n.T_(c, "error.err_access_denied") + case errors.Is(err, ErrFolderDoesNotExist): + return i18n.T_(c, "error.err_folder_does_not_exist") + case errors.Is(err, ErrFileDoesNotExist): + return i18n.T_(c, "error.err_file_does_not_exist") + case errors.Is(err, ErrNameTaken): + return i18n.T_(c, "error.err_name_taken") + case errors.Is(err, ErrMissingFileFieldDocument): + return i18n.T_(c, "error.err_missing_file_field_document") case errors.Is(err, ErrJSONBody): return i18n.T_(c, "error.err_json_body") + case errors.Is(err, ErrMaxAmtOfAddressesReached): + return i18n.T_(c, "error.err_max_amt_of_addresses_reached") + case errors.Is(err, ErrUserHasNoSuchAddress): + return i18n.T_(c, "error.err_user_has_no_such_address") + case errors.Is(err, ErrInvalidCountryID): + return i18n.T_(c, "error.err_invalid_country_id") + case errors.Is(err, ErrInvalidAddressJSON): + return i18n.T_(c, "error.err_invalid_address_json") + default: return i18n.T_(c, "error.err_internal_server_error") } @@ -195,6 +243,7 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrEmailPasswordRequired), errors.Is(err, ErrTokenRequired), errors.Is(err, ErrRefreshTokenRequired), + errors.Is(err, ErrAdminAccessRequired), errors.Is(err, ErrBadLangID), errors.Is(err, ErrBadCountryID), errors.Is(err, ErrPasswordsDoNotMatch), @@ -206,8 +255,12 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrBadAttribute), errors.Is(err, ErrBadField), + errors.Is(err, ErrInvalidURLSlug), errors.Is(err, ErrInvalidXHTML), errors.Is(err, ErrBadPaging), + errors.Is(err, ErrProductNotFound), + errors.Is(err, ErrAlreadyInFavorites), + errors.Is(err, ErrNotInFavorites), errors.Is(err, ErrNoRootFound), errors.Is(err, ErrCircularDependency), errors.Is(err, ErrStartCategoryNotFound), @@ -215,7 +268,16 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrUserHasNoSuchCart), errors.Is(err, ErrProductOrItsVariationDoesNotExist), - errors.Is(err, ErrJSONBody): + errors.Is(err, ErrAccessDenied), + errors.Is(err, ErrFolderDoesNotExist), + errors.Is(err, ErrFileDoesNotExist), + errors.Is(err, ErrNameTaken), + errors.Is(err, ErrMissingFileFieldDocument), + errors.Is(err, ErrJSONBody), + errors.Is(err, ErrMaxAmtOfAddressesReached), + errors.Is(err, ErrUserHasNoSuchAddress), + errors.Is(err, ErrInvalidCountryID), + errors.Is(err, ErrInvalidAddressJSON): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict diff --git a/bo/components.d.ts b/bo/components.d.ts index c9bf5aa..4a4c840 100644 --- a/bo/components.d.ts +++ b/bo/components.d.ts @@ -12,10 +12,7 @@ export {} declare module 'vue' { export interface GlobalComponents { CartDetails: typeof import('./src/components/customer/CartDetails.vue')['default'] - CategoryMenu: typeof import('./src/components/inner/CategoryMenu.vue')['default'] - CategoryMenuListing: typeof import('./src/components/inner/categoryMenuListing.vue')['default'] - copy: typeof import('./src/components/admin/ProductDetailView copy.vue')['default'] - CountryCurrencySwitch: typeof import('./src/components/inner/CountryCurrencySwitch.vue')['default'] + CategoryMenu: typeof import('./src/components/inner/categoryMenu.vue')['default'] Cs_PrivacyPolicyView: typeof import('./src/components/terms/cs_PrivacyPolicyView.vue')['default'] Cs_TermsAndConditionsView: typeof import('./src/components/terms/cs_TermsAndConditionsView.vue')['default'] En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default'] diff --git a/bruno/api_v1/currency/currency-rate.yml b/bruno/api_v1/currency/currency-rate.yml index d5e5bac..b741b82 100644 --- a/bruno/api_v1/currency/currency-rate.yml +++ b/bruno/api_v1/currency/currency-rate.yml @@ -6,6 +6,13 @@ info: http: method: POST url: "{{bas_url}}/restricted/currency-rate" + body: + type: json + data: |- + { + "b2b_id_currency" : 1, + "conversion_rate": 4.2 + } auth: inherit settings: diff --git a/bruno/api_v1/customer/Customer (me).yml b/bruno/api_v1/customer/Customer (me).yml index 253bead..891919e 100644 --- a/bruno/api_v1/customer/Customer (me).yml +++ b/bruno/api_v1/customer/Customer (me).yml @@ -5,11 +5,7 @@ info: http: method: GET - url: "{{bas_url}}/restricted/customer?id=1" - params: - - name: id - value: "1" - type: query + url: "{{bas_url}}/restricted/customer" auth: inherit settings: diff --git a/bruno/api_v1/customer/Customer list.yml b/bruno/api_v1/customer/Customer list.yml new file mode 100644 index 0000000..11c286b --- /dev/null +++ b/bruno/api_v1/customer/Customer list.yml @@ -0,0 +1,19 @@ +info: + name: Customer list + type: http + seq: 3 + +http: + method: GET + url: "{{bas_url}}/restricted/customer/list?search=" + params: + - name: search + value: "" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/product/Add To Favorites.yml b/bruno/api_v1/product/Add To Favorites.yml new file mode 100644 index 0000000..29a660d --- /dev/null +++ b/bruno/api_v1/product/Add To Favorites.yml @@ -0,0 +1,15 @@ +info: + name: Add To Favorites + type: http + seq: 3 + +http: + method: POST + url: "{{bas_url}}/restricted/product/favorite/53" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/product/Products List.yml b/bruno/api_v1/product/Products List.yml index cc07f08..6763495 100644 --- a/bruno/api_v1/product/Products List.yml +++ b/bruno/api_v1/product/Products List.yml @@ -5,7 +5,7 @@ info: http: method: GET - url: "{{bas_url}}/restricted/list/list-products?p=1&elems=30&sort=product_id,asc&category_id_in=243&reference=~62" + url: "{{bas_url}}/restricted/product/list?p=1&elems=30&sort=product_id,asc&category_id_in=243&reference=~62" params: - name: p value: "1" @@ -25,9 +25,6 @@ http: body: type: json data: "" - auth: - type: bearer - token: "{{token}}" settings: encodeUrl: true diff --git a/bruno/api_v1/product/Remove Form Favorites.yml b/bruno/api_v1/product/Remove Form Favorites.yml new file mode 100644 index 0000000..2b388c2 --- /dev/null +++ b/bruno/api_v1/product/Remove Form Favorites.yml @@ -0,0 +1,15 @@ +info: + name: Remove Form Favorites + type: http + seq: 4 + +http: + method: DELETE + url: "{{bas_url}}/restricted/product/favorite/51" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/save-product-description.yml b/bruno/b2b-daniel/save-product-description.yml new file mode 100644 index 0000000..e843995 --- /dev/null +++ b/bruno/b2b-daniel/save-product-description.yml @@ -0,0 +1,39 @@ +info: + name: save-product-description + type: http + seq: 19 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/product-translation/save-product-description?productID=1&productLangID=1 + params: + - name: productID + value: "1" + type: query + - name: productLangID + value: "1" + type: query + body: + type: json + data: |- + { + "description": "

Zastosowanie wałków rehabilitacyjnych w różnego rodzaju ćwiczeniach oraz zabiegach wpływa pozytywnie na łagodzenie urazów oraz zwiększa szanse na powrót pacjenta do pełnej sprawności fizycznej. Stosowane są w rehabilitacji ruchowej, podczas gimnastyki korekcyjnej, masaży tradycyjnych i sportowych, gdyż idealnie nadają się do unoszenia i separacji kończyn. Można je wykorzystać także do podpierania kolan, stóp, ramion, a także barków pacjenta. Wałki rehabilitacyjne polecane są także dla dzieci, wykorzystanie ich podczas zabawy znacznie wspiera rozwój dużej motoryki.

\n

Dzięki szerokiej ofercie kolorystycznej oraz zróżnicowanym rozmiarom, możliwe jest skomponowanie zestawu do ćwiczeń niezbędnego w każdym gabinecie fizjoterapeutycznym, gabinecie masażu czy też szkole i przedszkolu. 

\n

Wałek rehabilitacyjny  jest wyrobem medycznym zgodnie z wymaganiami zasadniczymi dla wyrobów medycznych i w rozumieniu ustawy o wyrobach medycznych, zgłoszonym do Rejestru Wyrobów Medycznych prowadzonego przez Urząd Rejestracji Produktów Leczniczych, Wyrobów Medycznych i Produktów Biobójczych, wyposażonym w deklarację zgodności producenta i opatrzonym znakiem CE.

\n

\n

\"Wyrób

\n

Polecane zastosowanie:

\n\n

\n

Specyfikacja materiału:

\n

Pokrowiec: materiał z powłoką PCV przeznaczony dla wyrobów medycznych, dzięki czemu jest bardzo łatwy w czyszczeniu oraz dezynfekcji:

\n\n

\"REACH\"\"Certyfikat\"Nie\"Ognioodporny\"\"Odporny\"Odporny\"Przeznaczony\"Odporny\"Olejoodporny\"

\n

Wypełnienie: średnio twarda pianka poliuretanowa o podwyższonej odporności na odkształcenia:

\n\n

\"Certyfikat\"Atest\"Atest

\n

\n

", + "description_short": "

Wałki rehabilitacyjne znajdują swoje zastosowanie w różnego rodzaju ćwiczeniach. Stosowane są w rehabilitacji ruchowej, podczas gimnastyki korekcyjnej, masaży tradycyjnych i sportowych, gdyż idealnie nadają się do unoszenia i separacji kończyn. Można je wykorzystać także do podpierania kolan, stóp, ramion, a także barków pacjenta. Wałki rehabilitacyjne polecane są także dla dzieci, wykorzystanie ich podczas zabawy, znacznie wspiera rozwój dużej motoryki. Produkt posiada certyfikację jako wyrób medyczny. 

", + "link_rewrite": " Wałek-Rehabilitacyjny-10x30-cm ", + "meta_description": "", + "meta_keywords": "", + "meta_title": "", + "name": "Wałek rehabilitacyjny 10 x 30 cm", + "available_now": "dostępny", + "available_later": "na zamówienie", + "delivery_in_stock": "Czas realizacji 3-7 dni roboczych", + "delivery_out_stock": "Czas realizacji 3-7 dni roboczych", + "usage": "

I. Czyszczenie i konserwacja

\r\n

Tapicerkę należy czyścić powierzchniowo stosując dozwolone środki:

\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n

Rodzaj zabrudzenia

\r\n
\r\n

Dozwolone środki

\r\n
\r\n

Postępowanie

\r\n
\r\n

Codzienne zabrudzenia

\r\n

 

\r\n
\r\n

Łagodny detergent najlepiej roztwór szarego mydła

\r\n
\r\n

Czyścić regularnie z użyciem gąbki lub miękkiej szczotki. Na koniec przetrzeć czyszczone miejsce wilgotną szmatką po czym wytrzeć do sucha (w celu usunięcia pozostałości detergentu).

\r\n
\r\n

Miejscowe, silniejsze zabrudzenia

\r\n
\r\n

25% roztwór alkoholu etylowego

\r\n
\r\n

Delikatnie przecierać nasączonym tamponem z gazy. Na koniec przetrzeć czyszczone miejsce wilgotną szmatką po czym wytrzeć do sucha (w celu usunięcia pozostałości detergentu).

\r\n
\r\n

Dezynfekcja

\r\n
\r\n

Ogólnodostępne środki do dezynfekcji zawierające:

\r\n

- aktywny chlor – dichloroizocyjanuran sodu, max stężenie 10000 ppm 

\r\n

- aktywny chlor - dwutlenek chloru w roztworze do 20 000 ppm 

\r\n

- alkohol izopropylowy max stężenie 70 % 

\r\n

\r\n
\r\n

Dezynfekować zgodnie z zaleceniami producenta używanego środka.

\r\n
\r\n

Przed użyciem środka innego niż łagodny detergent trzeba sprawdzić efekt w niewidocznym miejscu, a samo czyszczenie wykonać bardzo ostrożnie.

\r\n
\r\n


II. Informacje

\r\n

\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\"\"\r\n

Szamponować przy użyciu gąbki

\r\n
\r\n

 \"\"

\r\n
\r\n

Nie prać!!! (delikatne wyroby)   

\r\n
\r\n

\r\n
\r\n

Nie chlorować!!! (nie stosować do bielenia związków wydzielających wolny chlor)

\r\n
\r\n

 \"\"

\r\n
\r\n

Nie prasować!!! (nie dopuszczać do kontaktu z nagrzanymi powierzchniami np. kaloryfer)

\r\n
\r\n

 \"\"

\r\n
\r\n

Nie czyścić chemicznie!!!

\r\n
\r\n

\r\n

III. Warunki gwarancji

\r\n

Gwarancji nie podlegają:

\r\n" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/translate-product-description.yml b/bruno/b2b-daniel/translate-product-description.yml new file mode 100644 index 0000000..5f3a787 --- /dev/null +++ b/bruno/b2b-daniel/translate-product-description.yml @@ -0,0 +1,28 @@ +info: + name: translate-product-description + type: http + seq: 21 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/product-translation/translate-product-description?productID=51&productFromLangID=1&productToLangID=3&model=Google + params: + - name: productID + value: "51" + type: query + - name: productFromLangID + value: "1" + type: query + - name: productToLangID + value: "3" + type: query + - name: model + value: Google + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/.gitignore b/bruno/b2b_daniel/.gitignore similarity index 100% rename from bruno/b2b-daniel/.gitignore rename to bruno/b2b_daniel/.gitignore diff --git a/bruno/b2b_daniel/addresses/add-new-address.yml b/bruno/b2b_daniel/addresses/add-new-address.yml new file mode 100644 index 0000000..9c1abc1 --- /dev/null +++ b/bruno/b2b_daniel/addresses/add-new-address.yml @@ -0,0 +1,31 @@ +info: + name: add-new-address + type: http + seq: 1 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/addresses/add-new-address?country_id=1 + params: + - name: country_id + value: "1" + type: query + body: + type: json + data: |- + { + "postal_code": "31-154", + "city": "Kraków", + "voivodeship": "małopolskie", + "street": "Długa", + "building_no": "5", + "apartment_no": "7", + "recipient": "Jan Kowalski" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/delete-address.yml b/bruno/b2b_daniel/addresses/delete-address.yml new file mode 100644 index 0000000..dc9d33c --- /dev/null +++ b/bruno/b2b_daniel/addresses/delete-address.yml @@ -0,0 +1,19 @@ +info: + name: delete-address + type: http + seq: 4 + +http: + method: DELETE + url: http://localhost:3000/api/v1/restricted/addresses/delete-address?address_id=1 + params: + - name: address_id + value: "1" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/folder.yml b/bruno/b2b_daniel/addresses/folder.yml new file mode 100644 index 0000000..aaa37e8 --- /dev/null +++ b/bruno/b2b_daniel/addresses/folder.yml @@ -0,0 +1,7 @@ +info: + name: addresses + type: folder + seq: 10 + +request: + auth: inherit diff --git a/bruno/b2b_daniel/addresses/get-template.yml b/bruno/b2b_daniel/addresses/get-template.yml new file mode 100644 index 0000000..4105fb8 --- /dev/null +++ b/bruno/b2b_daniel/addresses/get-template.yml @@ -0,0 +1,19 @@ +info: + name: get-template + type: http + seq: 5 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/addresses/get-template?country_id=3 + params: + - name: country_id + value: "3" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/modify-address.yml b/bruno/b2b_daniel/addresses/modify-address.yml new file mode 100644 index 0000000..aadd02d --- /dev/null +++ b/bruno/b2b_daniel/addresses/modify-address.yml @@ -0,0 +1,33 @@ +info: + name: modify-address + type: http + seq: 2 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/addresses/modify-address?country_id=1&address_id=1 + params: + - name: country_id + value: "1" + type: query + - name: address_id + value: "1" + type: query + body: + type: json + data: |- + { + "postal_code": "31-154", + "city": "Kraków", + "voivodeship": "śląskie", + "street": "Długa", + "building_no": "5", + "recipient": "Adam Adamowicz" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/retrieve-addresses.yml b/bruno/b2b_daniel/addresses/retrieve-addresses.yml new file mode 100644 index 0000000..e490024 --- /dev/null +++ b/bruno/b2b_daniel/addresses/retrieve-addresses.yml @@ -0,0 +1,15 @@ +info: + name: retrieve-addresses + type: http + seq: 3 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/addresses/retrieve-addresses + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/auth/folder.yml b/bruno/b2b_daniel/auth/folder.yml new file mode 100644 index 0000000..120ac3e --- /dev/null +++ b/bruno/b2b_daniel/auth/folder.yml @@ -0,0 +1,7 @@ +info: + name: auth + type: folder + seq: 1 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/update-choice.yml b/bruno/b2b_daniel/auth/update-choice.yml similarity index 97% rename from bruno/b2b-daniel/update-choice.yml rename to bruno/b2b_daniel/auth/update-choice.yml index 53a469b..0a511b0 100644 --- a/bruno/b2b-daniel/update-choice.yml +++ b/bruno/b2b_daniel/auth/update-choice.yml @@ -1,7 +1,7 @@ info: name: update-choice type: http - seq: 3 + seq: 1 http: method: POST diff --git a/bruno/b2b-daniel/add-new-cart.yml b/bruno/b2b_daniel/carts/add-new-cart.yml similarity index 95% rename from bruno/b2b-daniel/add-new-cart.yml rename to bruno/b2b_daniel/carts/add-new-cart.yml index a6beb62..20199cf 100644 --- a/bruno/b2b-daniel/add-new-cart.yml +++ b/bruno/b2b_daniel/carts/add-new-cart.yml @@ -1,7 +1,7 @@ info: name: add-new-cart type: http - seq: 11 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/add-product-to-cart (1).yml b/bruno/b2b_daniel/carts/add-product-to-cart (1).yml similarity index 97% rename from bruno/b2b-daniel/add-product-to-cart (1).yml rename to bruno/b2b_daniel/carts/add-product-to-cart (1).yml index 7441656..eb7a5a1 100644 --- a/bruno/b2b-daniel/add-product-to-cart (1).yml +++ b/bruno/b2b_daniel/carts/add-product-to-cart (1).yml @@ -1,7 +1,7 @@ info: name: add-product-to-cart (1) type: http - seq: 16 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/add-product-to-cart.yml b/bruno/b2b_daniel/carts/add-product-to-cart.yml similarity index 98% rename from bruno/b2b-daniel/add-product-to-cart.yml rename to bruno/b2b_daniel/carts/add-product-to-cart.yml index 95e978b..ff780c1 100644 --- a/bruno/b2b-daniel/add-product-to-cart.yml +++ b/bruno/b2b_daniel/carts/add-product-to-cart.yml @@ -1,7 +1,7 @@ info: name: add-product-to-cart type: http - seq: 15 + seq: 14 http: method: GET diff --git a/bruno/b2b-daniel/change-cart-name.yml b/bruno/b2b_daniel/carts/change-cart-name.yml similarity index 97% rename from bruno/b2b-daniel/change-cart-name.yml rename to bruno/b2b_daniel/carts/change-cart-name.yml index 5dd32ee..08838dc 100644 --- a/bruno/b2b-daniel/change-cart-name.yml +++ b/bruno/b2b_daniel/carts/change-cart-name.yml @@ -1,7 +1,7 @@ info: name: change-cart-name type: http - seq: 12 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/carts/folder.yml b/bruno/b2b_daniel/carts/folder.yml new file mode 100644 index 0000000..4f51dfd --- /dev/null +++ b/bruno/b2b_daniel/carts/folder.yml @@ -0,0 +1,7 @@ +info: + name: carts + type: folder + seq: 7 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/retrieve-cart.yml b/bruno/b2b_daniel/carts/retrieve-cart.yml similarity index 96% rename from bruno/b2b-daniel/retrieve-cart.yml rename to bruno/b2b_daniel/carts/retrieve-cart.yml index 114116c..69c5e2e 100644 --- a/bruno/b2b-daniel/retrieve-cart.yml +++ b/bruno/b2b_daniel/carts/retrieve-cart.yml @@ -1,7 +1,7 @@ info: name: retrieve-cart type: http - seq: 14 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/retrieve-carts-info.yml b/bruno/b2b_daniel/carts/retrieve-carts-info.yml similarity index 96% rename from bruno/b2b-daniel/retrieve-carts-info.yml rename to bruno/b2b_daniel/carts/retrieve-carts-info.yml index f15ce51..479be4e 100644 --- a/bruno/b2b-daniel/retrieve-carts-info.yml +++ b/bruno/b2b_daniel/carts/retrieve-carts-info.yml @@ -1,7 +1,7 @@ info: name: retrieve-carts-info type: http - seq: 13 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/langs-and-countries/folder.yml b/bruno/b2b_daniel/langs-and-countries/folder.yml new file mode 100644 index 0000000..b895323 --- /dev/null +++ b/bruno/b2b_daniel/langs-and-countries/folder.yml @@ -0,0 +1,7 @@ +info: + name: langs-and-countries + type: folder + seq: 4 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get_countries.yml b/bruno/b2b_daniel/langs-and-countries/get_countries.yml similarity index 96% rename from bruno/b2b-daniel/get_countries.yml rename to bruno/b2b_daniel/langs-and-countries/get_countries.yml index e7077fd..b7204b4 100644 --- a/bruno/b2b-daniel/get_countries.yml +++ b/bruno/b2b_daniel/langs-and-countries/get_countries.yml @@ -1,7 +1,7 @@ info: name: get_countries type: http - seq: 4 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/list/folder.yml b/bruno/b2b_daniel/list/folder.yml new file mode 100644 index 0000000..52fa517 --- /dev/null +++ b/bruno/b2b_daniel/list/folder.yml @@ -0,0 +1,7 @@ +info: + name: list + type: folder + seq: 3 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/list-products.yml b/bruno/b2b_daniel/list/list-products.yml similarity index 77% rename from bruno/b2b-daniel/list-products.yml rename to bruno/b2b_daniel/list/list-products.yml index adc88a7..20e6cac 100644 --- a/bruno/b2b-daniel/list-products.yml +++ b/bruno/b2b_daniel/list/list-products.yml @@ -5,7 +5,7 @@ info: http: method: GET - url: http://localhost:3000/api/v1/restricted/list/list-products?p=1&elems=10 + url: http://localhost:3000/api/v1/restricted/list/list-products?p=1&elems=10&target_user_id=2 params: - name: p value: "1" @@ -13,6 +13,9 @@ http: - name: elems value: "10" type: query + - name: target_user_id + value: "2" + type: query settings: encodeUrl: true diff --git a/bruno/b2b-daniel/list-users.yml b/bruno/b2b_daniel/list/list-users.yml similarity index 97% rename from bruno/b2b-daniel/list-users.yml rename to bruno/b2b_daniel/list/list-users.yml index 288afbc..85d70fa 100644 --- a/bruno/b2b-daniel/list-users.yml +++ b/bruno/b2b_daniel/list/list-users.yml @@ -1,7 +1,7 @@ info: name: list-users type: http - seq: 2 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/menu/folder.yml b/bruno/b2b_daniel/menu/folder.yml new file mode 100644 index 0000000..32bc162 --- /dev/null +++ b/bruno/b2b_daniel/menu/folder.yml @@ -0,0 +1,7 @@ +info: + name: menu + type: folder + seq: 5 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get-breadcrumb.yml b/bruno/b2b_daniel/menu/get-breadcrumb.yml similarity index 97% rename from bruno/b2b-daniel/get-breadcrumb.yml rename to bruno/b2b_daniel/menu/get-breadcrumb.yml index 8b10c00..a805790 100644 --- a/bruno/b2b-daniel/get-breadcrumb.yml +++ b/bruno/b2b_daniel/menu/get-breadcrumb.yml @@ -1,7 +1,7 @@ info: name: get-breadcrumb type: http - seq: 18 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/get-category-tree.yml b/bruno/b2b_daniel/menu/get-category-tree.yml similarity index 97% rename from bruno/b2b-daniel/get-category-tree.yml rename to bruno/b2b_daniel/menu/get-category-tree.yml index c6b436e..6e9d875 100644 --- a/bruno/b2b-daniel/get-category-tree.yml +++ b/bruno/b2b_daniel/menu/get-category-tree.yml @@ -1,7 +1,7 @@ info: name: get-category-tree type: http - seq: 5 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/opencollection.yml b/bruno/b2b_daniel/opencollection.yml similarity index 100% rename from bruno/b2b-daniel/opencollection.yml rename to bruno/b2b_daniel/opencollection.yml diff --git a/bruno/b2b_daniel/product-translation/folder.yml b/bruno/b2b_daniel/product-translation/folder.yml new file mode 100644 index 0000000..cda7116 --- /dev/null +++ b/bruno/b2b_daniel/product-translation/folder.yml @@ -0,0 +1,7 @@ +info: + name: product-translation + type: folder + seq: 2 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get-product-description.yml b/bruno/b2b_daniel/product-translation/get-product-description.yml similarity index 97% rename from bruno/b2b-daniel/get-product-description.yml rename to bruno/b2b_daniel/product-translation/get-product-description.yml index 63a7447..4b6086d 100644 --- a/bruno/b2b-daniel/get-product-description.yml +++ b/bruno/b2b_daniel/product-translation/get-product-description.yml @@ -1,7 +1,7 @@ info: name: get-product-description type: http - seq: 17 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/product-translation/save-product-description.yml b/bruno/b2b_daniel/product-translation/save-product-description.yml new file mode 100644 index 0000000..201f4f8 --- /dev/null +++ b/bruno/b2b_daniel/product-translation/save-product-description.yml @@ -0,0 +1,28 @@ +info: + name: save-product-description + type: http + seq: 25 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/product-translation/save-product-description?productID=1&productLangID=2 + params: + - name: productID + value: "1" + type: query + - name: productLangID + value: "2" + type: query + body: + type: json + data: |- + { + "description": "

Rehabilitation rollers are essential, multi-functional tools used across various exercises and treatments. Their application positively influences the alleviation of injuries and significantly enhances a patient's chances of returning to full physical fitness.

\n

These versatile medical rollers are a staple in movement rehabilitation, corrective gymnastics, and both traditional and sports massages, as their design is perfect for precise limb elevation and separation. They provide critical support, making them ideal for comfortably cushioning the patient's knees, feet, arms, and shoulders.

\n

Support for Children's Development: Rehabilitation bolsters are also highly recommended for children. Incorporating them into play activities offers an engaging way to substantially support the development of gross motor skills.

\n

Customize Your Therapy Space: Thanks to our wide range of colours and various sizes, you can easily assemble a comprehensive exercise kit necessary for every physiotherapy clinic, massage parlour, school, or kindergarten.

\n

\n

Certified Medical Device:

\n

\n

This Rehabilitation Roller is a certified medical device compliant with the essential requirements for medical products and the Medical Devices Act. It has been officially registered with the Office for Registration of Medicinal Products, Medical Devices and Biocidal Products (URPL), is equipped with the manufacturer's Declaration of Conformity, and bears the CE mark.

\n

\"\"

\n
\n

\n

Recommended use:

\n\n
\n

\n

Material Specification & Certification

\n

\n

Cover Material: PVC-coated material specifically designated for medical devices, making it extremely easy to clean and disinfect:

\n\n

\"REACH\"\"Certyfikat\"Nie\"Ognioodporny\"\"Odporny\"Odporny\"Przeznaczony\"Odporny\"Olejoodporny\"

\n

Filling: Medium-firm polyurethane foam with enhanced resistance to deformation.

\n\n

\"Certyfikat\"Atest\"Atest

" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/product-translation/translate-product-description.yml b/bruno/b2b_daniel/product-translation/translate-product-description.yml new file mode 100644 index 0000000..12c65b4 --- /dev/null +++ b/bruno/b2b_daniel/product-translation/translate-product-description.yml @@ -0,0 +1,28 @@ +info: + name: translate-product-description + type: http + seq: 24 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/product-translation/translate-product-description?productID=51&productFromLangID=2&productToLangID=3&model=Google + params: + - name: productID + value: "51" + type: query + - name: productFromLangID + value: "2" + type: query + - name: productToLangID + value: "3" + type: query + - name: model + value: Google + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/create-index.yml b/bruno/b2b_daniel/search/create-index.yml similarity index 66% rename from bruno/b2b-daniel/create-index.yml rename to bruno/b2b_daniel/search/create-index.yml index 79eb62e..1469dc4 100644 --- a/bruno/b2b-daniel/create-index.yml +++ b/bruno/b2b_daniel/search/create-index.yml @@ -1,11 +1,11 @@ info: name: create-index type: http - seq: 7 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/meili-search/create-index + url: http://localhost:3000/api/v1/restricted/search/create-index auth: inherit settings: diff --git a/bruno/b2b_daniel/search/folder.yml b/bruno/b2b_daniel/search/folder.yml new file mode 100644 index 0000000..7b92aae --- /dev/null +++ b/bruno/b2b_daniel/search/folder.yml @@ -0,0 +1,7 @@ +info: + name: search + type: folder + seq: 6 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get-indexes.yml b/bruno/b2b_daniel/search/get-indexes.yml similarity index 96% rename from bruno/b2b-daniel/get-indexes.yml rename to bruno/b2b_daniel/search/get-indexes.yml index 850f7bc..0b85acf 100644 --- a/bruno/b2b-daniel/get-indexes.yml +++ b/bruno/b2b_daniel/search/get-indexes.yml @@ -1,7 +1,7 @@ info: name: get-indexes type: http - seq: 9 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/remove-index.yml b/bruno/b2b_daniel/search/remove-index.yml similarity index 97% rename from bruno/b2b-daniel/remove-index.yml rename to bruno/b2b_daniel/search/remove-index.yml index aecc977..c1c8856 100644 --- a/bruno/b2b-daniel/remove-index.yml +++ b/bruno/b2b_daniel/search/remove-index.yml @@ -1,7 +1,7 @@ info: name: remove-index type: http - seq: 8 + seq: 1 http: method: DELETE diff --git a/bruno/b2b-daniel/search.yml b/bruno/b2b_daniel/search/search.yml similarity index 75% rename from bruno/b2b-daniel/search.yml rename to bruno/b2b_daniel/search/search.yml index 39d3f04..5200d85 100644 --- a/bruno/b2b-daniel/search.yml +++ b/bruno/b2b_daniel/search/search.yml @@ -1,11 +1,11 @@ info: name: search type: http - seq: 10 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/meili-search/search?query=w&limit=4&id_category=0&price_lower_bound=60.0&price_upper_bound=70.0 + url: http://localhost:3000/api/v1/restricted/search/search?query=w&limit=4&id_category=0&price_lower_bound=60.0&price_upper_bound=70.0 params: - name: query value: w diff --git a/bruno/b2b-daniel/test.yml b/bruno/b2b_daniel/search/test.yml similarity index 67% rename from bruno/b2b-daniel/test.yml rename to bruno/b2b_daniel/search/test.yml index e63fe60..60fe55a 100644 --- a/bruno/b2b-daniel/test.yml +++ b/bruno/b2b_daniel/search/test.yml @@ -1,11 +1,11 @@ info: name: test type: http - seq: 6 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/meili-search/test + url: http://localhost:3000/api/v1/restricted/search/test auth: inherit settings: diff --git a/bruno/b2b_daniel/storage-old/copy.yml b/bruno/b2b_daniel/storage-old/copy.yml new file mode 100644 index 0000000..8161fc0 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/copy.yml @@ -0,0 +1,19 @@ +info: + name: copy + type: http + seq: 7 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/copy/folder1/test.txt?dest_path=/folder/a.txt + params: + - name: dest_path + value: /folder/a.txt + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/create-folder.yml b/bruno/b2b_daniel/storage-old/create-folder.yml new file mode 100644 index 0000000..1250965 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/create-folder.yml @@ -0,0 +1,19 @@ +info: + name: create-folder + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/create-folder?name=folder + params: + - name: name + value: folder + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/delete-file.yml b/bruno/b2b_daniel/storage-old/delete-file.yml new file mode 100644 index 0000000..01b1744 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/delete-file.yml @@ -0,0 +1,15 @@ +info: + name: delete-file + type: http + seq: 1 + +http: + method: DELETE + url: http://localhost:3000/api/v1/restricted/storage/delete-file/folder1/TODO.txt + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/delete-folder.yml b/bruno/b2b_daniel/storage-old/delete-folder.yml new file mode 100644 index 0000000..3c578ce --- /dev/null +++ b/bruno/b2b_daniel/storage-old/delete-folder.yml @@ -0,0 +1,15 @@ +info: + name: delete-folder + type: http + seq: 1 + +http: + method: DELETE + url: http://localhost:3000/api/v1/restricted/storage/delete-folder/folder/ + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/download-file.yml b/bruno/b2b_daniel/storage-old/download-file.yml new file mode 100644 index 0000000..d6c65a1 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/download-file.yml @@ -0,0 +1,15 @@ +info: + name: download-file + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/download-file/folder1/test.xlsx + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/folder.yml b/bruno/b2b_daniel/storage-old/folder.yml new file mode 100644 index 0000000..852efec --- /dev/null +++ b/bruno/b2b_daniel/storage-old/folder.yml @@ -0,0 +1,7 @@ +info: + name: storage-old + type: folder + seq: 1 + +request: + auth: inherit diff --git a/bruno/b2b_daniel/storage-old/list-content.yml b/bruno/b2b_daniel/storage-old/list-content.yml new file mode 100644 index 0000000..ed67b6d --- /dev/null +++ b/bruno/b2b_daniel/storage-old/list-content.yml @@ -0,0 +1,15 @@ +info: + name: list-content + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/list-content/folder1 + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/move.yml b/bruno/b2b_daniel/storage-old/move.yml new file mode 100644 index 0000000..7fb51e5 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/move.yml @@ -0,0 +1,19 @@ +info: + name: move + type: http + seq: 8 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/move/folder?dest_path=/folder1/test.txt + params: + - name: dest_path + value: /folder1/test.txt + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/upload-file.yml b/bruno/b2b_daniel/storage-old/upload-file.yml new file mode 100644 index 0000000..aa8d740 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/upload-file.yml @@ -0,0 +1,22 @@ +info: + name: upload-file + type: http + seq: 1 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/storage/upload-file/folder1/ + body: + type: multipart-form + data: + - name: document + type: file + value: + - /home/daniel/TODO.txt + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-restricted/create-new-webdav-token.yml b/bruno/b2b_daniel/storage-restricted/create-new-webdav-token.yml new file mode 100644 index 0000000..4340fda --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/create-new-webdav-token.yml @@ -0,0 +1,15 @@ +info: + name: create-new-webdav-token + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/create-new-webdav-token + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-restricted/download-file.yml b/bruno/b2b_daniel/storage-restricted/download-file.yml new file mode 100644 index 0000000..13553de --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/download-file.yml @@ -0,0 +1,15 @@ +info: + name: download-file + type: http + seq: 3 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/download-file/dest/src/cccc.txt + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-restricted/folder.yml b/bruno/b2b_daniel/storage-restricted/folder.yml new file mode 100644 index 0000000..ec9eca7 --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/folder.yml @@ -0,0 +1,7 @@ +info: + name: storage-restricted + type: folder + seq: 9 + +request: + auth: inherit diff --git a/bruno/b2b_daniel/storage-restricted/list-content.yml b/bruno/b2b_daniel/storage-restricted/list-content.yml new file mode 100644 index 0000000..7c94250 --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/list-content.yml @@ -0,0 +1,15 @@ +info: + name: list-content + type: http + seq: 2 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/list-content/dest/src + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/go.mod b/go.mod index 62c8aad..6141322 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/text v0.35.0 gopkg.in/warnings.v0 v0.1.2 // indirect gorm.io/driver/mysql v1.6.0 ) diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index b88f14e..ba4469a 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -104,6 +104,8 @@ CREATE TABLE IF NOT EXISTS b2b_customers ( email_verification_expires DATETIME(6) NULL, password_reset_token VARCHAR(255) NULL, password_reset_expires DATETIME(6) NULL, + webdav_token VARCHAR(255) NULL, + webdav_expires DATETIME(6) NULL, last_password_reset_request DATETIME(6) NULL, last_login_at DATETIME(6) NULL, lang_id INT NULL DEFAULT 2, @@ -119,6 +121,9 @@ ON b2b_customers (email); CREATE INDEX IF NOT EXISTS idx_customers_deleted_at ON b2b_customers (deleted_at); +CREATE INDEX IF NOT EXISTS idx_customers_webdav_token +ON b2b_customers (webdav_token); + ALTER TABLE b2b_customers ADD CONSTRAINT fk_customer_role FOREIGN KEY (role_id) REFERENCES b2b_roles(id); @@ -146,6 +151,16 @@ CREATE TABLE IF NOT EXISTS b2b_carts_products ( CREATE INDEX IF NOT EXISTS idx_carts_products_cart_id ON b2b_carts_products (cart_id); +-- favorites +CREATE TABLE IF NOT EXISTS b2b_favorites ( + user_id BIGINT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + PRIMARY KEY (user_id, product_id), + CONSTRAINT fk_favorites_customer FOREIGN KEY (user_id) REFERENCES b2b_customers(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_favorites_product FOREIGN KEY (product_id) REFERENCES ps_product(id_product) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + + -- refresh_tokens CREATE TABLE IF NOT EXISTS b2b_refresh_tokens ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, @@ -205,6 +220,18 @@ ON `b2b_countries` ( `ps_id_country` ASC ); +-- addresses +CREATE TABLE IF NOT EXISTS b2b_addresses ( + id BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, + b2b_customer_id BIGINT UNSIGNED NOT NULL, + address_info TEXT NOT NULL, + b2b_country_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_b2b_addresses_b2b_customers FOREIGN KEY (b2b_customer_id) REFERENCES b2b_customers (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_b2b_addresses_b2b_countries FOREIGN KEY (b2b_country_id) REFERENCES b2b_countries (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB; + + CREATE TABLE b2b_specific_price ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, diff --git a/i18n/migrations/20260302163123_create_tables_data.sql b/i18n/migrations/20260302163123_create_tables_data.sql index 620fbb2..c3bc103 100644 --- a/i18n/migrations/20260302163123_create_tables_data.sql +++ b/i18n/migrations/20260302163123_create_tables_data.sql @@ -29,4 +29,17 @@ VALUES (3, '🇨🇿', 16, 2), (4, '🇩🇪', 1, 2); +INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('1', 'user.read.any'); +INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('2', 'user.write.any'); +INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('3', 'user.delete.any'); +INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('4', 'currency.write'); + +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '1'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '2'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '3'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '4'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '1'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '2'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '3'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '4'); -- +goose Down \ No newline at end of file diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql index 8f7d5ab..743ca43 100644 --- a/i18n/migrations/20260319163200_procedures.sql +++ b/i18n/migrations/20260319163200_procedures.sql @@ -132,6 +132,12 @@ JSON_OBJECT( m.name, 'category', cl.name, + /* ================= FAVORITE ================= */ + 'is_favorite', + EXISTS( + SELECT 1 FROM b2b_favorites f + WHERE f.user_id = p_id_customer AND f.product_id = p_id_product + ), /* ================= IMAGE ================= */ 'cover_image', JSON_OBJECT( diff --git a/repository/currencyRepo/currencyRepo.go b/repository/currencyRepo/currencyRepo.go deleted file mode 100644 index 97b1b5e..0000000 --- a/repository/currencyRepo/currencyRepo.go +++ /dev/null @@ -1,53 +0,0 @@ -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/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/taskfiles/db.yml b/taskfiles/db.yml index 21ac231..d2b4041 100644 --- a/taskfiles/db.yml +++ b/taskfiles/db.yml @@ -62,5 +62,6 @@ tasks: sed '/-- +goose Down/,$d' i18n/migrations/20260302163123_create_tables_data.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} sed '/-- +goose Down/,$d' i18n/migrations/20260302163152_translations_backoffice.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} sed '/-- +goose Down/,$d' i18n/migrations/20260302163157_translations_backend.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} + sed '/-- +goose Down/,$d' i18n/migrations/20260319163200_procedures.sql | docker compose -p {{.PROJECT}} exec -T {{.LOCAL_DB_SERVICE}} mariadb -u {{.LOCAL_DB_USER}} --password={{.LOCAL_DB_PASSWORD}} {{.LOCAL_DB_NAME}} \ No newline at end of file