From c5f4e492c0a6aed6b06038ce93e217c121c76473 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Sat, 9 May 2026 10:08:32 +0200 Subject: [PATCH] refactor(orders): embed tracking on orders, drop shipments table realized per-shipment table is kinda bad, switched to single embedded tracking blob. since im only shipping from my house no split is needed. adds tracking_status, tracking_status_details, tracking_status_updated_at columns. reverse-backfills any historical shipments rows back onto orders (idempotent, table-guarded). UpdateTracking rewritten to write to otder columns direcly --- api/handlers/orders.go | 41 +++++++------------- api/handlers/view.go | 2 +- pkg/database/migrate.go | 53 ++++++++------------------ pkg/models/order.go | 80 ++++++++++++++++++++++++--------------- pkg/models/shipment.go | 43 --------------------- pkg/tui/account_orders.go | 40 +++++++++++++------- 6 files changed, 108 insertions(+), 151 deletions(-) delete mode 100644 pkg/models/shipment.go diff --git a/api/handlers/orders.go b/api/handlers/orders.go index f56cce7..55fb236 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -6,16 +6,15 @@ import ( "strconv" "time" - "github.com/go-chi/chi/v5" - "github.com/stripe/stripe-go/v78" - "github.com/stripe/stripe-go/v78/refund" - "gorm.io/gorm" - "terminalShop/api/middleware" "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" + + "github.com/go-chi/chi/v5" + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/refund" ) type OrderHandler struct { @@ -31,7 +30,7 @@ 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").Preload("Shipments").Order("created_at desc").Find(&orders) + db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "orders": orders, @@ -141,27 +140,16 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) { shippedAt = &now } - shipment := models.Shipment{ - OrderID: order.ID, - Carrier: req.Carrier, - TrackingNumber: req.TrackingNumber, - TrackingURL: req.TrackingURL, - Status: models.ShipmentStatusLabelCreated, - ShippedAt: shippedAt, + updates := map[string]any{ + "carrier": req.Carrier, + "tracking_number": req.TrackingNumber, + "tracking_url": req.TrackingURL, + "shipped_at": shippedAt, } - - err = db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&shipment).Error; err != nil { - return err - } - if order.Status == models.OrderStatusPaid { - if err := tx.Model(&order).Update("status", models.OrderStatusShipped).Error; err != nil { - return err - } - } - return nil - }) - if err != nil { + if order.Status == models.OrderStatusPaid { + updates["status"] = models.OrderStatusShipped + } + if err := db.Model(&order).Updates(updates).Error; err != nil { utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to record shipment", nil) return } @@ -170,7 +158,6 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) { utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "order_id": order.ID, - "shipment_id": shipment.ID, "carrier": req.Carrier, "tracking_number": req.TrackingNumber, "status": order.Status, diff --git a/api/handlers/view.go b/api/handlers/view.go index 87770ba..110d45f 100644 --- a/api/handlers/view.go +++ b/api/handlers/view.go @@ -38,7 +38,7 @@ 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").Preload("Shipments").Order("created_at desc").Find(&orders) + db.Where("user_id = ?", userID).Preload("Items").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/database/migrate.go b/pkg/database/migrate.go index 8da11b9..c76bf14 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -3,8 +3,6 @@ package database import ( "fmt" "log" - "time" - "terminalShop/pkg/models" "gorm.io/gorm" @@ -25,50 +23,31 @@ func Migrate(db *gorm.DB) error { &models.Order{}, // Completed purchases &models.OrderItem{}, // Line items within orders &models.Address{}, // Saved addresses - &models.Shipment{}, ); err != nil { return fmt.Errorf("migration failed: %w", err) } - if err := backfillShipments(db); err != nil { - return fmt.Errorf("shipments backfill failed: %w", err) + if err := backfillTrackingFromShipments(db); err != nil { + return fmt.Errorf("backfill tracking failed: %w", err) } log.Println("Database migrations completed successfully") return nil } -func backfillShipments(db *gorm.DB) error { - if !db.Migrator().HasColumn(&models.Order{}, "carrier") { +func backfillTrackingFromShipments(db *gorm.DB) error { + if !db.Migrator().HasTable("shipments") { return nil } - type legacy struct { - ID uint - Carrier string - TrackingNumber string - TrackingURL string - ShippedAt *time.Time - } - var rows []legacy - if err := db.Raw(`SELECT o.id, o.carrier, o.tracking_number, o.tracking_url, o.shipped_at -FROM orders o -WHERE o.carrier <> '' -AND NOT EXISTS(SELECT 1 FROM shipments s WHERE s.order_id = o.id) -`).Scan(&rows).Error; err != nil { - return err - } - for _, r := range rows { - s := models.Shipment{ - OrderID: r.ID, - Carrier: r.Carrier, - TrackingNumber: r.TrackingNumber, - TrackingURL: r.TrackingURL, - Status: models.ShipmentStatusInTransit, - ShippedAt: r.ShippedAt, - } - if err := db.Create(&s).Error; err != nil { - return fmt.Errorf("backfill order %d: %w", r.ID, err) - } - log.Printf("backfilled shipment for order %d", r.ID) - } - return nil + return db.Exec(` +UPDATE orders SET + carrier = COALESCE(NULLIF(orders.carrier, ''), s.carrier), + tracking_number = COALESCE(NULLIF(orders.tracking_number, ''), s.tracking_number), + tracking_url = COALESCE(NULLIF(orders.tracking_url, ''), s.tracking_url), + shipped_at = COALESCE(orders.shipped_at, s.shipped_at) +FROM (SELECT order_id, + carrier, tracking_number, tracking_url, shipped_at + FROM shipments + WHERE deleted_at IS NULL) s +WHERE orders.id = s.order_id +`).Error } diff --git a/pkg/models/order.go b/pkg/models/order.go index 1a11135..f753332 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -10,42 +10,62 @@ import ( type OrderStatus string const ( - OrderStatusPending OrderStatus = "pending" - OrderStatusPaid OrderStatus = "paid" - OrderStatusShipped OrderStatus = "shipped" - // OrderStatusDelivered OrderStatus = "delivered" - // OrderStatusCancelled OrderStatus = "cancelled" - OrderStatusRefunded OrderStatus = "refunded" - OrderStatusFailed OrderStatus = "failed" + OrderStatusPending OrderStatus = "pending" + OrderStatusPaid OrderStatus = "paid" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" + OrderStatusCancelled OrderStatus = "cancelled" + OrderStatusRefunded OrderStatus = "refunded" + OrderStatusFailed OrderStatus = "failed" +) + +type TrackingStatus string + +const ( + TrackingStatusUnknown TrackingStatus = "" + TrackingStatusPreTransit TrackingStatus = "pre_transit" + TrackingStatusTransit TrackingStatus = "transit" + TrackingStatusDelivered TrackingStatus = "delivered" + TrackingStatusReturned TrackingStatus = "returned" + TrackingStatusFailure TrackingStatus = "failure" ) // Order represents a completed purchase. type Order struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` - CardID uint `gorm:"not null" json:"card_id"` - StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID - Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"` - Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents - ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents - Total int `gorm:"not null" json:"total"` // Amount in cents - ShippingName string `gorm:"size:255" json:"shipping_name"` // The name of the person the shipment is tied to - ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to - ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason - ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to - ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to - ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to - ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to - ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + CardID uint `gorm:"not null" json:"card_id"` + StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID + Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"` + Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents + ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents + Total int `gorm:"not null" json:"total"` // Amount in cents + ShippingName string `gorm:"size:255" json:"shipping_name"` // The name of the person the shipment is tied to + ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to + ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason + ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to + ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to + ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to + ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to + ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to + + // Shipment / tracking - single embedded blob (since its just one warehouse (my house)) + Carrier string `gorm:"size:100" json:"carrier"` + TrackingNumber string `gorm:"size:255;index" json:"tracking_number"` + TrackingURL string `gorm:"size:512" json:"tracking_url"` + ShippedAt *time.Time `json:"shipped_at"` + TrackingStatus TrackingStatus `gorm:"size:50" json:"tracking_status"` + TrackingStatusDetails string `gorm:"size:512" json:"tracking_status_details"` + TrackingStatusUpdatedAt *time.Time `json:"tracking_status_updated_at"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + 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"` - Shipments []Shipment `gorm:"foreignKey:OrderID" json:"shipments,omitempty"` + User User `gorm:"foreignKey:UserID" json:"-"` + Card Card `gorm:"foreignKey:CardID" json:"-"` + Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` } // TableName specifies the table name for the Order model. diff --git a/pkg/models/shipment.go b/pkg/models/shipment.go deleted file mode 100644 index ea6db7a..0000000 --- a/pkg/models/shipment.go +++ /dev/null @@ -1,43 +0,0 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// ShipmentStatus represents the states a shipment can be in -type ShipmentStatus string - -const ( - ShipmentStatusPending ShipmentStatus = "pending" - ShipmentStatusLabelCreated ShipmentStatus = "label_created" - ShipmentStatusInTransit ShipmentStatus = "in_transit" - ShipmentStatusDelivered ShipmentStatus = "delivered" - ShipmentStatusException ShipmentStatus = "exception" -) - -// Shipment represents a single physical dispatch attached to an order -// Most orders have one, the scheme permits multiple (split shipments) -type Shipment struct { - ID uint `gorm:"primaryKey" json:"id"` - OrderID uint `gorm:"not null;index" json:"order_id"` - Carrier string `gorm:"size:100" json:"carrier"` - TrackingNumber string `gorm:"size:255" json:"tracking_number"` - TrackingURL string `gorm:"size:512" json:"tracking_url"` - Status ShipmentStatus `gorm:"size:50, not null; default:'pending'" json:"status"` - ShippoTransactionID *string `gorm:"size:255;uniqueIndex" json:"shippo_transaction_id,omitempty"` - LabelPDFURL string `gorm:"size:255" json:"label_pdf_url,omitempty,omitempty"` - CostCents int `gorm:"default:0" json:"cost_cents"` - ShippedAt *time.Time `json:"shipped_at"` - DeliveredAt *time.Time `json:"delivered_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - Order Order `gorm:"foreignKey:OrderID" json:"-"` -} - -func (Shipment) TableName() string { - return "shipments" -} diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 48ef269..1b09547 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -61,8 +61,6 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool) dimStyle = m.theme.TextAccent() } - statusStyle := m.theme.TextHighlight().Bold(true) - // Line 1: "Order #N" left, "$X.XX" right orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID)) total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0)) @@ -78,7 +76,7 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool) // Line 2: date + status date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006")) - status := statusStyle.Render(strings.ToUpper(string(order.Status))) + status := m.statusStyle(order.Status).Bold(true).Render(strings.ToUpper(string(order.Status))) line2 := date + " " + status boxContent := line1 + "\n" + line2 @@ -104,7 +102,7 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { // Status and date b.WriteString(labelStyle.Render("Status: ")) - b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status)))) + b.WriteString(m.statusStyle(order.Status).Render(strings.ToUpper(string(order.Status)))) b.WriteString("\n") b.WriteString(labelStyle.Render("Date: ")) b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM"))) @@ -136,21 +134,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { b.WriteString("\n\n") // Shipping (only when at least one shipment exists) - if len(order.Shipments) > 0 { - s := order.Shipments[0] + if order.Carrier != "" { b.WriteString(labelStyle.Render("Shipping")) b.WriteString("\n") - b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", s.Carrier))) + b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", order.Carrier))) b.WriteString("\n") - b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", s.TrackingNumber))) + b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", order.TrackingNumber))) b.WriteString("\n") - if s.ShippedAt != nil { - b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", - "Shipped", s.ShippedAt.Format("Jan 02 2006")))) + if order.TrackingStatus != "" { + b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Status", string(order.TrackingStatus)))) b.WriteString("\n") } - if s.TrackingURL != "" { - b.WriteString(dimStyle.Render(" " + s.TrackingURL)) + if order.ShippedAt != nil { + b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", + "Shipped", order.ShippedAt.Format("Jan 02 2006")))) + b.WriteString("\n") + } + if order.TrackingURL != "" { + b.WriteString(dimStyle.Render(" " + order.TrackingURL)) b.WriteString("\n") } } @@ -181,3 +182,16 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { return b.String() } + +func (m Model) statusStyle(s models.OrderStatus) lipgloss.Style { + switch s { + case models.OrderStatusDelivered: + return m.theme.TextSuccess() + case models.OrderStatusShipped: + return m.theme.TextBrand() + case models.OrderStatusFailed, models.OrderStatusCancelled, models.OrderStatusRefunded: + return m.theme.TextError() + + } + return m.theme.TextHighlight() +}