From 48d65c48fa039fbe3ddb47d345bc5e000e613c98 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sun, 24 May 2026 20:14:14 +0200 Subject: [PATCH] feat(tui): render customer-facing event timeline in order detail veiew (#7) Pulls events off the new order_events table and shows them as a timeline block in the order detail view: Paid, Label created, In transit, Delivered (etc) with timestamps. Filters out ops-only events like payment_critical, label_orphaned, and tracking_marked_manually so the customer never sees internal plumbing. DisplayLabel on OrderEvent decides what's customer-visible and what's redacted, so the same order_events rows can later power an admin view with no filter. --- api/handlers/orders.go | 9 +++++-- api/handlers/view.go | 7 +++++- pkg/models/order.go | 7 +++--- pkg/models/order_event.go | 49 ++++++++++++++++++++++++++++++++++++++- pkg/tui/account_orders.go | 18 ++++++++++++++ 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 2ceb366..b2bd03b 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -5,11 +5,12 @@ import ( "net/http" "os" "strconv" + "time" + "terminalShop/pkg/bring" "terminalShop/pkg/notify" "terminalShop/pkg/shipping" "terminalShop/pkg/shippo" - "time" "terminalShop/api/middleware" "terminalShop/pkg/audit" @@ -20,6 +21,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/refund" + "gorm.io/gorm" ) type OrderHandler struct { @@ -43,7 +45,10 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromContext(r.Context()) var orders []models.Order - db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) + db.Where("user_id = ?", userID). + Preload("Items"). + Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }). + Order("created_at desc").Find(&orders) utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "orders": orders, diff --git a/api/handlers/view.go b/api/handlers/view.go index 110d45f..9bd00ad 100644 --- a/api/handlers/view.go +++ b/api/handlers/view.go @@ -7,6 +7,8 @@ import ( "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" + + "gorm.io/gorm" ) type ViewHandler struct{} @@ -38,7 +40,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { db.Where("user_id = ?", userID).Find(&cards) var orders []models.Order - db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders) + db.Where("user_id = ?", userID). + Preload("Items"). + Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }). + Order("created_at DESC").Find(&orders) var cartItems []models.CartItem if cart.ID != 0 { db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems) diff --git a/pkg/models/order.go b/pkg/models/order.go index 1c78825..9831983 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -71,9 +71,10 @@ type Order struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel // Associations - User User `gorm:"foreignKey:UserID" json:"-"` - Card Card `gorm:"foreignKey:CardID" json:"-"` - Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` + User User `gorm:"foreignKey:UserID" json:"-"` + Card Card `gorm:"foreignKey:CardID" json:"-"` + Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` + Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"` } // TableName specifies the table name for the Order model. diff --git a/pkg/models/order_event.go b/pkg/models/order_event.go index 95c4fc1..39dc058 100644 --- a/pkg/models/order_event.go +++ b/pkg/models/order_event.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "encoding/json" + "time" +) type OrderEvent struct { ID uint `gorm:"primaryKey" json:"id"` @@ -14,3 +17,47 @@ type OrderEvent struct { func (OrderEvent) TableName() string { return "order_events" } + +func (e *OrderEvent) PayloadMap() map[string]any { + if e.Payload == "" { + return nil + } + var m map[string]any + if err := json.Unmarshal([]byte(e.Payload), &m); err != nil { + return nil + } + return m +} + +func (e *OrderEvent) DisplayLabel() (string, bool) { + switch e.Type { + case "order_paid": + return "Paid", true + case "label_purchased": + return "Label created", true + case "order_delivered": + return "Delivered", true + case "order_refunded": + return "Refunded", true + case "order_failed": + return "Payment failed", true + case "tracking_updated": + status, _ := e.PayloadMap()["status"].(string) + return trackingLabel(status) + } + return "", false +} + +func trackingLabel(status string) (string, bool) { + switch status { + case "transit": + return "In transit", true + case "failure": + return "Delivery issue", true + case "returned": + return "Returned to sender", true + case "pre_transit": + return "Label scanned by carrier", true + } + return "", false +} diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 44aaf7d..69de3dc 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -201,6 +201,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { } } + visible := make([]models.OrderEvent, 0, len(order.Events)) + for _, event := range order.Events { + if _, ok := event.DisplayLabel(); ok { + visible = append(visible, event) + } + } + if len(visible) > 0 { + b.WriteString(labelStyle.Render("Timeline")) + b.WriteString("\n") + for _, event := range visible { + label, _ := event.DisplayLabel() + when := event.CreatedAt.Format("Jan 02 3:04 PM") + b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when)))) + b.WriteString("\n") + } + b.WriteString("\n") + } + // Shipping address b.WriteString(labelStyle.Render("Ship To")) b.WriteString("\n")