diff --git a/cmd/shoppingbasketapp/command/migrate.go b/cmd/shoppingbasketapp/command/migrate.go new file mode 100644 index 00000000..d47dcf0d --- /dev/null +++ b/cmd/shoppingbasketapp/command/migrate.go @@ -0,0 +1 @@ +package command diff --git a/cmd/shoppingbasketapp/command/root.go b/cmd/shoppingbasketapp/command/root.go new file mode 100644 index 00000000..e2634743 --- /dev/null +++ b/cmd/shoppingbasketapp/command/root.go @@ -0,0 +1,52 @@ +package command + +import ( + cfgloader "git.gocasts.ir/ebhomengo/niki/pkg/cfg_loader" + "git.gocasts.ir/ebhomengo/niki/pkg/path" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp" + "github.com/spf13/cobra" + "log" + "os" + "path/filepath" +) + +var RootCmd = &cobra.Command{ + Use: "shoppingbasket_service", + Short: "A CLI for shoppingbasket service", + Long: `shoppingbasket Service CLI is a tool to manage and run +the shoppingbasket service, including migrations and server startup.`, +} + +func loadAppConfig() shoppingbasketapp.Config { + var cfg shoppingbasketapp.Config + + projectRoot, err := path.PathProjectRoot() + if err != nil { + log.Fatalf("error finding project root: %v", err) + } + + yamlPath := os.Getenv("CONFIG_PATH") + + if yamlPath == "" { + defaultConfig := filepath.Join(projectRoot, "deploy", "shoppingbasket", "development", "config.yml") + if _, err := os.Stat(defaultConfig); err == nil { + yamlPath = defaultConfig + } else { + yamlPath = filepath.Join(projectRoot, "deploy", "shoppingbasket", "development", "config.local.yml") + } + } + + options := cfgloader.Option{ + Prefix: "SHOPPINGBASKET_", + Delimiter: ".", + Separator: "__", + YamlFilePath: yamlPath, + CallbackEnv: nil, + } + + if err := cfgloader.Load(options, &cfg); err != nil { + log.Fatalf("Failed to load benefactor config: %v", err) + } + + return cfg +} diff --git a/cmd/shoppingbasketapp/command/serve.go b/cmd/shoppingbasketapp/command/serve.go new file mode 100644 index 00000000..8196c023 --- /dev/null +++ b/cmd/shoppingbasketapp/command/serve.go @@ -0,0 +1,43 @@ +package command + +import ( + "context" + "fmt" + "git.gocasts.ir/ebhomengo/niki/pkg/logger" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp" + "github.com/labstack/gommon/log" + "github.com/spf13/cobra" +) + +var ServeCmd = &cobra.Command{ + Use: "serve", + Short: "Start shoppingbasket service", + Long: `This command starts the main shoppingbasket service.`, + Run: func(cmd *cobra.Command, args []string) { + + }, +} + +func serve() { + var cfg = loadAppConfig() + + logger.Init(cfg.Logger) + l := logger.L() + + l.Info("Starting shoppingbasket service...") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + app, err := shoppingbasketapp.Setup(ctx, cfg) + if err != nil { + l.Error("failed initialize shopping basket app", "error", err) + log.Fatalf(fmt.Sprintf("error starting shopping basket app: %v", err)) + } + + app.Start() +} + +func init() { + RootCmd.AddCommand(ServeCmd) +} diff --git a/cmd/shoppingbasketapp/main.go b/cmd/shoppingbasketapp/main.go new file mode 100644 index 00000000..d204ab4a --- /dev/null +++ b/cmd/shoppingbasketapp/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "git.gocasts.ir/ebhomengo/niki/cmd/shoppingbasketapp/command" + "os" +) + +func main() { + if err := command.RootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/deploy/shoppingbasket/development/.env.example b/deploy/shoppingbasket/development/.env.example new file mode 100644 index 00000000..e69de29b diff --git a/deploy/shoppingbasket/development/Dockerfile b/deploy/shoppingbasket/development/Dockerfile new file mode 100644 index 00000000..e69de29b diff --git a/deploy/shoppingbasket/development/config.local.yml b/deploy/shoppingbasket/development/config.local.yml new file mode 100644 index 00000000..eecfaa99 --- /dev/null +++ b/deploy/shoppingbasket/development/config.local.yml @@ -0,0 +1,25 @@ +redis: + host: "localhost" + port: 6379 + password: "" + db: 0 + +repo: + kart_key_prefix: "shopping-basket-cart:" + ttl: 3600s + +http_server: + host: "localhost" + port: 8080 + shutdown_context_timeout: 10s + cors: + allow_origins: + - "*" + + +logger: + level: "debug" # Can be `debug`, `info`, `warn`, `error` + file_path: "logs/shoppingbasketapp/service.log" + use_local_time: true + file_max_size_in_mb: 10 + file_max_age_in_days: 7 diff --git a/deploy/shoppingbasket/development/docker-compose.yml b/deploy/shoppingbasket/development/docker-compose.yml new file mode 100644 index 00000000..e69de29b diff --git a/logger/logger.go b/logger/logger.go index cded39e4..13108622 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -16,10 +16,10 @@ const ( ) type Config struct { - FilePath string - UseLocalTime bool - FileMaxSizeInMB int - FileMaxAgeInDays int + FilePath string `koanf:"file_path"` + UseLocalTime bool `koanf:"use_local_time"` + FileMaxSizeInMB int `koanf:"file_max_size_in_mb"` + FileMaxAgeInDays int `koanf:"file_max_age_in_days"` } var l *slog.Logger diff --git a/pkg/httpserver/server.go b/pkg/httpserver/server.go index 51abd3fe..22468b59 100644 --- a/pkg/httpserver/server.go +++ b/pkg/httpserver/server.go @@ -3,11 +3,13 @@ package httpserver import ( "context" "fmt" + echomiddleware "github.com/gocasters/rankr/pkg/echo_middleware" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "strings" "sync" + "time" ) @@ -19,7 +21,6 @@ type Config struct { HideBanner bool `koanf:"hide_banner"` HidePort bool `koanf:"hide_port"` PublicPaths []string `koanf:"public_paths"` - // Optional Otel middleware can be injected from outside. OtelMiddleware echo.MiddlewareFunc } diff --git a/shoppingbasketapp/app.go b/shoppingbasketapp/app.go new file mode 100644 index 00000000..2ce4df1e --- /dev/null +++ b/shoppingbasketapp/app.go @@ -0,0 +1,126 @@ +package shoppingbasketapp + +import ( + "context" + "fmt" + "git.gocasts.ir/ebhomengo/niki/adapter/redis" + "git.gocasts.ir/ebhomengo/niki/pkg/httpserver" + "git.gocasts.ir/ebhomengo/niki/pkg/logger" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/delivery/http" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/repository" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/service/cart" + "os" + "os/signal" + "sync" + "syscall" +) + +type Application struct { + Repo repository.Repo + Service cart.Service + Handler http.Handler + Server http.Server + Config Config +} + +func Setup(ctx context.Context, cfg Config) (Application, error) { + + adapter := redis.New(cfg.Redis) + repo := repository.New(adapter.Client(), cfg.Repo) + + validator := cart.NewValidate() + svc := cart.New(validator, repo) + + handler := http.NewHandler(svc) + + httpServer, err := httpserver.New(cfg.HTTPServer) + if err != nil { + logger.L().Error("failed to initialize http server", "error", err) + return Application{}, err + } + server := http.NewServer(handler, httpServer) + + return Application{ + Repo: repo, + Service: svc, + Handler: handler, + Server: server, + Config: cfg, + }, nil +} + +func (app Application) Start() { + var wg sync.WaitGroup + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + startServers(app, &wg) + <-ctx.Done() + + logger.L().Info("Shutdown signal received...") + + shutdownTimeoutCtx, cancel := context.WithTimeout(context.Background(), app.Config.HTTPServer.ShutdownTimeout) + defer cancel() + + if app.shutdownServers(shutdownTimeoutCtx) { + logger.L().Info("Servers shutdown gracefully") + } else { + logger.L().Warn("Shutdown timed out, exiting application") + return + } + + wg.Wait() + logger.L().Info("shopping-basket-app stopped") +} + +func startServers(app Application, wg *sync.WaitGroup) { + wg.Add(1) + go func() { + defer wg.Wait() + logger.L().Info(fmt.Sprintf("HTTP server starting on port: %d", app.Config.HTTPServer.Port)) + if err := app.Server.Serve(); err != nil { + logger.L().Error(fmt.Sprintf("error listen and serve HTTP server on port %d", app.Config.HTTPServer.Port)) + } + + logger.L().Info(fmt.Sprintf("HTTP server stopped on port %d", app.Config.HTTPServer.Port)) + }() +} + +func (app Application) shutdownServers(ctx context.Context) bool { + logger.L().Info("Starting server shutdown process...") + + shutdownDone := make(chan struct{}) + + go func() { + var shutdownWg sync.WaitGroup + shutdownWg.Add(1) + go app.shutdownHTTPServe(ctx, &shutdownWg) + + shutdownWg.Wait() + close(shutdownDone) + logger.L().Info("All servers have been shut down successfully.") + + }() + + select { + case <-shutdownDone: + return true + case <-ctx.Done(): + return false + } +} + +func (app Application) shutdownHTTPServe(parentCtx context.Context, wg *sync.WaitGroup) { + logger.L().Info(fmt.Sprintf("Starting gracefully shutdown for http server on port %d", app.Config.HTTPServer.Port)) + + defer wg.Done() + httpShutdownCtx, httpCancel := context.WithTimeout(parentCtx, app.Config.HTTPServer.ShutdownTimeout) + defer httpCancel() + + if err := app.Server.Stop(httpShutdownCtx); err != nil { + logger.L().Error(fmt.Sprintf("failed http server gracefully shutdown: %v", err)) + } + + logger.L().Info("Successfully http server gracefully shutdown") +} diff --git a/shoppingbasketapp/config.go b/shoppingbasketapp/config.go new file mode 100644 index 00000000..291c301f --- /dev/null +++ b/shoppingbasketapp/config.go @@ -0,0 +1,15 @@ +package shoppingbasketapp + +import ( + "git.gocasts.ir/ebhomengo/niki/adapter/redis" + "git.gocasts.ir/ebhomengo/niki/pkg/httpserver" + logger "git.gocasts.ir/ebhomengo/niki/pkg/logger" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/repository" +) + +type Config struct { + Redis redis.Config `koanf:"redis" json:"redis"` + Repo repository.Config `koanf:"repo" json:"repo"` + HTTPServer httpserver.Config `koanf:"http_server" json:"http_server"` + Logger logger.Config `koanf:"logger" json:"logger"` +} diff --git a/shoppingbasketapp/delivery/http/handler.go b/shoppingbasketapp/delivery/http/handler.go new file mode 100644 index 00000000..f593c076 --- /dev/null +++ b/shoppingbasketapp/delivery/http/handler.go @@ -0,0 +1,117 @@ +package http + +import ( + "git.gocasts.ir/ebhomengo/niki/pkg/claim" + httpmsg "git.gocasts.ir/ebhomengo/niki/pkg/http_msg" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/service/cart" + "git.gocasts.ir/ebhomengo/niki/types" + "github.com/labstack/echo/v4" + "net/http" + "strconv" +) + +type Handler struct { + svc cart.Service +} + +func NewHandler(svc cart.Service) Handler { + return Handler{svc: svc} +} + +func (h Handler) addToBasket(c echo.Context) error { + claims := claim.GetClaimsFromEchoContext(c) + + var req cart.AddToCartRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid request body", + }) + } + + req.UserID = types.ID(claims.UserID) + if err := h.svc.AddToBasket(c.Request().Context(), req); err != nil { + msg, code := httpmsg.Error(err) + return c.JSON(code, msg) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h Handler) getCart(c echo.Context) error { + claims := claim.GetClaimsFromEchoContext(c) + + res, err := h.svc.GetCart(c.Request().Context(), types.ID(claims.UserID)) + if err != nil { + msg, code := httpmsg.Error(err) + return c.JSON(code, msg) + } + + return c.JSON(http.StatusOK, res) +} + +func (h Handler) removeCart(c echo.Context) error { + claims := claim.GetClaimsFromEchoContext(c) + + if err := h.svc.ClearCart(c.Request().Context(), types.ID(claims.UserID)); err != nil { + msg, code := httpmsg.Error(err) + return c.JSON(code, msg) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h Handler) removeItem(c echo.Context) error { + claims := claim.GetClaimsFromEchoContext(c) + p := c.Param("productID") + + pID, err := strconv.Atoi(p) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid product id", + }) + } + + var req cart.RemoveFromCartRequest + + req.UserID = types.ID(claims.UserID) + req.ProductID = types.ID(pID) + + if err := h.svc.RemoveFromCart(c.Request().Context(), req); err != nil { + msg, code := httpmsg.Error(err) + return c.JSON(code, msg) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h Handler) updateQuantity(c echo.Context) error { + claims := claim.GetClaimsFromEchoContext(c) + p := c.Param("productID") + + pID, err := strconv.Atoi(p) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid product id", + }) + } + + qStr := c.Param("quantity") + q, err := strconv.Atoi(qStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid quantity", + }) + } + + var req cart.UpdateQuantityRequest + req.UserID = types.ID(claims.UserID) + req.ProductID = types.ID(pID) + req.Quantity = q + + if err := h.svc.UpdateQuantity(c.Request().Context(), req); err != nil { + msg, code := httpmsg.Error(err) + return c.JSON(code, msg) + } + + return c.NoContent(http.StatusNoContent) +} diff --git a/shoppingbasketapp/delivery/http/health_check.go b/shoppingbasketapp/delivery/http/health_check.go new file mode 100644 index 00000000..aa0a0254 --- /dev/null +++ b/shoppingbasketapp/delivery/http/health_check.go @@ -0,0 +1,12 @@ +package http + +import ( + "github.com/labstack/echo/v4" + "net/http" +) + +func (s Server) healthCheck(c echo.Context) error { + return c.JSON(http.StatusOK, echo.Map{ + "message": "everything is good!", + }) +} diff --git a/shoppingbasketapp/delivery/http/server.go b/shoppingbasketapp/delivery/http/server.go new file mode 100644 index 00000000..cfe5fe8c --- /dev/null +++ b/shoppingbasketapp/delivery/http/server.go @@ -0,0 +1,43 @@ +package http + +import ( + "context" + "git.gocasts.ir/ebhomengo/niki/pkg/httpserver" +) + +type Server struct { + handler Handler + HTTPServer *httpserver.Server +} + +func NewServer(handler Handler, hS *httpserver.Server) Server { + return Server{handler: handler, HTTPServer: hS} +} + +func (s Server) Serve() error { + s.registerRoutes() + if err := s.HTTPServer.Start(); err != nil { + return err + } + + return nil +} + +func (s Server) Stop(ctx context.Context) error { + return s.HTTPServer.Stop(ctx) +} + +func (s Server) registerRoutes() { + router := s.HTTPServer.GetRouter() + + router.GET("shoppingbasket/health-check", s.healthCheck) + + r := router.Group("shoppingbasket/cart") // Authentication is required + + r.GET("/", s.handler.getCart) + r.DELETE("/", s.handler.removeCart) + + r.POST("/items", s.handler.addToBasket) + r.DELETE("/items/:productID", s.handler.removeItem) + r.PUT("/items/:productID/:quantity", s.handler.updateQuantity) +} diff --git a/shoppingbasketapp/repository/cart.go b/shoppingbasketapp/repository/cart.go new file mode 100644 index 00000000..7e45ce92 --- /dev/null +++ b/shoppingbasketapp/repository/cart.go @@ -0,0 +1,272 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/service/cart" + "git.gocasts.ir/ebhomengo/niki/types" + "github.com/redis/go-redis/v9" + "strconv" + "strings" + "time" +) + +const ( + FieldNumber = 5 + UserIDField = "user_id" + CreatedAtField = "created_at" + ExpireAtField = "expire_at" + TotalPriceField = "total_price" +) + +type Config struct { + KartKeyPrefix string `koanf:"kart_key_prefix"` + TTL time.Duration `koanf:"ttl"` +} + +type Repo struct { + client *redis.Client + config Config +} + +func New(client *redis.Client, cfg Config) Repo { + return Repo{client: client, config: cfg} +} + +func (r Repo) cartKey(userID types.ID) string { + return r.config.KartKeyPrefix + fmt.Sprintf("%d", userID) +} + +func (r Repo) itemKey(productID types.ID) string { + return fmt.Sprintf("item:%d", productID) +} + +func (r Repo) AddItem(ctx context.Context, userID types.ID, item cart.Item) error { + const op = "shoppingbasketapp.repository.AddItem" + + cartKey := r.cartKey(userID) + itemKey := r.itemKey(item.ProductID) + now := time.Now().UnixNano() + + itemJson, _ := json.Marshal(item) + + exists, _ := r.client.Exists(ctx, cartKey).Result() + + if exists == 0 { + r.client.HSet(ctx, cartKey, map[string]interface{}{ + UserIDField: userID, + itemKey: string(itemJson), + TotalPriceField: item.Price * types.Price(item.Quantity), + CreatedAtField: now, + ExpireAtField: now + r.config.TTL.Nanoseconds(), + }) + } else { + existsItem, _ := r.client.HGet(ctx, cartKey, itemKey).Result() + if existsItem != "" { + var i cart.Item + if err := json.Unmarshal([]byte(existsItem), &i); err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + item.Quantity += i.Quantity + itemJson, _ = json.Marshal(item) + } + + r.client.HSet(ctx, cartKey, itemKey, string(itemJson)) + r.client.HSet(ctx, cartKey, ExpireAtField, now+r.config.TTL.Nanoseconds()) + if err := r.updateTotalPrice(ctx, cartKey); err != nil { + return err + } + } + + r.client.Expire(ctx, cartKey, r.config.TTL) + + return nil +} + +func parsInt(s string) int64 { + i, _ := strconv.ParseInt(s, 10, 64) + return i +} + +func (r Repo) GetCart(ctx context.Context, userID types.ID) (cart.Cart, error) { + const op = "shoppingbasketapp.repository.GetCart" + cartKey := r.cartKey(userID) + + exists, err := r.client.Exists(ctx, cartKey).Result() + if err != nil { + return cart.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + if exists == 0 { + return cart.Cart{}, richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found shopping basket") + } + + allCart, err := r.client.HGetAll(ctx, cartKey).Result() + if err != nil { + return cart.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + c := cart.Cart{Items: []cart.Item{}} + + for field, value := range allCart { + if strings.HasPrefix(field, "item:") { + var i cart.Item + if err := json.Unmarshal([]byte(value), &i); err != nil { + return cart.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + c.Items = append(c.Items, i) + + continue + } + + switch field { + case UserIDField: + c.UserID = types.ID(parsInt(value)) + case TotalPriceField: + c.TotalPrice = types.Price(parsInt(value)) + case CreatedAtField: + c.CreatedAt = parsInt(value) + case ExpireAtField: + c.ExpireAt = parsInt(value) + } + } + + return c, nil +} + +func (r Repo) DeleteItem(ctx context.Context, userID, productID types.ID) error { + const op = "shoppingbasketapp.repository.DeleteItem" + cartKey := r.cartKey(userID) + itemKey := r.itemKey(productID) + + if err := r.existsCart(ctx, cartKey); err != nil { + return err + } + + if err := r.existsItem(ctx, cartKey, itemKey); err != nil { + return err + } + + if err := r.client.HDel(ctx, cartKey, itemKey).Err(); err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + num, err := r.client.HLen(ctx, cartKey).Result() + if err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + if num < FieldNumber { + return r.DeleteCart(ctx, userID) + } + + return r.updateTotalPrice(ctx, cartKey) +} + +func (r Repo) UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error { + const op = "shoppingbasketapp.repository.UpdateQuantity" + cartKey := r.cartKey(userID) + itemKey := r.itemKey(productID) + + if err := r.existsCart(ctx, cartKey); err != nil { + return err + } + + if err := r.existsItem(ctx, cartKey, itemKey); err != nil { + return err + } + + data, err := r.client.HGet(ctx, cartKey, itemKey).Result() + if err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + var item cart.Item + if err := json.Unmarshal([]byte(data), &item); err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + item.Quantity = quantity + + j, _ := json.Marshal(item) + + if err := r.client.HSet(ctx, cartKey, itemKey, string(j)).Err(); err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + return r.updateTotalPrice(ctx, cartKey) +} + +func (r Repo) DeleteCart(ctx context.Context, userID types.ID) error { + const op = "shoppingbasketapp.repository.DeleteCart" + cartKey := r.cartKey(userID) + + if err := r.existsCart(ctx, cartKey); err != nil { + return err + } + + if err := r.client.Del(ctx, cartKey).Err(); err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + return nil +} + +func (r Repo) updateTotalPrice(ctx context.Context, cartKey string) error { + const op = "shoppingbasketapp.repository.updateTotalPrice" + + allFields, err := r.client.HGetAll(ctx, cartKey).Result() + if err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + var total types.Price + + for field, value := range allFields { + if strings.HasPrefix(field, "item:") { + var item cart.Item + + if err := json.Unmarshal([]byte(value), &item); err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + total += item.Price * types.Price(item.Quantity) + } + } + + return r.client.HSet(ctx, cartKey, TotalPriceField, int64(total)).Err() +} + +func (r Repo) existsCart(ctx context.Context, cartKey string) error { + const op = "shoppingbasketapp.repository.existsCart" + + exists, err := r.client.Exists(ctx, cartKey).Result() + if err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + if exists == 0 { + return richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found shopping basket") + } + + return nil +} + +func (r Repo) existsItem(ctx context.Context, cartKey, itemKey string) error { + const op = "shoppingbasketapp.repository.existsCart" + + exists, err := r.client.HExists(ctx, cartKey, itemKey).Result() + if err != nil { + return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err) + } + + if !exists { + return richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found product form shopping basket") + } + + return nil +} diff --git a/shoppingbasketapp/service/cart/entity.go b/shoppingbasketapp/service/cart/entity.go new file mode 100644 index 00000000..f3032daf --- /dev/null +++ b/shoppingbasketapp/service/cart/entity.go @@ -0,0 +1,21 @@ +package cart + +import ( + "git.gocasts.ir/ebhomengo/niki/types" +) + +type Item struct { + ProductID types.ID + Quantity int + Price types.Price + Name string + AddedAt int64 +} + +type Cart struct { + UserID types.ID + Items []Item + TotalPrice types.Price + ExpireAt int64 + CreatedAt int64 +} diff --git a/shoppingbasketapp/service/cart/param.go b/shoppingbasketapp/service/cart/param.go new file mode 100644 index 00000000..97d8a520 --- /dev/null +++ b/shoppingbasketapp/service/cart/param.go @@ -0,0 +1,30 @@ +package cart + +import "git.gocasts.ir/ebhomengo/niki/types" + +type AddToCartRequest struct { + UserID types.ID `json:"user_id"` + ProductID types.ID `json:"product_id"` + Quantity int `json:"quantity"` + Price types.Price `json:"price"` + Name string `json:"name"` +} + +type GetCartResponse struct { + UserID types.ID `json:"user_id"` + Items []Item `json:"items"` + TotalPrice types.Price `json:"total_price"` + CreatedAt int64 `json:"created_at"` + ExpireAt int64 `json:"expire_at"` +} + +type RemoveFromCartRequest struct { + UserID types.ID `json:"user_id"` + ProductID types.ID `json:"product_id"` +} + +type UpdateQuantityRequest struct { + UserID types.ID `json:"user_id"` + ProductID types.ID `json:"product_id"` + Quantity int `json:"quantity"` +} diff --git a/shoppingbasketapp/service/cart/service.go b/shoppingbasketapp/service/cart/service.go new file mode 100644 index 00000000..1e2da37e --- /dev/null +++ b/shoppingbasketapp/service/cart/service.go @@ -0,0 +1,103 @@ +package cart + +import ( + "context" + "git.gocasts.ir/ebhomengo/niki/pkg/logger" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/types" + "time" +) + +type Repository interface { + AddItem(ctx context.Context, userID types.ID, item Item) error + GetCart(ctx context.Context, userID types.ID) (Cart, error) + DeleteItem(ctx context.Context, userID, productID types.ID) error + UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error + DeleteCart(ctx context.Context, userID types.ID) error +} + +type Service struct { + validate Validate + repo Repository +} + +func New(val Validate, repo Repository) Service { + return Service{validate: val, repo: repo} +} + +func (s Service) AddToBasket(ctx context.Context, req AddToCartRequest) error { + const op = "shoppingbasketapp.service.AddToBasket" + + if err := s.validate.ValidateAddToCart(req); err != nil { + logger.L().Error("shoppingbasket-service-AddToBasket", "error", err) + return err + } + + return s.repo.AddItem(ctx, req.UserID, Item{ + ProductID: req.ProductID, + Quantity: req.Quantity, + Price: req.Price, + Name: req.Name, + AddedAt: time.Now().UnixNano(), + }) +} + +func (s Service) GetCart(ctx context.Context, userID types.ID) (GetCartResponse, error) { + const op = "shoppingbasketapp.service.GetCart" + if userID < 1 { + logger.L().Error("shoppingbasket-service-GetCart", "error", "user id must be greater than 1") + return GetCartResponse{}, richerror.New(op).WithKind(richerror.KindInvalid).WithMessage("invalid user id") + } + + res, err := s.repo.GetCart(ctx, userID) + if err != nil { + logger.L().Error("shoppingbasket-service-GetCart", "error", err) + return GetCartResponse{}, richerror.New(op).WithErr(err) + } + + return GetCartResponse{ + UserID: res.UserID, + Items: res.Items, + TotalPrice: res.TotalPrice, + CreatedAt: res.CreatedAt, + ExpireAt: res.ExpireAt, + }, nil +} + +func (s Service) RemoveFromCart(ctx context.Context, req RemoveFromCartRequest) error { + const op = "shoppingbaskerapp.service.RemoveFromCart" + + if err := s.validate.ValidateRemoveFromCart(req); err != nil { + logger.L().Error("shoppingbasket-service-RemoveFromCart", "error", err) + return err + } + + return s.repo.DeleteItem(ctx, req.UserID, req.ProductID) +} + +func (s Service) UpdateQuantity(ctx context.Context, req UpdateQuantityRequest) error { + const op = "shoppingbaskerapp.service.UpdateQuantity" + + if err := s.validate.ValidateUpdateQuantity(req); err != nil { + logger.L().Error("shoppingbasket-service-UpdateQuantity", "error", err) + return err + } + + if req.Quantity == 0 { + return s.repo.DeleteItem(ctx, req.UserID, req.ProductID) + } + + return s.repo.UpdateQuantity(ctx, req.UserID, req.ProductID, req.Quantity) +} + +func (s Service) ClearCart(ctx context.Context, userID types.ID) error { + const op = "shoppingbaskerapp.service.ClearCart" + + if userID < 1 { + logger.L().Error("shoppingbasket-service-ClearCart", "error", "user id must be greater than 1") + return richerror.New(op).WithKind(richerror.KindInvalid). + WithMessage("invalid user id") + } + + return s.repo.DeleteCart(ctx, userID) +} diff --git a/shoppingbasketapp/service/cart/validator.go b/shoppingbasketapp/service/cart/validator.go new file mode 100644 index 00000000..2ce4b2f7 --- /dev/null +++ b/shoppingbasketapp/service/cart/validator.go @@ -0,0 +1,91 @@ +package cart + +import ( + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +const ( + ErrValidationPositive = "must be positive" + ErrValidationInvalidInput = "invalid input" +) + +type Validate struct{} + +func NewValidate() Validate { + return Validate{} +} + +func (v Validate) ValidateAddToCart(req AddToCartRequest) error { + const op = "shoppingbasketapp.service.AddToCart" + + if err := validation.ValidateStruct(&req, + validation.Field(&req.UserID, validation.Required), + validation.Field(&req.ProductID, validation.Required), + validation.Field(&req.Price, validation.Required, validation.Min(int64(1)).Error(ErrValidationPositive)), + validation.Field(&req.Quantity, validation.Min(int(1)).Error(ErrValidationPositive)), + validation.Field(&req.Name, validation.Required)); err != nil { + + fieldErr := make(map[string]interface{}) + vErr, ok := err.(validation.Errors) + if ok { + for key, value := range vErr { + if value != nil { + fieldErr[key] = value.Error() + } + } + } + + return richerror.New(op).WithMessage(ErrValidationInvalidInput). + WithMeta(fieldErr).WithErr(err).WithKind(richerror.KindInvalid) + } + + return nil +} + +func (v Validate) ValidateRemoveFromCart(req RemoveFromCartRequest) error { + const op = "shoppingbasketapp.service.ValidateRemoveFromCart" + + if err := validation.ValidateStruct(&req, + validation.Field(&req.UserID, validation.Required), + validation.Field(&req.ProductID, validation.Required)); err != nil { + + fieldErrs := make(map[string]interface{}) + vErr, ok := err.(validation.Errors) + if ok { + for key, value := range vErr { + if value != nil { + fieldErrs[key] = value.Error() + } + } + } + return richerror.New(op).WithMessage(ErrValidationInvalidInput). + WithKind(richerror.KindInvalid).WithMeta(fieldErrs).WithErr(err) + } + + return nil +} + +func (v Validate) ValidateUpdateQuantity(req UpdateQuantityRequest) error { + const op = "shoppingbasketapp.service.ValidateUpdateQuantity" + + if err := validation.ValidateStruct(&req, + validation.Field(&req.UserID, validation.Required), + validation.Field(&req.ProductID, validation.Required), + validation.Field(&req.Quantity, validation.Required, validation.Min(int(1)))); err != nil { + + fieldErrs := make(map[string]interface{}) + vErr, ok := err.(validation.Errors) + if ok { + for key, value := range vErr { + if value != nil { + fieldErrs[key] = value.Error() + } + } + } + return richerror.New(op).WithMessage(ErrValidationInvalidInput). + WithKind(richerror.KindInvalid).WithMeta(fieldErrs).WithErr(err) + } + + return nil +}