diff --git a/cmd/purchaseapp/command/migrate.go b/cmd/purchaseapp/command/migrate.go new file mode 100644 index 00000000..d47dcf0d --- /dev/null +++ b/cmd/purchaseapp/command/migrate.go @@ -0,0 +1 @@ +package command diff --git a/cmd/purchaseapp/command/root.go b/cmd/purchaseapp/command/root.go new file mode 100644 index 00000000..d47dcf0d --- /dev/null +++ b/cmd/purchaseapp/command/root.go @@ -0,0 +1 @@ +package command diff --git a/cmd/purchaseapp/command/serve.go b/cmd/purchaseapp/command/serve.go new file mode 100644 index 00000000..d47dcf0d --- /dev/null +++ b/cmd/purchaseapp/command/serve.go @@ -0,0 +1 @@ +package command diff --git a/cmd/purchaseapp/main.go b/cmd/purchaseapp/main.go new file mode 100644 index 00000000..fdd06b0b --- /dev/null +++ b/cmd/purchaseapp/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "flag" + "fmt" + "git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http" + purchaseMysql "git.gocasts.ir/ebhomengo/niki/purchaseapp/repository/mysql" + "git.gocasts.ir/ebhomengo/niki/purchaseapp/service/order" + "git.gocasts.ir/ebhomengo/niki/repository/migrator" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" +) + +func MariaDB() *mysql.DB { + cfg := mysql.Config{ + Username: "niki", + Password: "nikiappt0lk2o20", + Port: 3306, + Host: "localhost", + DBName: "niki_db", + } + migrate := flag.Bool("migrate", false, "perform database migration") + flag.Parse() + if *migrate { + migrator.New(migrator.Config{ + MysqlConfig: cfg, + MigrationPath: "./purchaseapp/repository/mysql/migration", + MigrationDBName: "gorp_migrations", + }).Up() + } + + return mysql.New(cfg) +} + +func main() { + cfg := mysql.Config{ + Username: "niki", + Password: "nikiappt0lk2o20", + Port: 3306, + Host: "localhost", + DBName: "niki_db", + } + db := mysql.New(cfg) + defer func() { + if err := db.CloseStatements(); err != nil { + fmt.Printf("Error closing statements: %v\n", err) + } + }() + + orderRepo := purchaseMysql.New(db) + + orderSvc := Service(orderRepo) + server := HTTPServer(orderSvc) + server.Serve() + +} + +func HTTPServer(orderSvc order.Service) *http.Server { + return http.New(orderSvc) +} + +func Service(orderRepo *purchaseMysql.DB) order.Service { + return order.New(orderRepo) +} diff --git a/config/loader.go b/config/loader.go index 3867c153..58de5ef9 100644 --- a/config/loader.go +++ b/config/loader.go @@ -12,12 +12,13 @@ import ( ) const ( - defaultPrefix = "EB_" - defaultDelimiter = "." - defaultSeparator = "__" - defaultYamlFilePath = "config.yml" + defaultPrefix = "EB_" + defaultDelimiter = "." + defaultSeparator = "__" ) +var defaultYamlFilePath = "config.yml" + var c Config type Option struct { diff --git a/purchaseapp/app.go b/purchaseapp/app.go index 8f3c7051..8a8eda21 100644 --- a/purchaseapp/app.go +++ b/purchaseapp/app.go @@ -1 +1,59 @@ package purchaseapp + +import ( + "fmt" + purchaseHTTP "git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http" + purchaseHandler "git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http/order" + purchaseMysql "git.gocasts.ir/ebhomengo/niki/purchaseapp/repository/mysql" + purchaseService "git.gocasts.ir/ebhomengo/niki/purchaseapp/service/order" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" +) + +type Application struct { + Config Config + HTTPServer *purchaseHTTP.Server + purchaseService purchaseService.Service + PurchaseHandler *purchaseHandler.Handler + PurchaseRepo purchaseService.Repo + DB *mysql.DB +} + +func SetUp() *Application { + + cfg := mysql.Config{ + Username: "niki", + Password: "nikiappt0lk2o20", + Port: 3306, + Host: "localhost", + DBName: "niki_db", + } + db := mysql.New(cfg) + defer func() { + if err := db.CloseStatements(); err != nil { + fmt.Printf("Error closing statements: %v\n", err) + } + }() + + orderRepo := purchaseMysql.New(db) + + orderSvc := Service(orderRepo) + server := HTTPServer(orderSvc) + + return &Application{ + Config: Config{}, + HTTPServer: server, + purchaseService: orderSvc, + PurchaseHandler: purchaseHandler.New(orderSvc), + PurchaseRepo: orderRepo, + DB: db, + } + +} + +func HTTPServer(orderSvc purchaseService.Service) *purchaseHTTP.Server { + return purchaseHTTP.New(orderSvc) +} + +func Service(orderRepo *purchaseMysql.DB) purchaseService.Service { + return purchaseService.New(orderRepo) +} diff --git a/purchaseapp/config.go b/purchaseapp/config.go index 3957f32e..988edda2 100644 --- a/purchaseapp/config.go +++ b/purchaseapp/config.go @@ -1,4 +1,9 @@ package purchaseapp +import ( + "git.gocasts.ir/ebhomengo/niki/repository/mysql" +) + type Config struct { + Mysql mysql.Config `koanf:"mariadb"` } diff --git a/purchaseapp/delivery/http/invoice/handler.go b/purchaseapp/delivery/http/invoice/handler.go deleted file mode 100644 index d5de3ca8..00000000 --- a/purchaseapp/delivery/http/invoice/handler.go +++ /dev/null @@ -1,8 +0,0 @@ -package invoice - -type Handler struct { -} - -func New() *Handler { - return &Handler{} -} diff --git a/purchaseapp/delivery/http/invoice/route.go b/purchaseapp/delivery/http/invoice/route.go deleted file mode 100644 index 7584f63a..00000000 --- a/purchaseapp/delivery/http/invoice/route.go +++ /dev/null @@ -1,7 +0,0 @@ -package invoice - -import "github.com/labstack/echo/v4" - -func (h Handler) SetRoutes(e *echo.Echo) { - -} diff --git a/purchaseapp/delivery/http/order/handler.go b/purchaseapp/delivery/http/order/handler.go index a515fef5..b88f7133 100644 --- a/purchaseapp/delivery/http/order/handler.go +++ b/purchaseapp/delivery/http/order/handler.go @@ -1,7 +1,69 @@ package order -type Handler struct{} +import ( + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/purchaseapp/entity" + "git.gocasts.ir/ebhomengo/niki/purchaseapp/service/order" + "git.gocasts.ir/ebhomengo/niki/types" + "github.com/labstack/echo/v4" + "net/http" + "time" +) -func New() *Handler { - return &Handler{} +type Handler struct { + orderSvc *order.Service +} + +func New(orderSvc order.Service) *Handler { + return &Handler{orderSvc: &orderSvc} +} + +func (h *Handler) CreateOrderHandler(c echo.Context) error { + var req order.CreateOrderRequest + if err := c.Bind(&req); err != nil { + msg, code := getErrorDataFromRichError(err) + return echo.NewHTTPError(code, msg) + } + + orderItems := req.OrderItems + order := entity.Order{ + ID: 0, + UserID: types.ID(req.UserID), + TotalAmount: types.Price(req.TotalAmount), + TotalDiscount: types.Price(req.TotalDiscount), + ShippingID: types.ID(req.ShippingID), + PaymentMethod: req.PaymentMethod, + ProcessStatus: entity.WaitingToPay, + PaymentStatus: entity.UnPaid, + Address: req.Address, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + resp, lErr := h.orderSvc.CreateOrder(order, orderItems) + + if lErr != nil { + msg, code := getErrorDataFromRichError(lErr) + return echo.NewHTTPError(code, msg) + } + return c.JSON(http.StatusOK, resp) +} + +func getErrorDataFromRichError(err error) (message string, code int) { + switch err.(type) { + case richerror.RichError: + re := err.(richerror.RichError) + + return re.Message(), mapKindToCode(re.Kind()) + default: + return err.Error(), http.StatusBadRequest + } +} + +func mapKindToCode(kind richerror.Kind) int { + switch kind { + case richerror.KindInvalid: + return http.StatusUnprocessableEntity + default: + return http.StatusBadRequest + } } diff --git a/purchaseapp/delivery/http/order/route.go b/purchaseapp/delivery/http/order/route.go index 62b20d3c..3db0dd1f 100644 --- a/purchaseapp/delivery/http/order/route.go +++ b/purchaseapp/delivery/http/order/route.go @@ -4,4 +4,5 @@ import "github.com/labstack/echo/v4" func (h Handler) SetRoutes(e *echo.Echo) { + e.POST("/order/create", h.CreateOrderHandler) } diff --git a/purchaseapp/delivery/http/server.go b/purchaseapp/delivery/http/server.go index fd23a2f6..1da710cb 100644 --- a/purchaseapp/delivery/http/server.go +++ b/purchaseapp/delivery/http/server.go @@ -1,33 +1,32 @@ package http import ( - httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server" - "git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http/invoice" "git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http/order" + orderService "git.gocasts.ir/ebhomengo/niki/purchaseapp/service/order" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" ) type Server struct { - HTTPServer *httpserver.Server - OrderHandler *order.Handler - InvoiceHandler *invoice.Handler + OrderHandler *order.Handler } -func New(httpserver *httpserver.Server) *Server { +func New(orderSvc orderService.Service) *Server { return &Server{ - HTTPServer: httpserver, - OrderHandler: order.New(), - InvoiceHandler: invoice.New(), + OrderHandler: order.New(orderSvc), } } func (s *Server) Serve() { - s.RegisterRoutes() + e := echo.New() + e.Use(middleware.RequestLogger()) + e.GET("/purchase/health-check", s.healthCheck) + + s.OrderHandler.SetRoutes(e) + + if err := e.Start(":8088"); err != nil { + e.Logger.Error("failed to start server", "error", err) + } } func (s *Server) Stop() {} - -func (s *Server) RegisterRoutes() { - s.HTTPServer.Router.GET("/purchase/health-check", s.healthCheck) - s.OrderHandler.SetRoutes(s.HTTPServer.Router) - s.InvoiceHandler.SetRoutes(s.HTTPServer.Router) -} diff --git a/purchaseapp/entity/invoice.go b/purchaseapp/entity/invoice.go deleted file mode 100644 index ee532a52..00000000 --- a/purchaseapp/entity/invoice.go +++ /dev/null @@ -1,4 +0,0 @@ -package entity - -type Invoice struct { -} diff --git a/purchaseapp/entity/order.go b/purchaseapp/entity/order.go index 332403f8..0e235f66 100644 --- a/purchaseapp/entity/order.go +++ b/purchaseapp/entity/order.go @@ -1,4 +1,59 @@ package entity +import ( + "git.gocasts.ir/ebhomengo/niki/types" + "time" +) + type Order struct { + ID types.ID + UserID types.ID + TotalAmount types.Price + TotalDiscount types.Price + ShippingID types.ID + PaymentMethod PaymentMethod + ProcessStatus ProcessStatus + PaymentStatus PaymentStatus + Address string + CreatedAt time.Time + UpdatedAt time.Time } + +type OrderItem struct { + ID types.ID + ProductID types.ID + Price types.Price + Quantity types.Count + PriceWithDiscount types.Price + OrderID types.ID + CreatedAt time.Time +} + +type PaymentMethod string + +const ( + Online PaymentMethod = "online" + Wallet = "wallet" + Cart = "cart" +) + +type ProcessStatus string + +const ( + WaitingToPay ProcessStatus = "waiting-to-pay" + processing = "processing" + accepted = "accepted" + preparing = "preparing" + prepared = "prepared" + givenToPost = "given-to-post" + delivered = "delivered" + cancelled = "cancelled" +) + +type PaymentStatus string + +const ( + Paid PaymentStatus = "paid" + UnPaid = "unpaid" + Cancelled = "cancelled" +) diff --git a/purchaseapp/repository/migrations/2026010411120_create_orders_table.sql b/purchaseapp/repository/migrations/2026010411120_create_orders_table.sql new file mode 100644 index 00000000..2fe04c92 --- /dev/null +++ b/purchaseapp/repository/migrations/2026010411120_create_orders_table.sql @@ -0,0 +1,23 @@ +-- +migrate Up +-- please read this article to understand why we use VARCHAR(191) +-- https://www.grouparoo.com/blog/varchar-191#why-varchar-and-not-text +CREATE TABLE `orders` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `user_id` INT NOT NULL, + `address` TEXT, + `shipping_id` INT NOT NULL, + `payment_method` ENUM('online', 'wallet', 'cart') DEFAULT 'online', + `payment_status` ENUM('unpaid', 'paid', 'cancelled') DEFAULT 'unpaid', + `process_status` ENUM('waiting-to-pay', 'processing', 'accepted', 'preparing', 'prepared', 'given-to-post', 'delivered', 'cancelled') DEFAULT 'waiting-to-pay', + `total_amount` INT NOT NULL, + `total_discount` INT NULL, + + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +-- FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) +-- FOREIGN KEY (`shipping_id`) REFERENCES `shippings`(`id`) + +); + +-- +migrate Down +DROP TABLE `orders`; diff --git a/purchaseapp/repository/migrations/20260104_11121_create_order_items_table.sql b/purchaseapp/repository/migrations/20260104_11121_create_order_items_table.sql new file mode 100644 index 00000000..bd209205 --- /dev/null +++ b/purchaseapp/repository/migrations/20260104_11121_create_order_items_table.sql @@ -0,0 +1,19 @@ +-- +migrate Up +-- please read this article to understand why we use VARCHAR(191) +-- https://www.grouparoo.com/blog/varchar-191#why-varchar-and-not-text +CREATE TABLE `order_items` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `order_id` INT NOT NULL, + `product_id` INT NOT NULL, + `quantity` INT DEFAULT 1, + `price` INT NOT NULL, + `price_with_discount` INT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) + +); + +-- +migrate Down +DROP TABLE `order_items`; diff --git a/purchaseapp/repository/mysql/order.go b/purchaseapp/repository/mysql/order.go new file mode 100644 index 00000000..279aa351 --- /dev/null +++ b/purchaseapp/repository/mysql/order.go @@ -0,0 +1,69 @@ +package mysql + +import ( + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/purchaseapp/entity" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" + "git.gocasts.ir/ebhomengo/niki/types" +) + +type DB struct { + conn *mysql.DB +} + +func New(db *mysql.DB) *DB { + return &DB{conn: db} +} + +func (d *DB) CreateOrder(order entity.Order, orderItems []entity.OrderItem) (types.ID, error) { + + const Op = "repository.mysql.order.createorder" + tx, err := d.conn.Conn().Begin() + + if err != nil { + return 0, richerror.New(Op).WithErr(err) + } + + defer tx.Rollback() + + query := "insert into orders(user_id, address, shipping_id," + + " payment_method, payment_status, process_status," + + " total_amount, total_discount) values (?, ?, ?, ?, ?, ?, ?, ?);" + res, oErr := tx.Exec(query, order.UserID, order.Address, order.ShippingID, + order.PaymentMethod, order.PaymentStatus, order.ProcessStatus, + order.TotalAmount, order.TotalDiscount) + + if oErr != nil { + return 0, richerror.New(Op).WithErr(oErr) + } + orderID, insertIDErr := res.LastInsertId() + if insertIDErr != nil { + return 0, richerror.New(Op).WithErr(insertIDErr) + } + + orderItemQuery := "insert into order_items(order_id, product_id, quantity, price, price_with_discount) values(?, ?, ?, ?, ?);" + for _, item := range orderItems { + _, iErr := tx.Exec(orderItemQuery, orderID, item.ProductID, item.Quantity, item.Price, item.PriceWithDiscount) + if iErr != nil { + return 0, richerror.New(Op).WithErr(iErr) + } + } + + if err := tx.Commit(); err != nil { + return 0, richerror.New(Op).WithErr(err) + } + + return types.ID(orderID), nil +} + +func (d *DB) UpdateOrderProcessStatus(orderID types.ID, status string) (bool, error) { + const Op = "repository.mysql.order.update-order-process-status" + _, err := d.conn.Conn().Exec("update orders set process_status=? where id=?;", status, orderID) + + if err != nil { + return false, richerror.New(Op).WithErr(err) + } + + return true, nil + +} diff --git a/purchaseapp/service/invoice/param.go b/purchaseapp/service/invoice/param.go deleted file mode 100644 index 958a6107..00000000 --- a/purchaseapp/service/invoice/param.go +++ /dev/null @@ -1 +0,0 @@ -package invoice diff --git a/purchaseapp/service/invoice/service.go b/purchaseapp/service/invoice/service.go deleted file mode 100644 index 63c4a3f5..00000000 --- a/purchaseapp/service/invoice/service.go +++ /dev/null @@ -1,4 +0,0 @@ -package invoice - -type Service struct { -} diff --git a/purchaseapp/service/invoice/validator.go b/purchaseapp/service/invoice/validator.go deleted file mode 100644 index 958a6107..00000000 --- a/purchaseapp/service/invoice/validator.go +++ /dev/null @@ -1 +0,0 @@ -package invoice diff --git a/purchaseapp/service/order/param.go b/purchaseapp/service/order/param.go index 175f0c10..d4a589f9 100644 --- a/purchaseapp/service/order/param.go +++ b/purchaseapp/service/order/param.go @@ -1 +1,20 @@ package order + +import ( + "git.gocasts.ir/ebhomengo/niki/purchaseapp/entity" + "git.gocasts.ir/ebhomengo/niki/types" +) + +type CreateOrderRequest struct { + UserID types.ID `json:"user_id"` + Address string `json:"address"` + ShippingID types.ID `json:"shipping_id"` + PaymentMethod entity.PaymentMethod `json:"payment_method"` + TotalAmount types.Price `json:"total_amount"` + TotalDiscount types.Price `json:"total_discount"` + OrderItems []entity.OrderItem `json:"order_items"` +} + +type CreateOrderResponse struct { + OrderID types.ID +} diff --git a/purchaseapp/service/order/service.go b/purchaseapp/service/order/service.go index 4ddf977f..21690140 100644 --- a/purchaseapp/service/order/service.go +++ b/purchaseapp/service/order/service.go @@ -1,4 +1,42 @@ package order +import ( + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/purchaseapp/entity" + "git.gocasts.ir/ebhomengo/niki/types" +) + type Service struct { + repo Repo +} + +type Repo interface { + CreateOrder(order entity.Order, orderItems []entity.OrderItem) (types.ID, error) + UpdateOrderProcessStatus(orderID types.ID, status string) (bool, error) +} + +func New(orderRepo Repo) Service { + return Service{repo: orderRepo} +} + +func (s Service) CreateOrder(order entity.Order, orderItems []entity.OrderItem) (CreateOrderResponse, error) { + const Op = "purchaseapp.service.CreateOrder" + orderID, err := s.repo.CreateOrder(order, orderItems) + + if err != nil { + return CreateOrderResponse{}, richerror.New(Op).WithErr(err) + } + + return CreateOrderResponse{OrderID: orderID}, nil +} + +func (s Service) UpdateOrderProcessStatus(orderID types.ID, status string) (bool, error) { + + const Op = "purchaseapp.service.UpdateOrderProcessStatus" + _, err := s.repo.UpdateOrderProcessStatus(orderID, status) + if err != nil { + return false, richerror.New(Op).WithErr(err) + } + + return true, nil } diff --git a/repository/mysql/db.go b/repository/mysql/db.go index eefe5508..1c8b08a1 100644 --- a/repository/mysql/db.go +++ b/repository/mysql/db.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + _ "github.com/go-sql-driver/mysql" "sync" "time" diff --git a/types/count.go b/types/count.go new file mode 100644 index 00000000..ff20ea41 --- /dev/null +++ b/types/count.go @@ -0,0 +1,3 @@ +package types + +type Count uint32 diff --git a/types/id.go b/types/id.go new file mode 100644 index 00000000..fb0c5a25 --- /dev/null +++ b/types/id.go @@ -0,0 +1,3 @@ +package types + +type ID uint64 diff --git a/types/price.go b/types/price.go new file mode 100644 index 00000000..195869d4 --- /dev/null +++ b/types/price.go @@ -0,0 +1,3 @@ +package types + +type Price uint64