diff --git a/app/actions/orderStatusActions/examples.go b/app/actions/orderStatusActions/examples.go new file mode 100644 index 0000000..bdd13ae --- /dev/null +++ b/app/actions/orderStatusActions/examples.go @@ -0,0 +1,116 @@ +package orderStatusActions + +// func init() { +// GlobalRegistry.Register(enums.OrderStatusConfirmed, ActionChain{ +// SendOrderConfirmationEmail, +// NotifyInventorySystem, +// }) + +// GlobalRegistry.Register(enums.OrderStatusProcessing, ActionChain{ +// NotifyWarehouse, +// ReserveInventory, +// }) + +// GlobalRegistry.Register(enums.OrderStatusShipped, ActionChain{ +// NotifyWarehouseShipped, +// GenerateTrackingNumber, +// SendShippingNotificationEmail, +// }) + +// GlobalRegistry.Register(enums.OrderStatusDelivered, ActionChain{ +// SendDeliveryConfirmationEmail, +// NotifyFulfillmentComplete, +// }) + +// GlobalRegistry.Register(enums.OrderStatusCancelled, ActionChain{ +// SendCancellationEmail, +// ReleaseInventory, +// ProcessRefund, +// }) + +// GlobalRegistry.Register(enums.OrderStatusReturned, ActionChain{ +// SendReturnConfirmationEmail, +// NotifyReturnsDepartment, +// }) + +// GlobalRegistry.Register(enums.OrderStatusRefunded, ActionChain{ +// NotifyRefundProcessed, +// }) + +// GlobalRegistry.Register(enums.OrderStatusPending, ActionChain{}) +// } + +// var SendOrderConfirmationEmail = WithID("send_order_confirmation_email", func(actionCtx ActionContext) ActionResult { +// log.Printf("Sending order confirmation email for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var NotifyInventorySystem = WithID("notify_inventory_system", func(actionCtx ActionContext) ActionResult { +// log.Printf("Notifying inventory system for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var NotifyWarehouse = WithID("notify_warehouse", func(actionCtx ActionContext) ActionResult { +// log.Printf("Notifying warehouse for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var ReserveInventory = WithID("reserve_inventory", func(actionCtx ActionContext) ActionResult { +// log.Printf("Reserving inventory for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var NotifyWarehouseShipped = WithID("notify_warehouse_shipped", func(actionCtx ActionContext) ActionResult { +// log.Printf("Notifying warehouse of shipment for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var GenerateTrackingNumber = WithID("generate_tracking_number", func(actionCtx ActionContext) ActionResult { +// log.Printf("Generating tracking number for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var SendShippingNotificationEmail = WithID("send_shipping_notification_email", func(actionCtx ActionContext) ActionResult { +// log.Printf("Sending shipping notification email for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var SendDeliveryConfirmationEmail = WithID("send_delivery_confirmation_email", func(actionCtx ActionContext) ActionResult { +// log.Printf("Sending delivery confirmation email for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var NotifyFulfillmentComplete = WithID("notify_fulfillment_complete", func(actionCtx ActionContext) ActionResult { +// log.Printf("Notifying fulfillment complete for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var SendCancellationEmail = WithID("send_cancellation_email", func(actionCtx ActionContext) ActionResult { +// log.Printf("Sending cancellation email for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var ReleaseInventory = WithID("release_inventory", func(actionCtx ActionContext) ActionResult { +// log.Printf("Releasing inventory for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var ProcessRefund = WithID("process_refund", func(actionCtx ActionContext) ActionResult { +// log.Printf("Processing refund for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var SendReturnConfirmationEmail = WithID("send_return_confirmation_email", func(actionCtx ActionContext) ActionResult { +// log.Printf("Sending return confirmation email for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var NotifyReturnsDepartment = WithID("notify_returns_department", func(actionCtx ActionContext) ActionResult { +// log.Printf("Notifying returns department for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) + +// var NotifyRefundProcessed = WithID("notify_refund_processed", func(actionCtx ActionContext) ActionResult { +// log.Printf("Notifying refund processed for order %d", actionCtx.OrderId) +// return ActionResult{Err: nil} +// }) diff --git a/app/actions/orderStatusActions/pending.go b/app/actions/orderStatusActions/pending.go new file mode 100644 index 0000000..2017514 --- /dev/null +++ b/app/actions/orderStatusActions/pending.go @@ -0,0 +1,21 @@ +package orderStatusActions + +import ( + "fmt" + + "git.ma-al.com/goc_daniel/b2b/app/model/enums" +) + +func init() { + var sendNewOrderEmail = WithID("send_new_order_email", func(actionCtx ActionContext) ActionResult { + + if actionCtx.EmailService == nil { + return ActionResult{Err: fmt.Errorf("emailService not provided")} + } + return ActionResult{Err: actionCtx.EmailService.SendNewOrderPlacedNotification(*actionCtx.UserId)} + }) + + GlobalRegistry.Register(enums.OrderStatusPending, ActionChain{ + sendNewOrderEmail, + }) +} diff --git a/app/actions/orderStatusActions/registry.go b/app/actions/orderStatusActions/registry.go new file mode 100644 index 0000000..f1d8688 --- /dev/null +++ b/app/actions/orderStatusActions/registry.go @@ -0,0 +1,88 @@ +package orderStatusActions + +import ( + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/model/enums" + "git.ma-al.com/goc_daniel/b2b/app/service/emailService" + "git.ma-al.com/goc_daniel/b2b/app/utils/logger" +) + +var GlobalRegistry = make(ActionRegistry) + +type ActionID string + +type ActionContext struct { + Order *model.CustomerOrder + UserId *uint + EmailService *emailService.EmailService +} + +type ActionResult struct { + Err error + Metadata map[string]any +} + +type OrderAction interface { + ID() ActionID + Execute(actionCtx ActionContext) ActionResult +} + +type ActionChain []OrderAction + +func (c ActionChain) Execute(actionCtx ActionContext) []ActionResult { + results := make([]ActionResult, 0, len(c)) + for _, action := range c { + result := action.Execute(actionCtx) + results = append(results, result) + if result.Err != nil { + logger.Debug("action failed", + "action_id", action.ID(), + "order", actionCtx.Order, + "user_id", actionCtx.UserId, + "error", result.Err, + ) + } + } + return results +} + +type ActionRegistry map[enums.OrderStatus]ActionChain + +func (r ActionRegistry) Register(status enums.OrderStatus, chain ActionChain) { + r[status] = chain +} + +func (r ActionRegistry) ExecuteForStatus(status enums.OrderStatus, actionCtx ActionContext) []ActionResult { + chain, exists := r[status] + if !exists { + return nil + } + return chain.Execute(actionCtx) +} + +type ActionFunc func(actionCtx ActionContext) ActionResult + +func (f ActionFunc) ID() ActionID { + return "anonymous" +} + +func (f ActionFunc) Execute(actionCtx ActionContext) ActionResult { + return f(actionCtx) +} + +type actionAdapter struct { + id ActionID + fn ActionFunc +} + +func (a *actionAdapter) ID() ActionID { + return a.id +} + +func (a *actionAdapter) Execute(actionCtx ActionContext) ActionResult { + return a.fn(actionCtx) +} + +func WithID(id ActionID, fn ActionFunc) OrderAction { + return &actionAdapter{id: id, fn: fn} +} diff --git a/app/delivery/web/api/restricted/carts.go b/app/delivery/web/api/restricted/carts.go index abd55c0..53fde2b 100644 --- a/app/delivery/web/api/restricted/carts.go +++ b/app/delivery/web/api/restricted/carts.go @@ -29,12 +29,12 @@ func NewCartsHandler() *CartsHandler { func CartsHandlerRoutes(r fiber.Router) fiber.Router { handler := NewCartsHandler() - r.Get("/add-new-cart", handler.AddNewCart) + r.Post("/add-new-cart", handler.AddNewCart) r.Delete("/remove-cart", handler.RemoveCart) - r.Get("/change-cart-name", handler.ChangeCartName) + r.Patch("/change-cart-name", handler.ChangeCartName) r.Get("/retrieve-carts-info", handler.RetrieveCartsInfo) r.Get("/retrieve-cart", handler.RetrieveCart) - r.Get("/add-product-to-cart", handler.AddProduct) + r.Post("/add-product-to-cart", handler.AddProduct) r.Delete("/remove-product-from-cart", handler.RemoveProduct) return r diff --git a/app/delivery/web/api/restricted/orders.go b/app/delivery/web/api/restricted/orders.go index 679ec1a..1857c33 100644 --- a/app/delivery/web/api/restricted/orders.go +++ b/app/delivery/web/api/restricted/orders.go @@ -2,8 +2,12 @@ package restricted import ( "strconv" + "strings" + "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/model/enums" "git.ma-al.com/goc_daniel/b2b/app/service/orderService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" @@ -32,7 +36,7 @@ func OrdersHandlerRoutes(r fiber.Router) fiber.Router { r.Get("/list", handler.ListOrders) r.Post("/place-new-order", handler.PlaceNewOrder) r.Post("/change-order-address", handler.ChangeOrderAddress) - r.Get("/change-order-status", handler.ChangeOrderStatus) + r.Patch("/change-order-status", middleware.Require(perms.OrdersModifyAll), handler.ChangeOrderStatus) return r } @@ -109,7 +113,13 @@ func (h *OrdersHandler) PlaceNewOrder(c fiber.Ctx) error { name := c.Query("name") - err = h.ordersService.PlaceNewOrder(userID, uint(cart_id), name, uint(country_id), address_info) + originalUserId, ok := localeExtractor.GetOriginalUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + err = h.ordersService.PlaceNewOrder(userID, uint(cart_id), name, uint(country_id), address_info, originalUserId) if err != nil { logger.Error("failed to place order", @@ -172,22 +182,19 @@ func (h *OrdersHandler) ChangeOrderAddress(c fiber.Ctx) error { // we base permissions and user based on target user only. // TODO: well, permissions and all that. func (h *OrdersHandler) ChangeOrderStatus(c fiber.Ctx) error { - user, ok := localeExtractor.GetCustomer(c) + 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))) } - order_id_attribute := c.Query("order_id") - order_id, err := strconv.Atoi(order_id_attribute) + order_id, err := strconv.Atoi(c.Query("order_id")) if err != nil { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - status := c.Query("status") - - err = h.ordersService.ChangeOrderStatus(user, uint(order_id), status) + err = h.ordersService.ChangeOrderStatus(userId, uint(order_id), enums.OrderStatus(strings.ToUpper(c.Query("status")))) if err != nil { logger.Error("failed to change order status", diff --git a/app/model/enums/orderStatus.go b/app/model/enums/orderStatus.go new file mode 100644 index 0000000..6aee1a3 --- /dev/null +++ b/app/model/enums/orderStatus.go @@ -0,0 +1,16 @@ +package enums + +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "PENDING" + OrderStatusConfirmed OrderStatus = "CONFIRMED" + OrderStatusProcessing OrderStatus = "PROCESSING" + OrderStatusShipped OrderStatus = "SHIPPED" + OrderStatusOutForDelivery OrderStatus = "OUT_FOR_DELIVERY" + OrderStatusDelivered OrderStatus = "DELIVERED" + OrderStatusCancelled OrderStatus = "CANCELLED" + OrderStatusReturned OrderStatus = "RETURNED" + OrderStatusRefunded OrderStatus = "REFUNDED" + OrderStatusFailed OrderStatus = "FAILED" +) diff --git a/app/model/order.go b/app/model/order.go index 93fcd96..4c893b8 100644 --- a/app/model/order.go +++ b/app/model/order.go @@ -1,21 +1,25 @@ package model -import "time" +import ( + "time" + + "git.ma-al.com/goc_daniel/b2b/app/model/enums" +) type CustomerOrder struct { - OrderID uint `gorm:"column:order_id;primaryKey;autoIncrement" json:"order_id"` - UserID uint `gorm:"column:user_id;not null;index" json:"user_id"` - Name string `gorm:"column:name;not null" json:"name"` - CountryID uint `gorm:"column:country_id;not null" json:"country_id"` - AddressString string `gorm:"column:address_string;not null" json:"address_string"` - AddressUnparsed *AddressUnparsed `gorm:"-" json:"address_unparsed"` - Status string `gorm:"column:status;size:50;not null" json:"status"` - BasePrice float64 `gorm:"column:base_price;type:decimal(10,2);not null" json:"base_price"` - TaxIncl float64 `gorm:"column:tax_incl;type:decimal(10,2);not null" json:"tax_incl"` - TaxExcl float64 `gorm:"column:tax_excl;type:decimal(10,2);not null" json:"tax_excl"` - CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;not null" json:"updated_at"` - Products []OrderProduct `gorm:"foreignKey:OrderID;references:OrderID" json:"products"` + OrderID uint `gorm:"column:order_id;primaryKey;autoIncrement" json:"order_id"` + UserID uint `gorm:"column:user_id;not null;index" json:"user_id"` + Name string `gorm:"column:name;not null" json:"name"` + CountryID uint `gorm:"column:country_id;not null" json:"country_id"` + AddressString string `gorm:"column:address_string;not null" json:"address_string"` + AddressUnparsed *AddressUnparsed `gorm:"-" json:"address_unparsed"` + Status enums.OrderStatus `gorm:"column:status;size:50;not null" json:"status"` + BasePrice float64 `gorm:"column:base_price;type:decimal(10,2);not null" json:"base_price"` + TaxIncl float64 `gorm:"column:tax_incl;type:decimal(10,2);not null" json:"tax_incl"` + TaxExcl float64 `gorm:"column:tax_excl;type:decimal(10,2);not null" json:"tax_excl"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null" json:"updated_at"` + Products []OrderProduct `gorm:"foreignKey:OrderID;references:OrderID" json:"products"` } func (CustomerOrder) TableName() string { diff --git a/app/model/orderStatusHistory.go b/app/model/orderStatusHistory.go new file mode 100644 index 0000000..0b8ae2c --- /dev/null +++ b/app/model/orderStatusHistory.go @@ -0,0 +1,20 @@ +package model + +import ( + "time" + + "git.ma-al.com/goc_daniel/b2b/app/model/enums" +) + +type OrderStatusHistory struct { + Id uint `gorm:"column:id;primaryKey;autoIncrement"` + OrderId uint `gorm:"column:order_id;not null;index:idx_order_status_history_order"` + OldStatus *enums.OrderStatus `gorm:"column:old_status;type:varchar(50)"` + NewStatus enums.OrderStatus `gorm:"column:new_status;type:varchar(50);not null"` + CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime"` + UserId uint `gorm:"column:user_id;index:idx_order_status_history_user;not null"` +} + +func (OrderStatusHistory) TableName() string { + return "b2b_order_status_history" +} diff --git a/app/repos/ordersRepo/ordersRepo.go b/app/repos/ordersRepo/ordersRepo.go index 97569e0..372c33b 100644 --- a/app/repos/ordersRepo/ordersRepo.go +++ b/app/repos/ordersRepo/ordersRepo.go @@ -5,17 +5,19 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" - constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/model/enums" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) type UIOrdersRepo interface { UserHasOrder(user_id uint, order_id uint) (bool, error) + Get(orderId uint) (*model.CustomerOrder, error) Find(user_id uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.CustomerOrder], error) - PlaceNewOrder(cart *model.CustomerCart, name string, country_id uint, address_info string, base_price float64, tax_incl float64, tax_excl float64) error + PlaceNewOrder(cart *model.CustomerCart, name string, country_id uint, address_info string, originalUserId uint, base_price float64, tax_incl float64, tax_excl float64) (*model.CustomerOrder, error) ChangeOrderAddress(order_id uint, country_id uint, address_info string) error - ChangeOrderStatus(order_id uint, status string) error + ChangeOrderStatus(orderId uint, newStatus enums.OrderStatus, userId uint) error + GetOrderStatus(orderID uint) (enums.OrderStatus, error) } type OrdersRepo struct{} @@ -37,6 +39,18 @@ func (repo *OrdersRepo) UserHasOrder(user_id uint, order_id uint) (bool, error) return amt >= 1, err } +func (repo *OrdersRepo) Get(orderId uint) (*model.CustomerOrder, error) { + var order model.CustomerOrder + + err := db.Get(). + Model(&model.CustomerOrder{}). + Preload("Products"). + Where("order_id = ?", orderId). + First(&order).Error + + return &order, err +} + func (repo *OrdersRepo) Find(user_id uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.CustomerOrder], error) { var list []model.CustomerOrder var total int64 @@ -71,13 +85,13 @@ func (repo *OrdersRepo) Find(user_id uint, p find.Paging, filt *filters.FiltersL }, nil } -func (repo *OrdersRepo) PlaceNewOrder(cart *model.CustomerCart, name string, country_id uint, address_info string, base_price float64, tax_incl float64, tax_excl float64) error { +func (repo *OrdersRepo) PlaceNewOrder(cart *model.CustomerCart, name string, country_id uint, address_info string, originalUserId uint, base_price float64, tax_incl float64, tax_excl float64) (*model.CustomerOrder, error) { order := model.CustomerOrder{ UserID: cart.UserID, Name: name, CountryID: country_id, AddressString: address_info, - Status: constdata.NEW_ORDER_STATUS, + Status: enums.OrderStatusPending, Products: make([]model.OrderProduct, 0, len(cart.Products)), } @@ -88,14 +102,35 @@ func (repo *OrdersRepo) PlaceNewOrder(cart *model.CustomerCart, name string, cou Amount: product.Amount, }) } - order.CreatedAt = time.Now() order.UpdatedAt = time.Now() order.BasePrice = base_price order.TaxIncl = tax_incl order.TaxExcl = tax_excl + tx := db.Get().Begin() + err := tx.Create(&order).Error + if err != nil { + tx.Rollback() + return nil, err + } + history := model.OrderStatusHistory{ + OrderId: order.OrderID, + OldStatus: nil, + NewStatus: enums.OrderStatusPending, + UserId: originalUserId, + } - return db.DB.Create(&order).Error + err = tx.Create(&history).Error + if err != nil { + tx.Rollback() + return nil, err + } + err = tx.Commit().Error + if err != nil { + return nil, err + } + + return &order, nil } func (repo *OrdersRepo) ChangeOrderAddress(order_id uint, country_id uint, address_info string) error { @@ -110,13 +145,48 @@ func (repo *OrdersRepo) ChangeOrderAddress(order_id uint, country_id uint, addre Error } -func (repo *OrdersRepo) ChangeOrderStatus(order_id uint, status string) error { - return db.DB. - Table("b2b_customer_orders"). - Where("order_id = ?", order_id). - Updates(map[string]interface{}{ - "status": status, - "updated_at": time.Now(), - }). - Error +func (repo *OrdersRepo) ChangeOrderStatus(orderID uint, newStatus enums.OrderStatus, userId uint) error { + tx := db.Get().Begin() + + var currentStatus enums.OrderStatus + err := tx.Table("b2b_customer_orders"). + Select("status"). + Where("order_id = ?", orderID). + Scan(¤tStatus).Error + if err != nil { + tx.Rollback() + return err + } + + err = tx.Table("b2b_customer_orders"). + Where("order_id = ?", orderID). + Update("status", string(newStatus)).Error + if err != nil { + tx.Rollback() + return err + } + + history := model.OrderStatusHistory{ + OrderId: orderID, + OldStatus: ¤tStatus, + NewStatus: newStatus, + UserId: userId, + } + + err = tx.Create(&history).Error + if err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +func (repo *OrdersRepo) GetOrderStatus(orderID uint) (enums.OrderStatus, error) { + var status enums.OrderStatus + err := db.DB.Table("b2b_customer_orders"). + Select("status"). + Where("order_id = ?", orderID). + Scan(&status).Error + return status, err } diff --git a/app/service/emailService/email.go b/app/service/emailService/email.go index 2c92117..f059359 100644 --- a/app/service/emailService/email.go +++ b/app/service/emailService/email.go @@ -12,6 +12,7 @@ import ( "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/utils/logger" "git.ma-al.com/goc_daniel/b2b/app/view" ) @@ -44,6 +45,11 @@ func getLangID(isoCode string) uint { // SendEmail sends an email to the specified recipient func (s *EmailService) SendEmail(to, subject, body string) error { if !s.config.Enabled { + logger.Debug("email service is disabled", + "service", "EmailService.SendEmail", + "to", to, + "subject", subject, + ) return fmt.Errorf("email service is disabled") } @@ -69,6 +75,12 @@ func (s *EmailService) SendEmail(to, subject, body string) error { // Send email addr := fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort) if err := smtp.SendMail(addr, auth, s.config.FromEmail, []string{to}, []byte(msg.String())); err != nil { + logger.Error("failed to send email", + "service", "EmailService.SendEmail", + "to", to, + "subject", subject, + "error", err.Error(), + ) return fmt.Errorf("failed to send email: %w", err) } @@ -120,9 +132,12 @@ func (s *EmailService) SendNewUserAdminNotification(userEmail, userName, baseURL // SendNewOrderPlacedNotification sends an email to admin when new order is placed func (s *EmailService) SendNewOrderPlacedNotification(userID uint) error { if s.config.AdminEmail == "" { - return nil // No admin email configured + logger.Warn("no admin email setup in the config", + "service", "EmailService.SendNewOrderPlacedNotification", + "user_id", userID, + ) + return nil } - subject := "New Order Created" body := s.newOrderPlacedTemplate(userID) diff --git a/app/service/orderService/orderService.go b/app/service/orderService/orderService.go index a5a90bb..2d070a7 100644 --- a/app/service/orderService/orderService.go +++ b/app/service/orderService/orderService.go @@ -3,8 +3,10 @@ package orderService import ( "strconv" + "git.ma-al.com/goc_daniel/b2b/app/actions/orderStatusActions" "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/model/enums" "git.ma-al.com/goc_daniel/b2b/app/repos/cartsRepo" "git.ma-al.com/goc_daniel/b2b/app/repos/ordersRepo" "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" @@ -23,6 +25,7 @@ type OrderService struct { productsRepo productsRepo.UIProductsRepo addressesService *addressesService.AddressesService emailService *emailService.EmailService + actionRegistry *orderStatusActions.ActionRegistry } func New() *OrderService { @@ -32,9 +35,23 @@ func New() *OrderService { productsRepo: productsRepo.New(), addressesService: addressesService.New(), emailService: emailService.NewEmailService(), + actionRegistry: &orderStatusActions.GlobalRegistry, } } +var ValidStatuses = map[enums.OrderStatus]bool{ + enums.OrderStatusPending: true, + enums.OrderStatusConfirmed: true, + enums.OrderStatusProcessing: true, + enums.OrderStatusShipped: true, + enums.OrderStatusOutForDelivery: true, + enums.OrderStatusDelivered: true, + enums.OrderStatusCancelled: true, + enums.OrderStatusReturned: true, + enums.OrderStatusRefunded: true, + enums.OrderStatusFailed: true, +} + func (s *OrderService) Find(user *model.Customer, p find.Paging, filt *filters.FiltersList) (*find.Found[model.CustomerOrder], error) { if !user.HasPermission(perms.OrdersViewAll) { // append filter to view only this user's orders @@ -63,7 +80,7 @@ func (s *OrderService) Find(user *model.Customer, p find.Paging, filt *filters.F return list, nil } -func (s *OrderService) PlaceNewOrder(user_id uint, cart_id uint, name string, country_id uint, address_info string) error { +func (s *OrderService) PlaceNewOrder(user_id uint, cart_id uint, name string, country_id uint, address_info string, originalUserId uint) error { _, err := s.addressesService.ValidateAddressJson(address_info, country_id) if err != nil { return err @@ -92,7 +109,7 @@ func (s *OrderService) PlaceNewOrder(user_id uint, cart_id uint, name string, co base_price, tax_incl, tax_excl, err := s.getOrderTotalPrice(user_id, cart_id, country_id) // all checks passed - err = s.ordersRepo.PlaceNewOrder(cart, name, country_id, address_info, base_price, tax_incl, tax_excl) + order, err := s.ordersRepo.PlaceNewOrder(cart, name, country_id, address_info, originalUserId, base_price, tax_incl, tax_excl) if err != nil { return err } @@ -109,19 +126,8 @@ func (s *OrderService) PlaceNewOrder(user_id uint, cart_id uint, name string, co ) } - // send email to admin - go func(user_id uint) { - err := s.emailService.SendNewOrderPlacedNotification(user_id) - if err != nil { - logger.Warn("failed to send new order notification", - "service", "orderService", - "user_id", user_id, - "error", err.Error(), - ) - } - }(user_id) + return s.ChangeOrderStatus(user_id, order.OrderID, enums.OrderStatusPending) - return nil } func (s *OrderService) ChangeOrderAddress(user *model.Customer, order_id uint, country_id uint, address_info string) error { @@ -144,20 +150,35 @@ func (s *OrderService) ChangeOrderAddress(user *model.Customer, order_id uint, c return s.ordersRepo.ChangeOrderAddress(order_id, country_id, address_info) } -// This is obiously just an initial version of this function -func (s *OrderService) ChangeOrderStatus(user *model.Customer, order_id uint, status string) error { - if !user.HasPermission(perms.OrdersModifyAll) { - exists, err := s.ordersRepo.UserHasOrder(user.ID, order_id) - if err != nil { - return err - } - - if !exists { - return responseErrors.ErrUserHasNoSuchOrder - } +func (s *OrderService) ChangeOrderStatus(userId, orderId uint, newStatus enums.OrderStatus) error { + order, err := s.ordersRepo.Get(orderId) + if err != nil { + return err + } + if order == nil { + return responseErrors.ErrOrderNotFound } - return s.ordersRepo.ChangeOrderStatus(order_id, status) + if !ValidStatuses[newStatus] { + return responseErrors.ErrInvalidStatus + } + + err = s.ordersRepo.ChangeOrderStatus(order.OrderID, newStatus, userId) + if err != nil { + return err + } + + actionCtx := orderStatusActions.ActionContext{ + Order: order, + UserId: &userId, + EmailService: s.emailService, + } + + go func() { + _ = s.actionRegistry.ExecuteForStatus(newStatus, actionCtx) + }() + + return nil } func (s *OrderService) getOrderTotalPrice(user_id uint, cart_id uint, country_id uint) (float64, float64, float64, error) { diff --git a/app/utils/localeExtractor/localeExtractor.go b/app/utils/localeExtractor/localeExtractor.go index 7dcd0cc..477e64d 100644 --- a/app/utils/localeExtractor/localeExtractor.go +++ b/app/utils/localeExtractor/localeExtractor.go @@ -14,6 +14,14 @@ func GetLangID(c fiber.Ctx) (uint, bool) { return user_locale.OriginalUser.LangID, true } +func GetOriginalUserID(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.ID, true +} + func GetUserID(c fiber.Ctx) (uint, bool) { user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) if !ok || user_locale.User == nil { diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 1eb735e..7b06460 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -71,6 +71,8 @@ var ( // Typed errors for orders handler ErrEmptyCart = errors.New("the cart is empty") ErrUserHasNoSuchOrder = errors.New("user does not have order with given id") + ErrInvalidStatus = errors.New("invalid order status") + ErrOrderNotFound = errors.New("order not found") // Typed errors for price reduction handler ErrInvalidReductionType = errors.New("invalid reduction type: must be 'amount' or 'percentage'") @@ -314,7 +316,8 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrMaxAmtOfAddressesReached), errors.Is(err, ErrUserHasNoSuchAddress), errors.Is(err, ErrInvalidCountryID), - errors.Is(err, ErrInvalidAddressJSON): + errors.Is(err, ErrInvalidAddressJSON), + errors.Is(err, ErrInvalidStatus): return fiber.StatusBadRequest case errors.Is(err, ErrSpecificPriceNotFound): return fiber.StatusNotFound diff --git a/bruno/api_v1/Delete Index - MeiliSearch.yml b/bruno/api_v1/Delete Index - MeiliSearch.yml index e5e011e..e8985f1 100644 --- a/bruno/api_v1/Delete Index - MeiliSearch.yml +++ b/bruno/api_v1/Delete Index - MeiliSearch.yml @@ -1,7 +1,7 @@ info: name: Delete Index - MeiliSearch type: http - seq: 7 + seq: 8 http: method: DELETE diff --git a/bruno/api_v1/cart/Add new cart.yml b/bruno/api_v1/cart/Add new cart.yml new file mode 100644 index 0000000..65cbe39 --- /dev/null +++ b/bruno/api_v1/cart/Add new cart.yml @@ -0,0 +1,19 @@ +info: + name: Add new cart + type: http + seq: 1 + +http: + method: POST + url: "{{bas_url}}/restricted/carts/add-new-cart?name=TestCart" + params: + - name: name + value: TestCart + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/cart/Add product to cart.yml b/bruno/api_v1/cart/Add product to cart.yml new file mode 100644 index 0000000..bd024b7 --- /dev/null +++ b/bruno/api_v1/cart/Add product to cart.yml @@ -0,0 +1,31 @@ +info: + name: Add product to cart + type: http + seq: 6 + +http: + method: POST + url: "{{bas_url}}/restricted/carts/add-product-to-cart?cart_id=1&product_id=51&product_attribute_id=1115&amount=1&set_amount=true" + params: + - name: cart_id + value: "1" + type: query + - name: product_id + value: "51" + type: query + - name: product_attribute_id + value: "1115" + type: query + - name: amount + value: "1" + type: query + - name: set_amount + value: "true" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/cart/Change cart name.yml b/bruno/api_v1/cart/Change cart name.yml new file mode 100644 index 0000000..2f35d17 --- /dev/null +++ b/bruno/api_v1/cart/Change cart name.yml @@ -0,0 +1,22 @@ +info: + name: Change cart name + type: http + seq: 3 + +http: + method: PATCH + url: "{{bas_url}}/restricted/carts/change-cart-name?cart_id=1&new_name=UpdatedCart" + params: + - name: cart_id + value: "1" + type: query + - name: new_name + value: UpdatedCart + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/cart/Remove cart.yml b/bruno/api_v1/cart/Remove cart.yml new file mode 100644 index 0000000..dedd77c --- /dev/null +++ b/bruno/api_v1/cart/Remove cart.yml @@ -0,0 +1,19 @@ +info: + name: Remove cart + type: http + seq: 2 + +http: + method: DELETE + url: "{{bas_url}}/restricted/carts/remove-cart?cart_id=1" + params: + - name: cart_id + value: "1" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/cart/Remove product from cart.yml b/bruno/api_v1/cart/Remove product from cart.yml new file mode 100644 index 0000000..e53654e --- /dev/null +++ b/bruno/api_v1/cart/Remove product from cart.yml @@ -0,0 +1,25 @@ +info: + name: Remove product from cart + type: http + seq: 7 + +http: + method: DELETE + url: "{{bas_url}}/restricted/carts/remove-product-from-cart?cart_id=1&product_id=51&product_attribute_id=1115" + params: + - name: cart_id + value: "1" + type: query + - name: product_id + value: "51" + type: query + - name: product_attribute_id + value: "1115" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/cart/Retrieve cart.yml b/bruno/api_v1/cart/Retrieve cart.yml new file mode 100644 index 0000000..0db04ec --- /dev/null +++ b/bruno/api_v1/cart/Retrieve cart.yml @@ -0,0 +1,19 @@ +info: + name: Retrieve cart + type: http + seq: 5 + +http: + method: GET + url: "{{bas_url}}/restricted/carts/retrieve-cart?cart_id=1" + params: + - name: cart_id + value: "1" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/cart/Retrieve carts info.yml b/bruno/api_v1/cart/Retrieve carts info.yml new file mode 100644 index 0000000..4c5254a --- /dev/null +++ b/bruno/api_v1/cart/Retrieve carts info.yml @@ -0,0 +1,15 @@ +info: + name: Retrieve carts info + type: http + seq: 4 + +http: + method: GET + url: "{{bas_url}}/restricted/carts/retrieve-carts-info" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/cart/folder.yml b/bruno/api_v1/cart/folder.yml new file mode 100644 index 0000000..fe0c591 --- /dev/null +++ b/bruno/api_v1/cart/folder.yml @@ -0,0 +1,7 @@ +info: + name: cart + type: folder + seq: 7 + +request: + auth: inherit diff --git a/bruno/api_v1/currency/folder.yml b/bruno/api_v1/currency/folder.yml index 7f299b6..a030816 100644 --- a/bruno/api_v1/currency/folder.yml +++ b/bruno/api_v1/currency/folder.yml @@ -1,7 +1,7 @@ info: name: currency type: folder - seq: 9 + seq: 10 request: auth: inherit diff --git a/bruno/api_v1/customer/folder.yml b/bruno/api_v1/customer/folder.yml index 50228ad..7649bd3 100644 --- a/bruno/api_v1/customer/folder.yml +++ b/bruno/api_v1/customer/folder.yml @@ -1,7 +1,7 @@ info: name: customer type: folder - seq: 10 + seq: 11 request: auth: inherit diff --git a/bruno/api_v1/order/Change order address.yml b/bruno/api_v1/order/Change order address.yml new file mode 100644 index 0000000..b71d6e7 --- /dev/null +++ b/bruno/api_v1/order/Change order address.yml @@ -0,0 +1,33 @@ +info: + name: Change order address + type: http + seq: 3 + +http: + method: POST + url: "{{bas_url}}/restricted/orders/change-order-address?order_id=1&country_id=1" + params: + - name: order_id + value: "1" + type: query + - name: country_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/api_v1/order/Change order status.yml b/bruno/api_v1/order/Change order status.yml new file mode 100644 index 0000000..41324af --- /dev/null +++ b/bruno/api_v1/order/Change order status.yml @@ -0,0 +1,22 @@ +info: + name: Change order status + type: http + seq: 1 + +http: + method: PATCH + url: "{{bas_url}}/restricted/orders/change-order-status?order_id=2&status=PENDING" + params: + - name: order_id + value: "2" + type: query + - name: status + value: PENDING + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/order/List.yml b/bruno/api_v1/order/List.yml new file mode 100644 index 0000000..a358aea --- /dev/null +++ b/bruno/api_v1/order/List.yml @@ -0,0 +1,25 @@ +info: + name: List + type: http + seq: 1 + +http: + method: GET + url: "{{bas_url}}/restricted/orders/list?p=1&elems=30&sort=order_id,desc" + params: + - name: p + value: "1" + type: query + - name: elems + value: "30" + type: query + - name: sort + value: order_id,desc + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/order/Place new order.yml b/bruno/api_v1/order/Place new order.yml new file mode 100644 index 0000000..10f7179 --- /dev/null +++ b/bruno/api_v1/order/Place new order.yml @@ -0,0 +1,37 @@ +info: + name: Place new order + type: http + seq: 2 + +http: + method: POST + url: "{{bas_url}}/restricted/orders/place-new-order?cart_id=1&name=Test+Order&country_id=1" + params: + - name: cart_id + value: "1" + type: query + - name: name + value: Test Order + type: query + - 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/api_v1/order/folder.yml b/bruno/api_v1/order/folder.yml new file mode 100644 index 0000000..3692c88 --- /dev/null +++ b/bruno/api_v1/order/folder.yml @@ -0,0 +1,7 @@ +info: + name: order + type: folder + seq: 13 + +request: + auth: inherit diff --git a/bruno/api_v1/product/folder.yml b/bruno/api_v1/product/folder.yml index 3957e49..068d575 100644 --- a/bruno/api_v1/product/folder.yml +++ b/bruno/api_v1/product/folder.yml @@ -1,7 +1,7 @@ info: name: product type: folder - seq: 8 + seq: 9 request: auth: inherit diff --git a/bruno/api_v1/routes/folder.yml b/bruno/api_v1/routes/folder.yml index bf44876..2c14d8c 100644 --- a/bruno/api_v1/routes/folder.yml +++ b/bruno/api_v1/routes/folder.yml @@ -1,7 +1,7 @@ info: name: routes type: folder - seq: 10 + seq: 12 request: auth: inherit diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index f8a1997..de09de9 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -462,6 +462,30 @@ END$$ DELIMITER ; +CREATE TABLE b2b_order_status_history ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + old_status VARCHAR(50) NULL, + new_status VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + user_id BIGINT UNSIGNED NULL +); + +CREATE INDEX idx_order_status_history_order + ON b2b_order_status_history(order_id); + +CREATE INDEX idx_order_status_history_user + ON b2b_order_status_history(user_id); + +ALTER TABLE b2b_order_status_history +ADD CONSTRAINT fk_order +FOREIGN KEY (order_id) REFERENCES b2b_customer_orders(order_id); + +ALTER TABLE b2b_order_status_history +ADD CONSTRAINT fk_user +FOREIGN KEY (user_id) REFERENCES b2b_customers(id); + + -- +goose Down DROP TABLE IF EXISTS b2b_addresses;